Merge branch 'dev' into ajnart/fix-duplicate-users

This commit is contained in:
Meier Lukas
2024-05-19 23:08:04 +02:00
288 changed files with 11536 additions and 5631 deletions

View File

@@ -48,13 +48,7 @@ export const HeroBanner = () => {
<Title>Homarr Dashboard</Title>
</Group>
</Stack>
<Box
className={classes.scrollContainer}
w={"30%"}
top={0}
right={0}
pos="absolute"
>
<Box className={classes.scrollContainer} w={"30%"} top={0} right={0} pos="absolute">
<Grid>
{Array(countIconGroups)
.fill(0)
@@ -67,24 +61,12 @@ export const HeroBanner = () => {
}}
>
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image
key={`grid-column-${columnIndex}-scroll-1-${index}`}
src={icon}
radius="md"
w={50}
h={50}
/>
<Image key={`grid-column-${columnIndex}-scroll-1-${index}`} src={icon} radius="md" w={50} h={50} />
))}
{/* This is used for making the animation seem seamless */}
{arrayInChunks[columnIndex]?.map((icon, index) => (
<Image
key={`grid-column-${columnIndex}-scroll-2-${index}`}
src={icon}
radius="md"
w={50}
h={50}
/>
<Image key={`grid-column-${columnIndex}-scroll-2-${index}`} src={icon} radius="md" w={50} h={50} />
))}
</Stack>
</GridCol>

View File

@@ -1,6 +1,3 @@
.contributorCard {
background-color: light-dark(
var(--mantine-color-gray-1),
var(--mantine-color-dark-5)
);
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}

View File

@@ -21,6 +21,7 @@ import { setStaticParamsLocale } from "next-international/server";
import { getScopedI18n, getStaticParams } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { getPackageAttributesAsync } from "~/versions/package-reader";
import contributorsData from "../../../../../../../static-data/contributors.json";
import translatorsData from "../../../../../../../static-data/translators.json";
@@ -29,10 +30,9 @@ import classes from "./about.module.css";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
title: createMetaTitle(t("metaTitle")),
};
}
@@ -55,9 +55,7 @@ export default async function AboutPage({ params: { locale } }: PageProps) {
<Title order={1} tt="uppercase">
Homarr
</Title>
<Title order={2}>
{t("version", { version: attributes.version })}
</Title>
<Title order={2}>{t("version", { version: attributes.version })}</Title>
</Stack>
</Group>
</Center>
@@ -150,20 +148,10 @@ interface GenericContributorLinkCardProps {
image: string;
}
const GenericContributorLinkCard = ({
name,
image,
link,
}: GenericContributorLinkCardProps) => {
const GenericContributorLinkCard = ({ name, image, link }: GenericContributorLinkCardProps) => {
return (
<AspectRatio ratio={1}>
<Card
className={classes.contributorCard}
component="a"
href={link}
target="_blank"
w={100}
>
<Card className={classes.contributorCard} component="a" href={link} target="_blank" w={100}>
<Stack align="center">
<Avatar src={image} alt={name} size={40} display="block" />
<Text lineClamp={1} size="sm">

View File

@@ -7,10 +7,7 @@ import { IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
@@ -52,13 +49,7 @@ export const AppDeleteButton = ({ app }: AppDeleteButtonProps) => {
}, [app, mutate, t, openConfirmModal]);
return (
<ActionIcon
loading={isPending}
variant="subtle"
color="red"
onClick={onClick}
aria-label="Delete app"
>
<ActionIcon loading={isPending} variant="subtle" color="red" onClick={onClick} aria-label="Delete app">
<IconTrash color="red" size={16} stroke={1.5} />
</ActionIcon>
);

View File

@@ -3,7 +3,7 @@
import Link from "next/link";
import { Button, Group, Stack, Textarea, TextInput } from "@mantine/core";
import { useForm, zodResolver } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
@@ -21,30 +21,23 @@ interface AppFormProps {
}
export const AppForm = (props: AppFormProps) => {
const { submitButtonTranslation, handleSubmit, initialValues, isPending } =
props;
const { submitButtonTranslation, handleSubmit, initialValues, isPending } = props;
const t = useI18n();
const form = useForm({
const form = useZodForm(validation.app.manage, {
initialValues: initialValues ?? {
name: "",
description: "",
iconUrl: "",
href: "",
},
validate: zodResolver(validation.app.manage),
});
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput {...form.getInputProps("name")} withAsterisk label="Name" />
<IconPicker
initialValue={initialValues?.iconUrl}
onChange={(iconUrl) => {
form.setFieldValue("iconUrl", iconUrl);
}}
/>
<IconPicker initialValue={initialValues?.iconUrl} {...form.getInputProps("iconUrl")} />
<Textarea {...form.getInputProps("description")} label="Description" />
<TextInput {...form.getInputProps("href")} label="URL" />

View File

@@ -5,10 +5,7 @@ import { useRouter } from "next/navigation";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
@@ -52,10 +49,7 @@ export const AppEditForm = ({ app }: AppEditFormProps) => {
[mutate, app.id],
);
const submitButtonTranslation = useCallback(
(t: TranslationFunction) => t("common.action.save"),
[],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.save"), []);
return (
<AppForm

View File

@@ -4,10 +4,7 @@ import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { TranslationFunction } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import type { validation, z } from "@homarr/validation";
@@ -44,16 +41,9 @@ export const AppNewForm = () => {
[mutate],
);
const submitButtonTranslation = useCallback(
(t: TranslationFunction) => t("common.action.create"),
[],
);
const submitButtonTranslation = useCallback((t: TranslationFunction) => t("common.action.create"), []);
return (
<AppForm
submitButtonTranslation={submitButtonTranslation}
handleSubmit={handleSubmit}
isPending={isPending}
/>
<AppForm submitButtonTranslation={submitButtonTranslation} handleSubmit={handleSubmit} isPending={isPending} />
);
};

View File

@@ -107,9 +107,7 @@ const AppNoResults = async () => {
<Text fw={500} size="lg">
{t("app.page.list.noResults.title")}
</Text>
<Anchor href="/manage/apps/new">
{t("app.page.list.noResults.description")}
</Anchor>
<Anchor href="/manage/apps/new">{t("app.page.list.noResults.description")}</Anchor>
</Stack>
</Card>
);

View File

@@ -3,7 +3,7 @@
import { useCallback } from "react";
import Link from "next/link";
import { Menu } from "@mantine/core";
import { IconSettings, IconTrash } from "@tabler/icons-react";
import { IconHome, IconSettings, IconTrash } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
@@ -21,18 +21,11 @@ const iconProps = {
interface BoardCardMenuDropdownProps {
board: Pick<
RouterOutputs["board"]["getAllBoards"][number],
| "id"
| "name"
| "creator"
| "userPermissions"
| "groupPermissions"
| "isPublic"
"id" | "name" | "creator" | "userPermissions" | "groupPermissions" | "isPublic"
>;
}
export const BoardCardMenuDropdown = ({
board,
}: BoardCardMenuDropdownProps) => {
export const BoardCardMenuDropdown = ({ board }: BoardCardMenuDropdownProps) => {
const t = useScopedI18n("management.page.board.action");
const tCommon = useScopedI18n("common");
@@ -40,7 +33,13 @@ export const BoardCardMenuDropdown = ({
const { openConfirmModal } = useConfirmModal();
const { mutateAsync, isPending } = clientApi.board.deleteBoard.useMutation({
const setHomeBoardMutation = clientApi.board.setHomeBoard.useMutation({
onSettled: async () => {
// Revalidate all as it's part of the user settings, /boards page and board manage page
await revalidatePathActionAsync("/");
},
});
const deleteBoardMutation = clientApi.board.deleteBoard.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/boards");
},
@@ -54,23 +53,33 @@ export const BoardCardMenuDropdown = ({
}),
// eslint-disable-next-line no-restricted-syntax
onConfirm: async () => {
await mutateAsync({
await deleteBoardMutation.mutateAsync({
id: board.id,
});
},
});
}, [board.id, board.name, mutateAsync, openConfirmModal, t]);
}, [board.id, board.name, deleteBoardMutation, openConfirmModal, t]);
const handleSetHomeBoard = useCallback(async () => {
await setHomeBoardMutation.mutateAsync({ id: board.id });
}, [board.id, setHomeBoardMutation]);
return (
<Menu.Dropdown>
<Menu.Item onClick={handleSetHomeBoard} leftSection={<IconHome {...iconProps} />}>
{t("setHomeBoard.label")}
</Menu.Item>
{hasChangeAccess && (
<Menu.Item
component={Link}
href={`/boards/${board.name}/settings`}
leftSection={<IconSettings {...iconProps} />}
>
{t("settings.label")}
</Menu.Item>
<>
<Menu.Divider />
<Menu.Item
component={Link}
href={`/boards/${board.name}/settings`}
leftSection={<IconSettings {...iconProps} />}
>
{t("settings.label")}
</Menu.Item>
</>
)}
{hasFullAccess && (
<>
@@ -80,7 +89,7 @@ export const BoardCardMenuDropdown = ({
c="red.7"
leftSection={<IconTrash {...iconProps} />}
onClick={handleDeletion}
disabled={isPending}
disabled={deleteBoardMutation.isPending}
>
{t("delete.label")}
</Menu.Item>

View File

@@ -37,11 +37,7 @@ export const CreateBoardButton = ({ boardNames }: CreateBoardButtonProps) => {
}, [mutateAsync, boardNames, openModal]);
return (
<Button
leftSection={<IconCategoryPlus size="1rem" />}
onClick={onClick}
loading={isPending}
>
<Button leftSection={<IconCategoryPlus size="1rem" />} onClick={onClick} loading={isPending}>
{t("management.page.board.action.new.label")}
</Button>
);

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import {
ActionIcon,
Badge,
Button,
Card,
CardSection,
@@ -13,7 +14,7 @@ import {
Title,
Tooltip,
} from "@mantine/core";
import { IconDotsVertical, IconLock, IconWorld } from "@tabler/icons-react";
import { IconDotsVertical, IconHomeFilled, IconLock, IconWorld } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -53,8 +54,7 @@ interface BoardCardProps {
const BoardCard = async ({ board }: BoardCardProps) => {
const t = await getScopedI18n("management.page.board");
const { hasChangeAccess: isMenuVisible } =
await getBoardPermissionsAsync(board);
const { hasChangeAccess: isMenuVisible } = await getBoardPermissionsAsync(board);
const visibility = board.isPublic ? "public" : "private";
const VisibilityIcon = board.isPublic ? IconWorld : IconLock;
@@ -71,23 +71,28 @@ const BoardCard = async ({ board }: BoardCardProps) => {
</Text>
</Group>
{board.creator && (
<Group gap="xs">
<UserAvatar user={board.creator} size="sm" />
<Text>{board.creator?.name}</Text>
</Group>
)}
<Group>
{board.isHome && (
<Tooltip label={t("action.setHomeBoard.badge.tooltip")}>
<Badge tt="none" color="yellow" variant="light" leftSection={<IconHomeFilled size=".7rem" />}>
{t("action.setHomeBoard.badge.label")}
</Badge>
</Tooltip>
)}
{board.creator && (
<Group gap="xs">
<UserAvatar user={board.creator} size="sm" />
<Text>{board.creator?.name}</Text>
</Group>
)}
</Group>
</Group>
</CardSection>
<CardSection p="sm">
<Group wrap="nowrap">
<Button
component={Link}
href={`/boards/${board.name}`}
variant="default"
fullWidth
>
<Button component={Link} href={`/boards/${board.name}`} variant="default" fullWidth>
{t("action.open.label")}
</Button>
{isMenuVisible && (

View File

@@ -6,10 +6,7 @@ import { IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "../../../revalidatePathAction";
@@ -19,10 +16,7 @@ interface DeleteIntegrationActionButtonProps {
integration: { id: string; name: string };
}
export const DeleteIntegrationActionButton = ({
count,
integration,
}: DeleteIntegrationActionButtonProps) => {
export const DeleteIntegrationActionButton = ({ count, integration }: DeleteIntegrationActionButtonProps) => {
const t = useScopedI18n("integration.page.delete");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();

View File

@@ -2,17 +2,7 @@
import { useState } from "react";
import { useParams } from "next/navigation";
import {
ActionIcon,
Avatar,
Button,
Card,
Collapse,
Group,
Kbd,
Stack,
Text,
} from "@mantine/core";
import { ActionIcon, Avatar, Button, Card, Collapse, Group, Kbd, Stack, Text } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
import { IconEye, IconEyeOff } from "@tabler/icons-react";
import dayjs from "dayjs";
@@ -36,8 +26,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
const params = useParams<{ locale: string }>();
const t = useI18n();
const { isPublic } = integrationSecretKindObject[secret.kind];
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] =
useDisclosure(false);
const [publicSecretDisplayOpened, { toggle: togglePublicSecretDisplay }] = useDisclosure(false);
const [editMode, setEditMode] = useState(false);
const DisplayIcon = publicSecretDisplayOpened ? IconEye : IconEyeOff;
const KindIcon = integrationSecretIcons[secret.kind];
@@ -50,9 +39,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
<Avatar>
<KindIcon size={16} />
</Avatar>
<Text fw={500}>
{t(`integration.secrets.kind.${secret.kind}.label`)}
</Text>
<Text fw={500}>{t(`integration.secrets.kind.${secret.kind}.label`)}</Text>
{publicSecretDisplayOpened ? <Kbd>{secret.value}</Kbd> : null}
</Group>
<Group>
@@ -62,11 +49,7 @@ export const SecretCard = ({ secret, children, onCancel }: SecretCardProps) => {
})}
</Text>
{isPublic ? (
<ActionIcon
color="gray"
variant="subtle"
onClick={togglePublicSecretDisplay}
>
<ActionIcon color="gray" variant="subtle" onClick={togglePublicSecretDisplay}>
<DisplayIcon size={16} stroke={1.5} />
</ActionIcon>
) : null}

View File

@@ -10,6 +10,7 @@ import { useI18n } from "@homarr/translation/client";
import { integrationSecretIcons } from "./_integration-secret-icons";
interface IntegrationSecretInputProps {
withAsterisk?: boolean;
label?: string;
kind: IntegrationSecretKind;
value?: string;
@@ -41,10 +42,7 @@ const PublicSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
);
};
const PrivateSecretInput = ({
kind,
...props
}: IntegrationSecretInputProps) => {
const PrivateSecretInput = ({ kind, ...props }: IntegrationSecretInputProps) => {
const t = useI18n();
const Icon = integrationSecretIcons[kind];

View File

@@ -6,10 +6,7 @@ import { IconCheck, IconInfoCircle, IconX } from "@tabler/icons-react";
import type { RouterInputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
interface UseTestConnectionDirtyProps {
@@ -20,10 +17,7 @@ interface UseTestConnectionDirtyProps {
};
}
export const useTestConnectionDirty = ({
defaultDirty,
initialFormValue,
}: UseTestConnectionDirtyProps) => {
export const useTestConnectionDirty = ({ defaultDirty, initialFormValue }: UseTestConnectionDirtyProps) => {
const [isDirty, setIsDirty] = useState(defaultDirty);
const prevFormValueRef = useRef(initialFormValue);
@@ -36,10 +30,7 @@ export const useTestConnectionDirty = ({
prevFormValueRef.current.url !== values.url ||
!prevFormValueRef.current.secrets
.map((secret) => secret.value)
.every(
(secretValue, index) =>
values.secrets[index]?.value === secretValue,
)
.every((secretValue, index) => values.secrets[index]?.value === secretValue)
) {
setIsDirty(true);
return;
@@ -62,14 +53,9 @@ interface TestConnectionProps {
integration: RouterInputs["integration"]["testConnection"] & { name: string };
}
export const TestConnection = ({
integration,
removeDirty,
isDirty,
}: TestConnectionProps) => {
export const TestConnection = ({ integration, removeDirty, isDirty }: TestConnectionProps) => {
const t = useScopedI18n("integration.testConnection");
const { mutateAsync, ...mutation } =
clientApi.integration.testConnection.useMutation();
const { mutateAsync, ...mutation } = clientApi.integration.testConnection.useMutation();
return (
<Group>
@@ -125,13 +111,7 @@ interface TestConnectionIconProps {
size: number;
}
const TestConnectionIcon = ({
isDirty,
isPending,
isSuccess,
isError,
size,
}: TestConnectionIconProps) => {
const TestConnectionIcon = ({ isDirty, isPending, isSuccess, isError, size }: TestConnectionIconProps) => {
if (isPending) return <Loader color="blue" size={size} />;
if (isDirty) return null;
if (isSuccess) return <IconCheck size={size} stroke={1.5} color="green" />;
@@ -142,12 +122,7 @@ const TestConnectionIcon = ({
export const TestConnectionNoticeAlert = () => {
const t = useI18n();
return (
<Alert
variant="light"
color="yellow"
title="Test Connection"
icon={<IconInfoCircle />}
>
<Alert variant="light" color="yellow" title="Test Connection" icon={<IconInfoCircle />}>
{t("integration.testConnection.alertNotice")}
</Alert>
);

View File

@@ -6,16 +6,10 @@ import { Button, Fieldset, Group, Stack, TextInput } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
getAllSecretKindOptions,
getDefaultSecretKinds,
} from "@homarr/definitions";
import { useForm, zodResolver } from "@homarr/form";
import { getAllSecretKindOptions, getDefaultSecretKinds } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
@@ -23,11 +17,7 @@ import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
import { SecretCard } from "../../_integration-secret-card";
import { IntegrationSecretInput } from "../../_integration-secret-inputs";
import {
TestConnection,
TestConnectionNoticeAlert,
useTestConnectionDirty,
} from "../../_integration-test-connection";
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../../_integration-test-connection";
interface EditIntegrationForm {
integration: RouterOutputs["integration"]["byId"];
@@ -45,8 +35,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
url: integration.url,
secrets: secretsKinds.map((kind) => ({
kind,
value:
integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
value: integration.secrets.find((secret) => secret.kind === kind)?.value ?? "",
})),
};
const { isDirty, onValuesChange, removeDirty } = useTestConnectionDirty({
@@ -55,16 +44,13 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
});
const router = useRouter();
const form = useForm<FormType>({
const form = useZodForm(validation.integration.update.omit({ id: true }), {
initialValues: initialFormValues,
validate: zodResolver(validation.integration.update.omit({ id: true })),
onValuesChange,
});
const { mutateAsync, isPending } = clientApi.integration.update.useMutation();
const secretsMap = new Map(
integration.secrets.map((secret) => [secret.kind, secret]),
);
const secretsMap = new Map(integration.secrets.map((secret) => [secret.kind, secret]));
const handleSubmitAsync = async (values: FormType) => {
if (isDirty) return;
@@ -83,9 +69,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
title: t("integration.page.edit.notification.success.title"),
message: t("integration.page.edit.notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations").then(() =>
router.push("/manage/integrations"),
);
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
onError: () => {
showErrorNotification({
@@ -102,15 +86,9 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
<Stack>
<TestConnectionNoticeAlert />
<TextInput
label={t("integration.field.name.label")}
{...form.getInputProps("name")}
/>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
<TextInput
label={t("integration.field.url.label")}
{...form.getInputProps("url")}
/>
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
<Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm">
@@ -121,10 +99,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
onCancel={() =>
new Promise((res) => {
// When nothing changed, just close the secret card
if (
(form.values.secrets[index]?.value ?? "") ===
(secretsMap.get(kind)?.value ?? "")
) {
if ((form.values.secrets[index]?.value ?? "") === (secretsMap.get(kind)?.value ?? "")) {
return res(true);
}
openConfirmModal({
@@ -132,10 +107,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
children: t("integration.secrets.reset.message"),
onCancel: () => res(false),
onConfirm: () => {
form.setFieldValue(
`secrets.${index}.value`,
secretsMap.get(kind)!.value ?? "",
);
form.setFieldValue(`secrets.${index}.value`, secretsMap.get(kind)!.value ?? "");
res(true);
},
});
@@ -164,11 +136,7 @@ export const EditIntegrationForm = ({ integration }: EditIntegrationForm) => {
}}
/>
<Group>
<Button
variant="default"
component={Link}
href="/manage/integrations"
>
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending} disabled={isDirty}>

View File

@@ -11,9 +11,7 @@ interface EditIntegrationPageProps {
params: { id: string };
}
export default async function EditIntegrationPage({
params,
}: EditIntegrationPageProps) {
export default async function EditIntegrationPage({ params }: EditIntegrationPageProps) {
const t = await getScopedI18n("integration.page.edit");
const integration = await api.integration.byId({ id: params.id });
@@ -22,9 +20,7 @@ export default async function EditIntegrationPage({
<Stack>
<Group align="center">
<IntegrationAvatar kind={integration.kind} size="md" />
<Title>
{t("title", { name: getIntegrationName(integration.kind) })}
</Title>
<Title>{t("title", { name: getIntegrationName(integration.kind) })}</Title>
</Group>
<EditIntegrationForm integration={integration} />
</Stack>

View File

@@ -16,9 +16,7 @@ export const IntegrationCreateDropdownContent = () => {
const [search, setSearch] = useState("");
const filteredKinds = useMemo(() => {
return integrationKinds.filter((kind) =>
kind.includes(search.toLowerCase()),
);
return integrationKinds.filter((kind) => kind.includes(search.toLowerCase()));
}, [search]);
const handleSearch = React.useCallback(
@@ -38,11 +36,7 @@ export const IntegrationCreateDropdownContent = () => {
{filteredKinds.length > 0 ? (
<ScrollArea.Autosize mah={384}>
{filteredKinds.map((kind) => (
<Menu.Item
component={Link}
href={`/manage/integrations/new?kind=${kind}`}
key={kind}
>
<Menu.Item component={Link} href={`/manage/integrations/new?kind=${kind}`} key={kind}>
<Group>
<IntegrationAvatar kind={kind} size="sm" />
<Text size="sm">{getIntegrationName(kind)}</Text>

View File

@@ -3,37 +3,20 @@
import { useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Button,
Fieldset,
Group,
SegmentedControl,
Stack,
TextInput,
} from "@mantine/core";
import { Button, Fieldset, Group, SegmentedControl, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type {
IntegrationKind,
IntegrationSecretKind,
} from "@homarr/definitions";
import type { IntegrationKind, IntegrationSecretKind } from "@homarr/definitions";
import { getAllSecretKindOptions } from "@homarr/definitions";
import type { UseFormReturnType } from "@homarr/form";
import { useForm, zodResolver } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { z } from "@homarr/validation";
import { validation } from "@homarr/validation";
import { IntegrationSecretInput } from "../_integration-secret-inputs";
import {
TestConnection,
TestConnectionNoticeAlert,
useTestConnectionDirty,
} from "../_integration-test-connection";
import { TestConnection, TestConnectionNoticeAlert, useTestConnectionDirty } from "../_integration-test-connection";
import { revalidatePathActionAsync } from "../../../../revalidatePathAction";
interface NewIntegrationFormProps {
@@ -42,9 +25,7 @@ interface NewIntegrationFormProps {
};
}
export const NewIntegrationForm = ({
searchParams,
}: NewIntegrationFormProps) => {
export const NewIntegrationForm = ({ searchParams }: NewIntegrationFormProps) => {
const t = useI18n();
const secretKinds = getAllSecretKindOptions(searchParams.kind);
const initialFormValues = {
@@ -60,9 +41,8 @@ export const NewIntegrationForm = ({
initialFormValue: initialFormValues,
});
const router = useRouter();
const form = useForm<FormType>({
const form = useZodForm(validation.integration.create.omit({ kind: true }), {
initialValues: initialFormValues,
validate: zodResolver(validation.integration.create.omit({ kind: true })),
onValuesChange,
});
const { mutateAsync, isPending } = clientApi.integration.create.useMutation();
@@ -80,9 +60,7 @@ export const NewIntegrationForm = ({
title: t("integration.page.create.notification.success.title"),
message: t("integration.page.create.notification.success.message"),
});
void revalidatePathActionAsync("/manage/integrations").then(() =>
router.push("/manage/integrations"),
);
void revalidatePathActionAsync("/manage/integrations").then(() => router.push("/manage/integrations"));
},
onError: () => {
showErrorNotification({
@@ -99,26 +77,16 @@ export const NewIntegrationForm = ({
<Stack>
<TestConnectionNoticeAlert />
<TextInput
label={t("integration.field.name.label")}
{...form.getInputProps("name")}
/>
<TextInput withAsterisk label={t("integration.field.name.label")} {...form.getInputProps("name")} />
<TextInput
label={t("integration.field.url.label")}
{...form.getInputProps("url")}
/>
<TextInput withAsterisk label={t("integration.field.url.label")} {...form.getInputProps("url")} />
<Fieldset legend={t("integration.secrets.title")}>
<Stack gap="sm">
{secretKinds.length > 1 && (
<SecretKindsSegmentedControl
secretKinds={secretKinds}
form={form}
/>
)}
{secretKinds.length > 1 && <SecretKindsSegmentedControl secretKinds={secretKinds} form={form} />}
{form.values.secrets.map(({ kind }, index) => (
<IntegrationSecretInput
withAsterisk
key={kind}
kind={kind}
{...form.getInputProps(`secrets.${index}.value`)}
@@ -139,11 +107,7 @@ export const NewIntegrationForm = ({
/>
<Group>
<Button
variant="default"
component={Link}
href="/manage/integrations"
>
<Button variant="default" component={Link} href="/manage/integrations">
{t("common.action.backToOverview")}
</Button>
<Button type="submit" loading={isPending} disabled={isDirty}>
@@ -161,10 +125,7 @@ interface SecretKindsSegmentedControlProps {
form: UseFormReturnType<FormType, (values: FormType) => FormType>;
}
const SecretKindsSegmentedControl = ({
secretKinds,
form,
}: SecretKindsSegmentedControlProps) => {
const SecretKindsSegmentedControl = ({ secretKinds, form }: SecretKindsSegmentedControlProps) => {
const t = useScopedI18n("integration.secrets");
const secretKindGroups = secretKinds.map((kinds) => ({
@@ -184,13 +145,7 @@ const SecretKindsSegmentedControl = ({
[form],
);
return (
<SegmentedControl
fullWidth
data={secretKindGroups}
onChange={onChange}
></SegmentedControl>
);
return <SegmentedControl fullWidth data={secretKindGroups} onChange={onChange}></SegmentedControl>;
};
type FormType = Omit<z.infer<typeof validation.integration.create>, "kind">;

View File

@@ -16,12 +16,8 @@ interface NewIntegrationPageProps {
};
}
export default async function IntegrationsNewPage({
searchParams,
}: NewIntegrationPageProps) {
const result = z
.enum([integrationKinds[0]!, ...integrationKinds.slice(1)])
.safeParse(searchParams.kind);
export default async function IntegrationsNewPage({ searchParams }: NewIntegrationPageProps) {
const result = z.enum([integrationKinds[0]!, ...integrationKinds.slice(1)]).safeParse(searchParams.kind);
if (!result.success) {
notFound();
}

View File

@@ -43,9 +43,7 @@ interface IntegrationsPageProps {
};
}
export default async function IntegrationsPage({
searchParams,
}: IntegrationsPageProps) {
export default async function IntegrationsPage({ searchParams }: IntegrationsPageProps) {
const integrations = await api.integration.all();
const t = await getScopedI18n("integration");
@@ -54,18 +52,9 @@ export default async function IntegrationsPage({
<Stack>
<Group justify="space-between" align="center">
<Title>{t("page.list.title")}</Title>
<Menu
width={256}
trapFocus
position="bottom-start"
withinPortal
shadow="md"
keepMounted={false}
>
<Menu width={256} trapFocus position="bottom-start" withinPortal shadow="md" keepMounted={false}>
<MenuTarget>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>
{t("action.create")}
</Button>
<Button rightSection={<IconChevronDown size={16} stroke={1.5} />}>{t("action.create")}</Button>
</MenuTarget>
<MenuDropdown>
<IntegrationCreateDropdownContent />
@@ -73,10 +62,7 @@ export default async function IntegrationsPage({
</Menu>
</Group>
<IntegrationList
integrations={integrations}
activeTab={searchParams.tab}
/>
<IntegrationList integrations={integrations} activeTab={searchParams.tab} />
</Stack>
</Container>
);
@@ -87,10 +73,7 @@ interface IntegrationListProps {
activeTab?: IntegrationKind;
}
const IntegrationList = async ({
integrations,
activeTab,
}: IntegrationListProps) => {
const IntegrationList = async ({ integrations, activeTab }: IntegrationListProps) => {
const t = await getScopedI18n("integration");
if (integrations.length === 0) {
@@ -134,12 +117,7 @@ const IntegrationList = async ({
<TableTr key={integration.id}>
<TableTd>{integration.name}</TableTd>
<TableTd>
<Anchor
href={integration.url}
target="_blank"
rel="noreferrer"
size="sm"
>
<Anchor href={integration.url} target="_blank" rel="noreferrer" size="sm">
{integration.url}
</Anchor>
</TableTd>
@@ -155,10 +133,7 @@ const IntegrationList = async ({
>
<IconPencil size={16} stroke={1.5} />
</ActionIcon>
<DeleteIntegrationActionButton
integration={integration}
count={integrations.length}
/>
<DeleteIntegrationActionButton integration={integration} count={integrations.length} />
</ActionIconGroup>
</Group>
</TableTd>

View File

@@ -14,6 +14,7 @@ import {
IconMailForward,
IconPlug,
IconQuestionMark,
IconSettings,
IconTool,
IconUser,
IconUsers,
@@ -87,6 +88,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
},
],
},
{
label: t("items.settings"),
href: "/manage/settings",
icon: IconSettings,
},
{
label: t("items.help.label"),
icon: IconQuestionMark,

View File

@@ -5,6 +5,7 @@ import { IconArrowRight } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { HeroBanner } from "./_components/hero-banner";
interface LinkProps {
@@ -16,10 +17,9 @@ interface LinkProps {
export async function generateMetadata() {
const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
title: createMetaTitle(t("metaTitle")),
};
}
@@ -71,12 +71,7 @@ export default async function ManagementPage() {
<Space h="md" />
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3 }}>
{links.map((link, index) => (
<Card
component={Link}
href={link.href}
key={`link-${index}`}
withBorder
>
<Card component={Link} href={link.href} key={`link-${index}`} withBorder>
<Group justify="space-between">
<Group>
<Text size="2.4rem" fw="bolder">

View File

@@ -0,0 +1,113 @@
"use client";
import type { ReactNode } from "react";
import React from "react";
import type { MantineSpacing } from "@mantine/core";
import { Card, Group, LoadingOverlay, Stack, Switch, Text, Title, UnstyledButton } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { UseFormReturnType } from "@homarr/form";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import { useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
interface AnalyticsSettingsProps {
initialData: typeof defaultServerSettings.analytics;
}
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.analytics");
const form = useForm({
initialValues: initialData,
onValuesChange: (updatedValues, _) => {
if (!form.isValid()) {
return;
}
if (
!updatedValues.enableGeneral &&
(updatedValues.enableWidgetData || updatedValues.enableIntegrationData || updatedValues.enableUserData)
) {
updatedValues.enableIntegrationData = false;
updatedValues.enableUserData = false;
updatedValues.enableWidgetData = false;
}
void mutateAsync({
settingsKey: "analytics",
value: updatedValues,
});
},
});
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/settings");
},
});
return (
<>
<Title order={2}>{t("title")}</Title>
<Card pos="relative" withBorder>
<LoadingOverlay visible={isPending} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Stack>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
<SwitchSetting
form={form}
formKey="enableIntegrationData"
ms="xl"
title={t("integrationData.title")}
text={t("integrationData.text")}
/>
<SwitchSetting
form={form}
formKey="enableWidgetData"
ms="xl"
title={t("widgetData.title")}
text={t("widgetData.text")}
/>
<SwitchSetting
form={form}
formKey="enableUserData"
ms="xl"
title={t("usersData.title")}
text={t("usersData.text")}
/>
</Stack>
</Card>
</>
);
};
const SwitchSetting = ({
form,
ms,
title,
text,
formKey,
}: {
form: UseFormReturnType<typeof defaultServerSettings.analytics>;
formKey: keyof typeof defaultServerSettings.analytics;
ms?: MantineSpacing;
title: string;
text: ReactNode;
}) => {
const handleClick = React.useCallback(() => {
form.setFieldValue(formKey, !form.values[formKey]);
}, [form, formKey]);
return (
<UnstyledButton onClick={handleClick}>
<Group ms={ms} justify="space-between" gap="lg" align="center" wrap="nowrap">
<Stack gap={0}>
<Text fw="bold">{title}</Text>
<Text c="gray.5">{text}</Text>
</Stack>
<Switch {...form.getInputProps(formKey, { type: "checkbox" })} />
</Group>
</UnstyledButton>
);
};

View File

@@ -0,0 +1,26 @@
import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { AnalyticsSettings } from "./_components/analytics.settings";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
};
}
export default async function SettingsPage() {
const serverSettings = await api.serverSettings.getAll();
const t = await getScopedI18n("management.page.settings");
return (
<Stack>
<Title order={1}>{t("title")}</Title>
<AnalyticsSettings initialData={serverSettings.analytics} />
</Stack>
);
}

View File

@@ -7,13 +7,13 @@ import "@xterm/xterm/css/xterm.css";
import dynamic from "next/dynamic";
import { fullHeightWithoutHeaderAndFooter } from "~/constants";
import { createMetaTitle } from "~/metadata";
export async function generateMetadata() {
const t = await getScopedI18n("management");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
title: createMetaTitle(t("metaTitle")),
};
}
@@ -23,12 +23,7 @@ const ClientSideTerminalComponent = dynamic(() => import("./terminal"), {
export default function LogsManagementPage() {
return (
<Box
style={{ borderRadius: 6 }}
h={fullHeightWithoutHeaderAndFooter}
p="md"
bg="black"
>
<Box style={{ borderRadius: 6 }} h={fullHeightWithoutHeaderAndFooter} p="md" bg="black">
<ClientSideTerminalComponent />
</Box>
);

View File

@@ -16,9 +16,7 @@ export default function TerminalComponent() {
const terminalRef = useRef<Terminal>();
clientApi.log.subscribe.useSubscription(undefined, {
onData(data) {
terminalRef.current?.writeln(
`${data.timestamp} ${data.level} ${data.message}`,
);
terminalRef.current?.writeln(`${data.timestamp} ${data.level} ${data.message}`);
terminalRef.current?.refresh(0, terminalRef.current.rows - 1);
},
onError(err) {
@@ -55,12 +53,5 @@ export default function TerminalComponent() {
canvasAddon.dispose();
};
}, []);
return (
<Box
ref={ref}
id="terminal"
className={classes.outerTerminal}
h="100%"
></Box>
);
return <Box ref={ref} id="terminal" className={classes.outerTerminal} h="100%"></Box>;
}

View File

@@ -1,9 +1,6 @@
import type { Session } from "@homarr/auth";
export const canAccessUserEditPage = (
session: Session | null,
userId: string,
) => {
export const canAccessUserEditPage = (session: Session | null, userId: string) => {
if (!session) {
return false;
}

View File

@@ -18,14 +18,11 @@ interface DeleteUserButtonProps {
export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
const t = useI18n();
const router = useRouter();
const { mutateAsync: mutateUserDeletionAsync } =
clientApi.user.delete.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/users").then(() =>
router.push("/manage/users"),
);
},
});
const { mutateAsync: mutateUserDeletionAsync } = clientApi.user.delete.useMutation({
async onSuccess() {
await revalidatePathActionAsync("/manage/users").then(() => router.push("/manage/users"));
},
});
const { openConfirmModal } = useConfirmModal();
const handleDelete = useCallback(

View File

@@ -8,10 +8,7 @@ import { IconPencil, IconPhotoEdit, IconPhotoX } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserAvatar } from "@homarr/ui";
@@ -46,25 +43,18 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
showSuccessNotification({
message: tManageAvatar(
"changeImage.notification.success.message",
),
message: tManageAvatar("changeImage.notification.success.message"),
});
},
onError(error) {
if (error.shape?.data.code === "BAD_REQUEST") {
showErrorNotification({
title: tManageAvatar("changeImage.notification.toLarge.title"),
message: tManageAvatar(
"changeImage.notification.toLarge.message",
{ size: "256KB" },
),
message: tManageAvatar("changeImage.notification.toLarge.message", { size: "256KB" }),
});
} else {
showErrorNotification({
message: tManageAvatar(
"changeImage.notification.error.message",
),
message: tManageAvatar("changeImage.notification.error.message"),
});
}
},
@@ -89,16 +79,12 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
// Revalidate all as the avatar is used in multiple places
await revalidatePathActionAsync("/");
showSuccessNotification({
message: tManageAvatar(
"removeImage.notification.success.message",
),
message: tManageAvatar("removeImage.notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tManageAvatar(
"removeImage.notification.error.message",
),
message: tManageAvatar("removeImage.notification.error.message"),
});
},
},
@@ -109,13 +95,7 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
return (
<Box pos="relative">
<Menu
opened={opened}
keepMounted
onChange={toggle}
position="bottom-start"
withArrow
>
<Menu opened={opened} keepMounted onChange={toggle} position="bottom-start" withArrow>
<Menu.Target>
<UnstyledButton onClick={toggle}>
<UserAvatar user={user} size={200} />
@@ -134,24 +114,15 @@ export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown>
<FileButton
onChange={handleAvatarChange}
accept="image/png,image/jpeg,image/webp,image/gif"
>
<FileButton onChange={handleAvatarChange} accept="image/png,image/jpeg,image/webp,image/gif">
{(props) => (
<Menu.Item
{...props}
leftSection={<IconPhotoEdit size={16} stroke={1.5} />}
>
<Menu.Item {...props} leftSection={<IconPhotoEdit size={16} stroke={1.5} />}>
{tManageAvatar("changeImage.label")}
</Menu.Item>
)}
</FileButton>
{user.image && (
<Menu.Item
onClick={handleRemoveAvatar}
leftSection={<IconPhotoX size={16} stroke={1.5} />}
>
<Menu.Item onClick={handleRemoveAvatar} leftSection={<IconPhotoX size={16} stroke={1.5} />}>
{tManageAvatar("removeImage.label")}
</Menu.Item>
)}

View File

@@ -5,11 +5,8 @@ import { Button, Group, Stack, TextInput } from "@mantine/core";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useForm, zodResolver } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
@@ -21,15 +18,6 @@ interface UserProfileFormProps {
export const UserProfileForm = ({ user }: UserProfileFormProps) => {
const t = useI18n();
const form = useForm({
initialValues: {
name: user.name ?? "",
email: user.email ?? "",
},
validate: zodResolver(validation.user.editProfile.omit({ id: true })),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
async onSettled() {
await revalidatePathActionAsync("/manage/users");
@@ -56,6 +44,12 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
});
},
});
const form = useZodForm(validation.user.editProfile.omit({ id: true }), {
initialValues: {
name: user.name ?? "",
email: user.email ?? "",
},
});
const handleSubmit = useCallback(
(values: FormType) => {
@@ -70,23 +64,11 @@ export const UserProfileForm = ({ user }: UserProfileFormProps) => {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("user.field.username.label")}
withAsterisk
{...form.getInputProps("name")}
/>
<TextInput
label={t("user.field.email.label")}
{...form.getInputProps("email")}
/>
<TextInput label={t("user.field.username.label")} withAsterisk {...form.getInputProps("name")} />
<TextInput label={t("user.field.email.label")} {...form.getInputProps("email")} />
<Group justify="end">
<Button
type="submit"
color="teal"
disabled={!form.isDirty()}
loading={isPending}
>
<Button type="submit" color="teal" disabled={!form.isDirty()} loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>

View File

@@ -0,0 +1,12 @@
import { Stack, Title } from "@mantine/core";
import { LanguageCombobox } from "~/components/language/language-combobox";
export const ProfileLanguageChange = () => {
return (
<Stack mb="lg">
<Title order={2}>Language & Region</Title>
<LanguageCombobox />
</Stack>
);
};

View File

@@ -5,15 +5,14 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import {
DangerZoneItem,
DangerZoneRoot,
} from "~/components/manage/danger-zone";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { DeleteUserButton } from "./_delete-user-button";
import { UserProfileAvatarForm } from "./_profile-avatar-form";
import { UserProfileForm } from "./_profile-form";
import { canAccessUserEditPage } from "./access";
import { createMetaTitle } from "~/metadata";
import { canAccessUserEditPage } from "../access";
import { DeleteUserButton } from "./_components/_delete-user-button";
import { UserProfileAvatarForm } from "./_components/_profile-avatar-form";
import { UserProfileForm } from "./_components/_profile-form";
import { ProfileLanguageChange } from "./_components/_profile-language-change";
interface Props {
params: {
@@ -34,10 +33,9 @@ export async function generateMetadata({ params }: Props) {
}
const t = await getScopedI18n("management.page.user.edit");
const metaTitle = `${t("metaTitle", { username: user?.name })} • Homarr`;
return {
title: metaTitle,
title: createMetaTitle(t("metaTitle", { username: user?.name })),
};
}
@@ -67,6 +65,8 @@ export default async function EditUserPage({ params }: Props) {
</Box>
</Group>
<ProfileLanguageChange />
<DangerZoneRoot>
<DangerZoneItem
label={t("user.action.delete.label")}

View File

@@ -1,16 +1,7 @@
import type { PropsWithChildren } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import {
Button,
Container,
Grid,
GridCol,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { Button, Container, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
import { IconSettings, IconShieldLock } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
@@ -26,16 +17,11 @@ interface LayoutProps {
params: { userId: string };
}
export default async function Layout({
children,
params,
}: PropsWithChildren<LayoutProps>) {
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
const session = await auth();
const t = await getI18n();
const tUser = await getScopedI18n("management.page.user");
const user = await api.user
.getById({ userId: params.userId })
.catch(catchTrpcNotFound);
const user = await api.user.getById({ userId: params.userId }).catch(catchTrpcNotFound);
if (!canAccessUserEditPage(session, user.id)) {
notFound();
@@ -54,12 +40,7 @@ export default async function Layout({
</Stack>
</Group>
{session?.user.permissions.includes("admin") && (
<Button
component={Link}
href="/manage/users"
color="gray"
variant="light"
>
<Button component={Link} href="/manage/users" color="gray" variant="light">
{tUser("back")}
</Button>
)}
@@ -69,7 +50,7 @@ export default async function Layout({
<Stack>
<Stack gap={0}>
<NavigationLink
href={`/manage/users/${params.userId}`}
href={`/manage/users/${params.userId}/general`}
label={tUser("setting.general.title")}
icon={<IconSettings size="1rem" stroke={1.5} />}
/>

View File

@@ -5,11 +5,8 @@ import { Button, Fieldset, Group, PasswordInput, Stack } from "@mantine/core";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useForm, zodResolver } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
@@ -37,15 +34,13 @@ export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => {
});
},
});
const form = useForm<FormType>({
const form = useZodForm(validation.user.changePassword, {
initialValues: {
previousPassword: "",
/* Require previous password if the current user want's to change his password */
previousPassword: session?.user.id === user.id ? "" : "_",
password: "",
confirmPassword: "",
},
validate: zodResolver(validation.user.changePassword),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const handleSubmit = (values: FormType) => {
@@ -76,11 +71,7 @@ export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => {
/>
)}
<PasswordInput
withAsterisk
label={t("user.field.password.label")}
{...form.getInputProps("password")}
/>
<PasswordInput withAsterisk label={t("user.field.password.label")} {...form.getInputProps("password")} />
<PasswordInput
withAsterisk

View File

@@ -7,7 +7,7 @@ import { getScopedI18n } from "@homarr/translation/server";
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
import { canAccessUserEditPage } from "../access";
import { ChangePasswordForm } from "./_change-password-form";
import { ChangePasswordForm } from "./_components/_change-password-form";
interface Props {
params: {
@@ -17,9 +17,7 @@ interface Props {
export default async function UserSecurityPage({ params }: Props) {
const session = await auth();
const tSecurity = await getScopedI18n(
"management.page.user.setting.security",
);
const tSecurity = await getScopedI18n("management.page.user.setting.security");
const user = await api.user
.getById({
userId: params.userId,

View File

@@ -15,25 +15,21 @@ interface UserListComponentProps {
initialUserList: RouterOutputs["user"]["getAll"];
}
export const UserListComponent = ({
initialUserList,
}: UserListComponentProps) => {
export const UserListComponent = ({ initialUserList }: UserListComponentProps) => {
const tUserList = useScopedI18n("management.page.user.list");
const t = useI18n();
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
initialData: initialUserList,
});
const columns = useMemo<
MRT_ColumnDef<RouterOutputs["user"]["getAll"][number]>[]
>(
const columns = useMemo<MRT_ColumnDef<RouterOutputs["user"]["getAll"][number]>[]>(
() => [
{
accessorKey: "name",
header: t("user.field.username.label"),
grow: 100,
Cell: ({ renderedCellValue, row }) => (
<Link href={`/manage/users/${row.original.id}`}>
<Link href={`/manage/users/${row.original.id}/general`}>
<Group>
<Avatar size="sm"></Avatar>
{renderedCellValue}

View File

@@ -1,23 +1,15 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import {
Avatar,
Card,
PasswordInput,
Stack,
Stepper,
Text,
TextInput,
Title,
} from "@mantine/core";
import { Avatar, Card, PasswordInput, Stack, Stepper, Text, TextInput, Title } from "@mantine/core";
import { IconUserCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useForm, zodResolver } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { validation, z } from "@homarr/validation";
import { createCustomErrorParams } from "@homarr/validation/form";
import { StepperNavigationComponent } from "./stepper-navigation.component";
@@ -28,14 +20,10 @@ export const UserCreateStepperComponent = () => {
const stepperMax = 4;
const [active, setActive] = useState(0);
const nextStep = useCallback(
() =>
setActive((current) => (current < stepperMax ? current + 1 : current)),
[setActive],
);
const prevStep = useCallback(
() => setActive((current) => (current > 0 ? current - 1 : current)),
() => setActive((current) => (current < stepperMax ? current + 1 : current)),
[setActive],
);
const prevStep = useCallback(() => setActive((current) => (current > 0 ? current - 1 : current)), [setActive]);
const hasNext = active < stepperMax;
const hasPrevious = active > 0;
@@ -50,49 +38,40 @@ export const UserCreateStepperComponent = () => {
},
});
const generalForm = useForm({
initialValues: {
username: "",
email: undefined,
const generalForm = useZodForm(
z.object({
username: z.string().min(1),
email: z.string().email().or(z.string().length(0).optional()),
}),
{
initialValues: {
username: "",
email: "",
},
},
validate: zodResolver(
z.object({
username: z.string().min(1),
email: z.string().email().or(z.string().length(0).optional()),
}),
),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const securityForm = useForm({
initialValues: {
password: "",
confirmPassword: "",
},
validate: zodResolver(
z
.object({
password: validation.user.password,
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match",
}),
),
validateInputOnBlur: true,
validateInputOnChange: true,
});
const allForms = useMemo(
() => [generalForm, securityForm],
[generalForm, securityForm],
);
const isCurrentFormValid = allForms[active]
? (allForms[active]!.isValid satisfies () => boolean)
: () => true;
const securityForm = useZodForm(
z
.object({
password: validation.user.password,
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
path: ["confirmPassword"],
params: createCustomErrorParams("passwordsDoNotMatch"),
}),
{
initialValues: {
password: "",
confirmPassword: "",
},
},
);
const allForms = useMemo(() => [generalForm, securityForm], [generalForm, securityForm]);
const isCurrentFormValid = allForms[active] ? (allForms[active]!.isValid satisfies () => boolean) : () => true;
const canNavigateToNextStep = isCurrentFormValid();
const controlledGoToNextStep = useCallback(async () => {
@@ -117,12 +96,7 @@ export const UserCreateStepperComponent = () => {
return (
<>
<Title mb="md">{t("title")}</Title>
<Stepper
active={active}
onStepClick={setActive}
allowNextStepsSelect={false}
mb="md"
>
<Stepper active={active} onStepClick={setActive} allowNextStepsSelect={false} mb="md">
<Stepper.Step
label={t("step.personalInformation.label")}
allowStepSelect={false}
@@ -139,20 +113,12 @@ export const UserCreateStepperComponent = () => {
{...generalForm.getInputProps("username")}
/>
<TextInput
label={tUserField("email.label")}
variant="filled"
{...generalForm.getInputProps("email")}
/>
<TextInput label={tUserField("email.label")} variant="filled" {...generalForm.getInputProps("email")} />
</Stack>
</Card>
</form>
</Stepper.Step>
<Stepper.Step
label={t("step.security.label")}
allowStepSelect={false}
allowStepClick={false}
>
<Stepper.Step label={t("step.security.label")} allowStepSelect={false} allowStepClick={false}>
<form>
<Card p="xl">
<Stack gap="md">
@@ -180,11 +146,7 @@ export const UserCreateStepperComponent = () => {
>
3
</Stepper.Step>
<Stepper.Step
label={t("step.review.label")}
allowStepSelect={false}
allowStepClick={false}
>
<Stepper.Step label={t("step.review.label")} allowStepSelect={false} allowStepClick={false}>
<Card p="xl">
<Stack maw={300} align="center" mx="auto">
<Avatar size="xl">{generalForm.values.username}</Avatar>

View File

@@ -1,11 +1,6 @@
import Link from "next/link";
import { Button, Card, Group } from "@mantine/core";
import {
IconArrowBackUp,
IconArrowLeft,
IconArrowRight,
IconRotate,
} from "@tabler/icons-react";
import { IconArrowBackUp, IconArrowLeft, IconArrowRight, IconRotate } from "@tabler/icons-react";
import { useI18n } from "@homarr/translation/client";
@@ -51,18 +46,10 @@ export const StepperNavigationComponent = ({
</Group>
) : (
<Group justify="end" wrap="nowrap">
<Button
variant="light"
leftSection={<IconRotate size="1rem" />}
onClick={reset}
>
<Button variant="light" leftSection={<IconRotate size="1rem" />} onClick={reset}>
{t("management.page.user.create.action.createAnother")}
</Button>
<Button
leftSection={<IconArrowBackUp size="1rem" />}
component={Link}
href="/manage/users"
>
<Button leftSection={<IconArrowBackUp size="1rem" />} component={Link} href="/manage/users">
{t("management.page.user.create.action.back")}
</Button>
</Group>

View File

@@ -1,13 +1,13 @@
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { UserCreateStepperComponent } from "./_components/create-user-stepper";
export async function generateMetadata() {
const t = await getScopedI18n("management.page.user.create");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
title: createMetaTitle(t("metaTitle")),
};
}

View File

@@ -6,10 +6,7 @@ import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
@@ -63,15 +60,7 @@ export const DeleteGroup = ({ group }: DeleteGroupProps) => {
);
},
});
}, [
tDelete,
tRoot,
openConfirmModal,
group.id,
group.name,
mutateAsync,
router,
]);
}, [tDelete, tRoot, openConfirmModal, group.id, group.name, mutateAsync, router]);
return (
<Button variant="subtle" color="red" onClick={handleDeletion}>

View File

@@ -14,13 +14,5 @@ interface NavigationLinkProps {
export const NavigationLink = ({ href, icon, label }: NavigationLinkProps) => {
const pathName = usePathname();
return (
<NavLink
component={Link}
href={href}
active={pathName === href}
label={label}
leftSection={icon}
/>
);
return <NavLink component={Link} href={href} active={pathName === href} label={label} leftSection={icon} />;
};

View File

@@ -4,12 +4,10 @@ import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
@@ -23,7 +21,7 @@ interface RenameGroupFormProps {
export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.updateGroup.useMutation();
const form = useForm<FormType>({
const form = useZodForm(validation.group.update.pick({ name: true }), {
initialValues: {
name: group.name,
},
@@ -63,10 +61,7 @@ export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("group.field.name")}
{...form.getInputProps("name")}
/>
<TextInput label={t("group.field.name")} {...form.getInputProps("name")} />
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>

View File

@@ -5,10 +5,7 @@ import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access/user-select-modal";
@@ -21,9 +18,7 @@ interface TransferGroupOwnershipProps {
};
}
export const TransferGroupOwnership = ({
group,
}: TransferGroupOwnershipProps) => {
export const TransferGroupOwnership = ({ group }: TransferGroupOwnershipProps) => {
const tTransfer = useScopedI18n("group.action.transfer");
const tRoot = useI18n();
const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId);
@@ -77,16 +72,7 @@ export const TransferGroupOwnership = ({
title: tTransfer("label"),
},
);
}, [
group.id,
group.name,
innerOwnerId,
mutateAsync,
openConfirmModal,
openModal,
tRoot,
tTransfer,
]);
}, [group.id, group.name, innerOwnerId, mutateAsync, openConfirmModal, openModal, tRoot, tTransfer]);
return (
<Button variant="subtle" color="red" onClick={handleTransfer}>

View File

@@ -1,15 +1,6 @@
import type { PropsWithChildren } from "react";
import Link from "next/link";
import {
Button,
Container,
Grid,
GridCol,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { Button, Container, Grid, GridCol, Group, Stack, Text, Title } from "@mantine/core";
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
@@ -21,10 +12,7 @@ interface LayoutProps {
params: { id: string };
}
export default async function Layout({
children,
params,
}: PropsWithChildren<LayoutProps>) {
export default async function Layout({ children, params }: PropsWithChildren<LayoutProps>) {
const t = await getI18n();
const tGroup = await getScopedI18n("management.page.group");
const group = await api.group.getById({ id: params.id });
@@ -38,12 +26,7 @@ export default async function Layout({
<Title order={3}>{group.name}</Title>
<Text c="gray.5">{t("group.name")}</Text>
</Stack>
<Button
component={Link}
href="/manage/users/groups"
color="gray"
variant="light"
>
<Button component={Link} href="/manage/users/groups" color="gray" variant="light">
{tGroup("back")}
</Button>
</Group>

View File

@@ -15,10 +15,7 @@ interface AddGroupMemberProps {
presentUserIds: string[];
}
export const AddGroupMember = ({
groupId,
presentUserIds,
}: AddGroupMemberProps) => {
export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps) => {
const tMembersAdd = useScopedI18n("group.action.addMember");
const { mutateAsync } = clientApi.group.addMember.useMutation();
const { openModal } = useModalAction(UserSelectModal);
@@ -32,9 +29,7 @@ export const AddGroupMember = ({
userId: id,
groupId,
});
await revalidatePathActionAsync(
`/manage/users/groups/${groupId}}/members`,
);
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
},
presentUserIds,
},

View File

@@ -14,10 +14,7 @@ interface RemoveGroupMemberProps {
user: { id: string; name: string | null };
}
export const RemoveGroupMember = ({
groupId,
user,
}: RemoveGroupMemberProps) => {
export const RemoveGroupMember = ({ groupId, user }: RemoveGroupMemberProps) => {
const t = useI18n();
const tRemoveMember = useScopedI18n("group.action.removeMember");
const { mutateAsync } = clientApi.group.removeMember.useMutation();
@@ -35,27 +32,13 @@ export const RemoveGroupMember = ({
groupId,
userId: user.id,
});
await revalidatePathActionAsync(
`/manage/users/groups/${groupId}/members`,
);
await revalidatePathActionAsync(`/manage/users/groups/${groupId}/members`);
},
});
}, [
openConfirmModal,
mutateAsync,
groupId,
user.id,
user.name,
tRemoveMember,
]);
}, [openConfirmModal, mutateAsync, groupId, user.id, user.name, tRemoveMember]);
return (
<Button
variant="subtle"
color="red.9"
size="compact-sm"
onClick={handleRemove}
>
<Button variant="subtle" color="red.9" size="compact-sm" onClick={handleRemove}>
{t("common.action.remove")}
</Button>
);

View File

@@ -1,16 +1,5 @@
import Link from "next/link";
import {
Anchor,
Center,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTr,
Text,
Title,
} from "@mantine/core";
import { Anchor, Center, Group, Stack, Table, TableTbody, TableTd, TableTr, Text, Title } from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
@@ -29,20 +18,13 @@ interface GroupsDetailPageProps {
};
}
export default async function GroupsDetailPage({
params,
searchParams,
}: GroupsDetailPageProps) {
export default async function GroupsDetailPage({ params, searchParams }: GroupsDetailPageProps) {
const t = await getI18n();
const tMembers = await getScopedI18n("management.page.group.setting.members");
const group = await api.group.getById({ id: params.id });
const filteredMembers = searchParams.search
? group.members.filter((member) =>
member.name
?.toLowerCase()
.includes(searchParams.search!.trim().toLowerCase()),
)
? group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase()))
: group.members;
return (
@@ -56,10 +38,7 @@ export default async function GroupsDetailPage({
})}
defaultValue={searchParams.search}
/>
<AddGroupMember
groupId={group.id}
presentUserIds={group.members.map((member) => member.id)}
/>
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
</Group>
{filteredMembers.length === 0 && (
<Center py="sm">

View File

@@ -3,10 +3,7 @@ import { Stack, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import {
DangerZoneItem,
DangerZoneRoot,
} from "~/components/manage/danger-zone";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
import { DeleteGroup } from "./_delete-group";
import { RenameGroupForm } from "./_rename-group-form";
import { TransferGroupOwnership } from "./_transfer-group-ownership";
@@ -17,9 +14,7 @@ interface GroupsDetailPageProps {
};
}
export default async function GroupsDetailPage({
params,
}: GroupsDetailPageProps) {
export default async function GroupsDetailPage({ params }: GroupsDetailPageProps) {
const group = await api.group.getById({ id: params.id });
const tGeneral = await getScopedI18n("management.page.group.setting.general");
const tGroupAction = await getScopedI18n("group.action");

View File

@@ -9,10 +9,7 @@ import { objectEntries } from "@homarr/common";
import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissionKeys } from "@homarr/definitions";
import { createFormContext } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
@@ -21,10 +18,7 @@ interface PermissionFormProps {
initialPermissions: GroupPermissionKey[];
}
export const PermissionForm = ({
children,
initialPermissions,
}: PropsWithChildren<PermissionFormProps>) => {
export const PermissionForm = ({ children, initialPermissions }: PropsWithChildren<PermissionFormProps>) => {
const form = useForm({
initialValues: groupPermissionKeys.reduce((acc, key) => {
acc[key] = initialPermissions.includes(key);
@@ -73,9 +67,7 @@ interface SaveAffixProps {
export const SaveAffix = ({ groupId }: SaveAffixProps) => {
const t = useI18n();
const tForm = useScopedI18n("management.page.group.setting.permissions.form");
const tNotification = useScopedI18n(
"group.action.changePermissions.notification",
);
const tNotification = useScopedI18n("group.action.changePermissions.notification");
const form = useFormContext();
const { mutate, isPending } = clientApi.group.savePermissions.useMutation();

View File

@@ -1,13 +1,5 @@
import React from "react";
import {
Card,
CardSection,
Divider,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
import { api } from "@homarr/api/server";
import { objectKeys } from "@homarr/common";
@@ -15,11 +7,7 @@ import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissions } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import {
PermissionForm,
PermissionSwitch,
SaveAffix,
} from "./_group-permission-form";
import { PermissionForm, PermissionSwitch, SaveAffix } from "./_group-permission-form";
interface GroupPermissionsPageProps {
params: {
@@ -27,9 +15,7 @@ interface GroupPermissionsPageProps {
};
}
export default async function GroupPermissionsPage({
params,
}: GroupPermissionsPageProps) {
export default async function GroupPermissionsPage({ params }: GroupPermissionsPageProps) {
const group = await api.group.getById({ id: params.id });
const tPermissions = await getScopedI18n("group.permission");
const t = await getI18n();
@@ -99,10 +85,7 @@ const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
);
};
const createGroupPermissionKey = (
group: keyof typeof groupPermissions,
permission: string,
): GroupPermissionKey => {
const createGroupPermissionKey = (group: keyof typeof groupPermissions, permission: string): GroupPermissionKey => {
if (typeof groupPermissions[group] === "boolean") {
return group as GroupPermissionKey;
}

View File

@@ -4,13 +4,11 @@ import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { validation } from "@homarr/validation";
import { revalidatePathActionAsync } from "~/app/revalidatePathAction";
@@ -32,7 +30,7 @@ export const AddGroup = () => {
const AddGroupModal = createModal<void>(({ actions }) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
const form = useForm({
const form = useZodForm(validation.group.create, {
initialValues: {
name: "",
},
@@ -60,11 +58,7 @@ const AddGroupModal = createModal<void>(({ actions }) => {
})}
>
<Stack>
<TextInput
label={t("group.field.name")}
data-autofocus
{...form.getInputProps("name")}
/>
<TextInput label={t("group.field.name")} data-autofocus {...form.getInputProps("name")} />
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}

View File

@@ -27,25 +27,18 @@ const searchParamsSchema = z.object({
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<
TSchema extends Record<string, unknown>,
> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[]
? string[]
: string;
type SearchParamsSchemaInputFromSchema<TSchema extends Record<string, unknown>> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[] ? string[] : string;
}>;
interface GroupsListPageProps {
searchParams: SearchParamsSchemaInputFromSchema<
z.infer<typeof searchParamsSchema>
>;
searchParams: SearchParamsSchemaInputFromSchema<z.infer<typeof searchParamsSchema>>;
}
export default async function GroupsListPage(props: GroupsListPageProps) {
const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: groups, totalCount } =
await api.group.getPaginated(searchParams);
const { items: groups, totalCount } = await api.group.getPaginated(searchParams);
return (
<Container size="xl">
@@ -76,9 +69,7 @@ export default async function GroupsListPage(props: GroupsListPageProps) {
</Table>
<Group justify="end">
<TablePagination
total={Math.ceil(totalCount / searchParams.pageSize)}
/>
<TablePagination total={Math.ceil(totalCount / searchParams.pageSize)} />
</Group>
</Stack>
</Container>

View File

@@ -6,9 +6,7 @@ import type { RouterOutputs } from "@homarr/api";
import { createModal } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
export const InviteCopyModal = createModal<
RouterOutputs["invite"]["createInvite"]
>(({ actions, innerProps }) => {
export const InviteCopyModal = createModal<RouterOutputs["invite"]["createInvite"]>(({ actions, innerProps }) => {
const t = useScopedI18n("management.page.user.invite");
const inviteUrl = useInviteUrl(innerProps);
@@ -50,13 +48,9 @@ export const InviteCopyModal = createModal<
},
});
const createPath = ({ id, token }: RouterOutputs["invite"]["createInvite"]) =>
`/auth/invite/${id}?token=${token}`;
const createPath = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => `/auth/invite/${id}?token=${token}`;
const useInviteUrl = ({
id,
token,
}: RouterOutputs["invite"]["createInvite"]) => {
const useInviteUrl = ({ id, token }: RouterOutputs["invite"]["createInvite"]) => {
const pathname = usePathname();
return window.location.href.replace(pathname, createPath({ id, token }));

View File

@@ -21,9 +21,7 @@ interface InviteListComponentProps {
initialInvites: RouterOutputs["invite"]["getAll"];
}
export const InviteListComponent = ({
initialInvites,
}: InviteListComponentProps) => {
export const InviteListComponent = ({ initialInvites }: InviteListComponentProps) => {
const t = useScopedI18n("management.page.user.invite");
const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, {
initialData: initialInvites,
@@ -32,9 +30,7 @@ export const InviteListComponent = ({
refetchOnReconnect: false,
});
const columns = useMemo<
MRT_ColumnDef<RouterOutputs["invite"]["getAll"][number]>[]
>(
const columns = useMemo<MRT_ColumnDef<RouterOutputs["invite"]["getAll"][number]>[]>(
() => [
{
accessorKey: "id",
@@ -100,11 +96,7 @@ const RenderTopToolbarCustomActions = () => {
);
};
const RenderRowActions = ({
row,
}: {
row: MRT_Row<RouterOutputs["invite"]["getAll"][number]>;
}) => {
const RenderRowActions = ({ row }: { row: MRT_Row<RouterOutputs["invite"]["getAll"][number]> }) => {
const t = useScopedI18n("management.page.user.invite");
const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation();
const utils = clientApi.useUtils();
@@ -121,12 +113,7 @@ const RenderRowActions = ({
}, [openConfirmModal, row.original.id, mutate, utils, t]);
return (
<ActionIcon
variant="subtle"
color="red"
onClick={handleDelete}
loading={isPending}
>
<ActionIcon variant="subtle" color="red" onClick={handleDelete} loading={isPending}>
<IconTrash color="red" size={20} stroke={1.5} />
</ActionIcon>
);

View File

@@ -1,14 +1,14 @@
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata";
import { UserListComponent } from "./_components/user-list.component";
export async function generateMetadata() {
const t = await getScopedI18n("management.page.user.list");
const metaTitle = `${t("metaTitle")} • Homarr`;
return {
title: metaTitle,
title: createMetaTitle(t("metaTitle")),
};
}