mirror of
https://github.com/uroni/urbackup_backend.git
synced 2025-10-26 11:36:50 +00:00
Add user management page (only creates admin users, currently)
This commit is contained in:
parent
396eb4973e
commit
05e4a9c249
@ -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,
|
||||
|
||||
@ -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 };
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -102,6 +102,11 @@
|
||||
align-items: var(--repel-vertical-alignment, center);
|
||||
gap: var(--gutter);
|
||||
}
|
||||
|
||||
.centered {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@layer blocks {
|
||||
|
||||
@ -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));
|
||||
@ -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"]),
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
.dialog {
|
||||
max-width: 50ch;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-block-start: var(--spacingL);
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
));
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
}
|
||||
@ -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 = [];
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
24
urbackupserver/www2/src/utils/formatUserRights.test.ts
Normal file
24
urbackupserver/www2/src/utils/formatUserRights.test.ts
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
17
urbackupserver/www2/src/utils/formatUserRights.ts
Normal file
17
urbackupserver/www2/src/utils/formatUserRights.ts
Normal 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];
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user