Merge branch 'dev' into ajnart/fix-duplicate-users
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, PasswordInput, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
interface RegistrationFormProps {
|
||||
invite: {
|
||||
id: string;
|
||||
token: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
|
||||
const t = useScopedI18n("user");
|
||||
const router = useRouter();
|
||||
const { mutate, isPending } = clientApi.user.register.useMutation();
|
||||
const form = useForm<FormType>({
|
||||
validate: zodResolver(validation.user.registration),
|
||||
initialValues: {
|
||||
username: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate(
|
||||
{
|
||||
...values,
|
||||
inviteId: invite.id,
|
||||
token: invite.token,
|
||||
},
|
||||
{
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
title: t("action.register.notification.success.title"),
|
||||
message: t("action.register.notification.success.message"),
|
||||
});
|
||||
router.push("/auth/login");
|
||||
},
|
||||
onError(error) {
|
||||
const message =
|
||||
error.data?.code === "CONFLICT"
|
||||
? t("error.usernameTaken")
|
||||
: t("action.register.notification.error.message");
|
||||
|
||||
showErrorNotification({
|
||||
title: t("action.register.notification.error.title"),
|
||||
message,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="lg">
|
||||
<TextInput
|
||||
label={t("field.username.label")}
|
||||
autoComplete="off"
|
||||
{...form.getInputProps("username")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("field.password.label")}
|
||||
autoComplete="new-password"
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
label={t("field.passwordConfirm.label")}
|
||||
autoComplete="new-password"
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
/>
|
||||
<Button type="submit" fullWidth loading={isPending}>
|
||||
{t("action.register.label")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = z.infer<typeof validation.user.registration>;
|
||||
72
apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx
Normal file
72
apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Card, Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { and, db, eq } from "@homarr/db";
|
||||
import { invites } from "@homarr/db/schema/sqlite";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo";
|
||||
import { RegistrationForm } from "./_registration-form";
|
||||
|
||||
interface InviteUsagePageProps {
|
||||
params: {
|
||||
id: string;
|
||||
};
|
||||
searchParams: {
|
||||
token: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function InviteUsagePage({
|
||||
params,
|
||||
searchParams,
|
||||
}: InviteUsagePageProps) {
|
||||
const session = await auth();
|
||||
if (session) notFound();
|
||||
|
||||
const invite = await db.query.invites.findFirst({
|
||||
where: and(
|
||||
eq(invites.id, params.id),
|
||||
eq(invites.token, searchParams.token),
|
||||
),
|
||||
columns: {
|
||||
id: true,
|
||||
token: true,
|
||||
expirationDate: true,
|
||||
},
|
||||
with: {
|
||||
creator: {
|
||||
columns: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!invite || invite.expirationDate < new Date()) notFound();
|
||||
|
||||
const t = await getScopedI18n("user.page.invite");
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Stack align="center" mt="xl">
|
||||
<HomarrLogoWithTitle size="lg" />
|
||||
<Stack gap={6} align="center">
|
||||
<Title order={3} fw={400} ta="center">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text size="sm" c="gray.5" ta="center">
|
||||
{t("subtitle")}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||
<RegistrationForm invite={invite} />
|
||||
</Card>
|
||||
<Text size="xs" c="gray.5" ta="center">
|
||||
{t("description", { username: invite.creator.name })}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,10 @@ import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
import { signIn } from "@homarr/auth/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import type { z } from "@homarr/validation";
|
||||
import { validation } from "@homarr/validation";
|
||||
@@ -44,11 +48,19 @@ export const LoginForm = () => {
|
||||
throw response?.error;
|
||||
}
|
||||
|
||||
void router.push("/");
|
||||
showSuccessNotification({
|
||||
title: t("action.login.notification.success.title"),
|
||||
message: t("action.login.notification.success.message"),
|
||||
});
|
||||
router.push("/");
|
||||
})
|
||||
.catch((error: Error | string) => {
|
||||
setIsLoading(false);
|
||||
setError(error.toString());
|
||||
showErrorNotification({
|
||||
title: t("action.login.notification.error.title"),
|
||||
message: t("action.login.notification.error.message"),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
@@ -65,7 +77,7 @@ export const LoginForm = () => {
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
<Button type="submit" fullWidth loading={isLoading}>
|
||||
{t("action.login")}
|
||||
{t("action.login.label")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
import { Center, Stack, Text, Title } from "@mantine/core";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getScopedI18n("management.notFound");
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center">
|
||||
<Title order={1} tt="uppercase">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text>{t("text")}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
export default function NotFound() {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
.bannerContainer {
|
||||
padding: 3rem;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(
|
||||
130deg,
|
||||
#fa52521f 0%,
|
||||
var(--mantine-color-dark-6) 35%,
|
||||
var(--mantine-color-dark-6) 100%
|
||||
) !important;
|
||||
}
|
||||
|
||||
.scrollContainer {
|
||||
height: 100%;
|
||||
transform: rotateZ(10deg);
|
||||
}
|
||||
|
||||
@keyframes scrolling {
|
||||
0% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.scrollAnimationContainer {
|
||||
animation: scrolling;
|
||||
animation-timing-function: linear;
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.scrollAnimationContainer {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Box, Grid, GridCol, Group, Image, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { splitToNChunks } from "@homarr/common";
|
||||
|
||||
import classes from "./hero-banner.module.css";
|
||||
|
||||
const icons = [
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/homarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sabnzbd.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/deluge.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/radarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/sonarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/lidarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/pihole.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/dashdot.png",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/overseerr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/plex.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyfin.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/homeassistant.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/freshrss.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/readarr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/transmission.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/qbittorrent.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/nzbget.png",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/openmediavault.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/docker.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/jellyseerr.svg",
|
||||
"https://cdn.jsdelivr.net/gh/loganmarchione/homelab-svg-assets/assets/adguardhome.svg",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/png/tdarr.png",
|
||||
"https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons/svg/prowlarr.svg",
|
||||
];
|
||||
|
||||
const countIconGroups = 3;
|
||||
const animationDurationInSeconds = 12;
|
||||
|
||||
export const HeroBanner = () => {
|
||||
const arrayInChunks = splitToNChunks(icons, countIconGroups);
|
||||
const gridSpan = 12 / countIconGroups;
|
||||
|
||||
return (
|
||||
<Box className={classes.bannerContainer} bg="dark.6" pos="relative">
|
||||
<Stack gap={0}>
|
||||
<Title order={2} c="dimmed">
|
||||
Welcome back to your
|
||||
</Title>
|
||||
<Group gap="xs">
|
||||
<Image src="/logo/logo.png" w={40} h={40} />
|
||||
<Title>Homarr Dashboard</Title>
|
||||
</Group>
|
||||
</Stack>
|
||||
<Box
|
||||
className={classes.scrollContainer}
|
||||
w={"30%"}
|
||||
top={0}
|
||||
right={0}
|
||||
pos="absolute"
|
||||
>
|
||||
<Grid>
|
||||
{Array(countIconGroups)
|
||||
.fill(0)
|
||||
.map((_, columnIndex) => (
|
||||
<GridCol key={`grid-column-${columnIndex}`} span={gridSpan}>
|
||||
<Stack
|
||||
className={classes.scrollAnimationContainer}
|
||||
style={{
|
||||
animationDuration: `${animationDurationInSeconds - columnIndex}s`,
|
||||
}}
|
||||
>
|
||||
{arrayInChunks[columnIndex]?.map((icon, index) => (
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</GridCol>
|
||||
))}
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -74,9 +74,7 @@ export const BoardCardMenuDropdown = ({
|
||||
{hasFullAccess && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.7">
|
||||
{tCommon("menu.section.dangerZone.title")}
|
||||
</Menu.Label>
|
||||
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
|
||||
<Menu.Item
|
||||
c="red.7"
|
||||
leftSection={<IconTrash {...iconProps} />}
|
||||
|
||||
17
apps/nextjs/src/app/[locale]/manage/not-found.tsx
Normal file
17
apps/nextjs/src/app/[locale]/manage/not-found.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Center, Stack, Text, Title } from "@mantine/core";
|
||||
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
export default async function NotFound() {
|
||||
const t = await getScopedI18n("management.notFound");
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Stack align="center">
|
||||
<Title order={1} tt="uppercase">
|
||||
{t("title")}
|
||||
</Title>
|
||||
<Text>{t("text")}</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
import { Title } from "@mantine/core";
|
||||
import Link from "next/link";
|
||||
import { Card, Group, SimpleGrid, Space, Stack, Text } from "@mantine/core";
|
||||
import { IconArrowRight } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { Test } from "./test";
|
||||
import { HeroBanner } from "./_components/hero-banner";
|
||||
|
||||
interface LinkProps {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
count: number;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export async function generateMetadata() {
|
||||
const t = await getScopedI18n("management");
|
||||
@@ -14,20 +24,76 @@ export async function generateMetadata() {
|
||||
}
|
||||
|
||||
export default async function ManagementPage() {
|
||||
const t = await getScopedI18n("management.title");
|
||||
|
||||
const dateNow = new Date();
|
||||
const timeOfDay =
|
||||
dateNow.getHours() < 10
|
||||
? "morning"
|
||||
: dateNow.getHours() < 17
|
||||
? "afternoon"
|
||||
: "evening";
|
||||
const statistics = await api.home.getStats();
|
||||
const t = await getScopedI18n("management.page.home");
|
||||
|
||||
const links: LinkProps[] = [
|
||||
{
|
||||
count: statistics.countBoards,
|
||||
href: "/manage/boards",
|
||||
subtitle: t("statisticLabel.boards"),
|
||||
title: t("statistic.countBoards"),
|
||||
},
|
||||
{
|
||||
count: statistics.countUsers,
|
||||
href: "/manage/boards",
|
||||
subtitle: t("statisticLabel.authentication"),
|
||||
title: t("statistic.createUser"),
|
||||
},
|
||||
{
|
||||
count: statistics.countInvites,
|
||||
href: "/manage/boards",
|
||||
subtitle: t("statisticLabel.authentication"),
|
||||
title: t("statistic.createInvite"),
|
||||
},
|
||||
{
|
||||
count: statistics.countIntegrations,
|
||||
href: "/manage/integrations",
|
||||
subtitle: t("statisticLabel.resources"),
|
||||
title: t("statistic.addIntegration"),
|
||||
},
|
||||
{
|
||||
count: statistics.countApps,
|
||||
href: "/manage/apps",
|
||||
subtitle: t("statisticLabel.resources"),
|
||||
title: t("statistic.addApp"),
|
||||
},
|
||||
{
|
||||
count: statistics.countGroups,
|
||||
href: "/manage/users/groups",
|
||||
subtitle: t("statisticLabel.authorization"),
|
||||
title: t("statistic.manageRoles"),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<Title>{t(timeOfDay, { username: "admin" })}</Title>
|
||||
<Test />
|
||||
<HeroBanner />
|
||||
<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
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
<Text size="2.4rem" fw="bolder">
|
||||
{link.count}
|
||||
</Text>
|
||||
<Stack gap={0}>
|
||||
<Text c="red" size="xs">
|
||||
{link.subtitle}
|
||||
</Text>
|
||||
<Text fw="bold">{link.title}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<IconArrowRight />
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { Button, Stack, Text, TextInput } from "@mantine/core";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
|
||||
export const Test = () => {
|
||||
const [value, setValue] = useState("");
|
||||
const [message, setMessage] = useState<string>("Hello, world!");
|
||||
const { mutate } = clientApi.user.setMessage.useMutation();
|
||||
clientApi.user.test.useSubscription(undefined, {
|
||||
onData({ message }) {
|
||||
setMessage(message);
|
||||
},
|
||||
onError(err) {
|
||||
alert(err);
|
||||
},
|
||||
});
|
||||
|
||||
const onChange = useCallback(
|
||||
(event: ChangeEvent<HTMLInputElement>) => setValue(event.target.value),
|
||||
[setValue],
|
||||
);
|
||||
const onClick = useCallback(() => {
|
||||
mutate(value);
|
||||
setValue("");
|
||||
}, [mutate, value]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<TextInput label="Update message" value={value} onChange={onChange} />
|
||||
<Button onClick={onClick}>Update message</Button>
|
||||
<Text>This message gets through subscription: {message}</Text>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface DangerZoneAccordionProps {
|
||||
user: NonNullable<RouterOutputs["user"]["getById"]>;
|
||||
}
|
||||
|
||||
export const DangerZoneAccordion = ({ user }: DangerZoneAccordionProps) => {
|
||||
const t = useScopedI18n("management.page.user.edit.section.dangerZone");
|
||||
const router = useRouter();
|
||||
const { mutateAsync: mutateUserDeletionAsync } =
|
||||
clientApi.user.delete.useMutation({
|
||||
onSettled: () => {
|
||||
router.push("/manage/users");
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = React.useCallback(
|
||||
async () => await mutateUserDeletionAsync(user.id),
|
||||
[user, mutateUserDeletionAsync],
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Divider />
|
||||
<Group justify="space-between" px="md">
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold" size="sm">
|
||||
{t("action.delete.label")}
|
||||
</Text>
|
||||
<Text size="sm">{t("action.delete.description")}</Text>
|
||||
</Stack>
|
||||
<Button onClick={handleDelete} variant="subtle" color="red">
|
||||
{t("action.delete.button")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,81 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, Stack, TextInput } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import {
|
||||
showErrorNotification,
|
||||
showSuccessNotification,
|
||||
} from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
|
||||
interface ProfileAccordionProps {
|
||||
user: NonNullable<RouterOutputs["user"]["getById"]>;
|
||||
}
|
||||
|
||||
export const ProfileAccordion = ({ user }: ProfileAccordionProps) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
|
||||
onError(error) {
|
||||
showErrorNotification({
|
||||
title: t("management.page.user.edit.section.profile.editProfile.title"),
|
||||
message: error.message,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSuccessNotification({
|
||||
title: t("management.page.user.edit.section.profile.editProfile.title"),
|
||||
message: t(
|
||||
"management.page.user.edit.section.profile.editProfile.message.profileUpdated",
|
||||
),
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await revalidatePathAction("/manage/users");
|
||||
},
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
name: user.name ?? "",
|
||||
email: user.email ?? "",
|
||||
},
|
||||
validate: zodResolver(validation.user.editProfile),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
mutate({
|
||||
userId: user.id,
|
||||
form: form.values,
|
||||
});
|
||||
};
|
||||
|
||||
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")}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!form.isValid() || !form.isDirty()}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button, PasswordInput, Stack, Title } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { showSuccessNotification } from "@homarr/notifications";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
|
||||
interface SecurityAccordionComponentProps {
|
||||
user: NonNullable<RouterOutputs["user"]["getById"]>;
|
||||
}
|
||||
|
||||
export const SecurityAccordionComponent = ({
|
||||
user,
|
||||
}: SecurityAccordionComponentProps) => {
|
||||
return (
|
||||
<Stack>
|
||||
<ChangePasswordForm user={user} />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const ChangePasswordForm = ({
|
||||
user,
|
||||
}: {
|
||||
user: NonNullable<RouterOutputs["user"]["getById"]>;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changePassword.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathAction(`/manage/users/${user.id}`);
|
||||
showSuccessNotification({
|
||||
title: t(
|
||||
"management.page.user.edit.section.security.changePassword.message.passwordUpdated",
|
||||
),
|
||||
message: "",
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useForm({
|
||||
initialValues: {
|
||||
userId: user.id,
|
||||
password: "",
|
||||
},
|
||||
validate: zodResolver(validation.user.changePassword),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
mutate(form.values);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<Stack gap={0}>
|
||||
<Title order={5}>
|
||||
{t(
|
||||
"management.page.user.edit.section.security.changePassword.title",
|
||||
)}
|
||||
</Title>
|
||||
<PasswordInput
|
||||
label={t("user.field.password.label")}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
</Stack>
|
||||
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
|
||||
{t("common.action.confirm")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
|
||||
interface DeleteUserButtonProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
export const DeleteUserButton = ({ user }: DeleteUserButtonProps) => {
|
||||
const t = useI18n();
|
||||
const router = useRouter();
|
||||
const { mutateAsync: mutateUserDeletionAsync } =
|
||||
clientApi.user.delete.useMutation({
|
||||
async onSuccess() {
|
||||
await revalidatePathAction("/manage/users").then(() =>
|
||||
router.push("/manage/users"),
|
||||
);
|
||||
},
|
||||
});
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const handleDelete = useCallback(
|
||||
() =>
|
||||
openConfirmModal({
|
||||
title: t("user.action.delete.label"),
|
||||
children: t("user.action.delete.confirm", { username: user.name }),
|
||||
async onConfirm() {
|
||||
await mutateUserDeletionAsync(user.id);
|
||||
},
|
||||
}),
|
||||
[user, mutateUserDeletionAsync, openConfirmModal, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Button onClick={handleDelete} variant="subtle" color="red">
|
||||
{t("common.action.delete")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Box, Button, FileButton, Menu, UnstyledButton } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
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 { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
|
||||
interface UserProfileAvatarForm {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
export const UserProfileAvatarForm = ({ user }: UserProfileAvatarForm) => {
|
||||
const { mutate } = clientApi.user.setProfileImage.useMutation();
|
||||
const [opened, { toggle }] = useDisclosure(false);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const t = useI18n();
|
||||
const tManageAvatar = useScopedI18n("user.action.manageAvatar");
|
||||
|
||||
const handleAvatarChange = useCallback(
|
||||
async (file: File | null) => {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
const base64Url = await fileToBase64Async(file);
|
||||
|
||||
mutate(
|
||||
{
|
||||
userId: user.id,
|
||||
image: base64Url,
|
||||
},
|
||||
{
|
||||
async onSuccess() {
|
||||
// Revalidate all as the avatar is used in multiple places
|
||||
await revalidatePathAction("/");
|
||||
showSuccessNotification({
|
||||
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" },
|
||||
),
|
||||
});
|
||||
} else {
|
||||
showErrorNotification({
|
||||
message: tManageAvatar(
|
||||
"changeImage.notification.error.message",
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[mutate, user.id, tManageAvatar],
|
||||
);
|
||||
|
||||
const handleRemoveAvatar = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: tManageAvatar("removeImage.label"),
|
||||
children: tManageAvatar("removeImage.confirm"),
|
||||
onConfirm() {
|
||||
mutate(
|
||||
{
|
||||
userId: user.id,
|
||||
image: null,
|
||||
},
|
||||
{
|
||||
async onSuccess() {
|
||||
// Revalidate all as the avatar is used in multiple places
|
||||
await revalidatePathAction("/");
|
||||
showSuccessNotification({
|
||||
message: tManageAvatar(
|
||||
"removeImage.notification.success.message",
|
||||
),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: tManageAvatar(
|
||||
"removeImage.notification.error.message",
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}, [mutate, user.id, openConfirmModal, tManageAvatar]);
|
||||
|
||||
return (
|
||||
<Box pos="relative">
|
||||
<Menu
|
||||
opened={opened}
|
||||
keepMounted
|
||||
onChange={toggle}
|
||||
position="bottom-start"
|
||||
withArrow
|
||||
>
|
||||
<Menu.Target>
|
||||
<UnstyledButton onClick={toggle}>
|
||||
<UserAvatar user={user} size={200} />
|
||||
<Button
|
||||
component="div"
|
||||
pos="absolute"
|
||||
bottom={0}
|
||||
left={0}
|
||||
size="compact-md"
|
||||
fw="normal"
|
||||
variant="default"
|
||||
leftSection={<IconPencil size={18} stroke={1.5} />}
|
||||
>
|
||||
{t("common.action.edit")}
|
||||
</Button>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<FileButton
|
||||
onChange={handleAvatarChange}
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
>
|
||||
{(props) => (
|
||||
<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} />}
|
||||
>
|
||||
{tManageAvatar("removeImage.label")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const fileToBase64Async = (file: File): Promise<string> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result?.toString() || "");
|
||||
reader.onerror = reject;
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
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 { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
|
||||
interface UserProfileFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
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 revalidatePathAction("/manage/users");
|
||||
},
|
||||
onSuccess(_, variables) {
|
||||
// Reset form initial values to reset dirty state
|
||||
form.setInitialValues({
|
||||
name: variables.name,
|
||||
email: variables.email ?? "",
|
||||
});
|
||||
showSuccessNotification({
|
||||
title: t("common.notification.update.success"),
|
||||
message: t("user.action.editProfile.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError(error) {
|
||||
const message =
|
||||
error.data?.code === "CONFLICT"
|
||||
? t("user.error.usernameTaken")
|
||||
: t("user.action.editProfile.notification.error.message");
|
||||
showErrorNotification({
|
||||
title: t("common.notification.update.error"),
|
||||
message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(values: FormType) => {
|
||||
mutate({
|
||||
...values,
|
||||
id: user.id,
|
||||
});
|
||||
},
|
||||
[user.id, mutate],
|
||||
);
|
||||
|
||||
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")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button
|
||||
type="submit"
|
||||
color="teal"
|
||||
disabled={!form.isDirty()}
|
||||
loading={isPending}
|
||||
>
|
||||
{t("common.action.saveChanges")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = Omit<RouterInputs["user"]["editProfile"], "id">;
|
||||
20
apps/nextjs/src/app/[locale]/manage/users/[userId]/access.ts
Normal file
20
apps/nextjs/src/app/[locale]/manage/users/[userId]/access.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { Session } from "@homarr/auth";
|
||||
|
||||
export const canAccessUserEditPage = (
|
||||
session: Session | null,
|
||||
userId: string,
|
||||
) => {
|
||||
if (!session) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (session.user.id === userId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (session.user.permissions.includes("admin")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
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 { IconSettings, IconShieldLock } from "@tabler/icons-react";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||
import { NavigationLink } from "../groups/[id]/_navigation";
|
||||
import { canAccessUserEditPage } from "./access";
|
||||
|
||||
interface LayoutProps {
|
||||
params: { userId: string };
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!canAccessUserEditPage(session, user.id)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="xl">
|
||||
<Grid>
|
||||
<GridCol span={12}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group>
|
||||
<UserAvatar user={user} size="lg" />
|
||||
<Stack gap={0}>
|
||||
<Title order={3}>{user.name}</Title>
|
||||
<Text c="gray.5">{t("user.name")}</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
{session?.user.permissions.includes("admin") && (
|
||||
<Button
|
||||
component={Link}
|
||||
href="/manage/users"
|
||||
color="gray"
|
||||
variant="light"
|
||||
>
|
||||
{tUser("back")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</GridCol>
|
||||
<GridCol span={{ xs: 12, md: 4, lg: 3, xl: 2 }}>
|
||||
<Stack>
|
||||
<Stack gap={0}>
|
||||
<NavigationLink
|
||||
href={`/manage/users/${params.userId}`}
|
||||
label={tUser("setting.general.title")}
|
||||
icon={<IconSettings size="1rem" stroke={1.5} />}
|
||||
/>
|
||||
<NavigationLink
|
||||
href={`/manage/users/${params.userId}/security`}
|
||||
label={tUser("setting.security.title")}
|
||||
icon={<IconShieldLock size="1rem" stroke={1.5} />}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</GridCol>
|
||||
<GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,19 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionControl,
|
||||
AccordionItem,
|
||||
AccordionPanel,
|
||||
Avatar,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangleFilled,
|
||||
IconSettingsFilled,
|
||||
IconShieldLockFilled,
|
||||
IconUserFilled,
|
||||
} from "@tabler/icons-react";
|
||||
import { Box, Group, Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { DangerZoneAccordion } from "./_components/dangerZone.accordion";
|
||||
import { ProfileAccordion } from "./_components/profile.accordion";
|
||||
import { SecurityAccordionComponent } from "./_components/security.accordion";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
@@ -31,9 +22,17 @@ interface Props {
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: Props) {
|
||||
const user = await api.user.getById({
|
||||
userId: params.userId,
|
||||
});
|
||||
const session = await auth();
|
||||
const user = await api.user
|
||||
.getById({
|
||||
userId: params.userId,
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!user || !canAccessUserEditPage(session, user.id)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const t = await getScopedI18n("management.page.user.edit");
|
||||
const metaTitle = `${t("metaTitle", { username: user?.name })} • Homarr`;
|
||||
|
||||
@@ -43,71 +42,38 @@ export async function generateMetadata({ params }: Props) {
|
||||
}
|
||||
|
||||
export default async function EditUserPage({ params }: Props) {
|
||||
const t = await getScopedI18n("management.page.user.edit");
|
||||
const user = await api.user.getById({
|
||||
userId: params.userId,
|
||||
});
|
||||
const t = await getI18n();
|
||||
const tGeneral = await getScopedI18n("management.page.user.setting.general");
|
||||
const session = await auth();
|
||||
const user = await api.user
|
||||
.getById({
|
||||
userId: params.userId,
|
||||
})
|
||||
.catch(catchTrpcNotFound);
|
||||
|
||||
if (!user) {
|
||||
if (!canAccessUserEditPage(session, user.id)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group mb="md">
|
||||
<Avatar>{user.name?.substring(0, 2)}</Avatar>
|
||||
<Title>{user.name}</Title>
|
||||
<Title>{tGeneral("title")}</Title>
|
||||
<Group gap="xl">
|
||||
<Box flex={1}>
|
||||
<UserProfileForm user={user} />
|
||||
</Box>
|
||||
<Box w={{ base: "100%", lg: 200 }}>
|
||||
<UserProfileAvatarForm user={user} />
|
||||
</Box>
|
||||
</Group>
|
||||
<Accordion variant="separated" defaultValue="general">
|
||||
<AccordionItem value="general">
|
||||
<AccordionControl icon={<IconUserFilled />}>
|
||||
<Text fw="bold" size="lg">
|
||||
{t("section.profile.title")}
|
||||
</Text>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<ProfileAccordion user={user} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="preferences">
|
||||
<AccordionControl icon={<IconSettingsFilled />}>
|
||||
<Text fw="bold" size="lg">
|
||||
{t("section.preferences.title")}
|
||||
</Text>
|
||||
</AccordionControl>
|
||||
<AccordionPanel></AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="security">
|
||||
<AccordionControl icon={<IconShieldLockFilled />}>
|
||||
<Text fw="bold" size="lg">
|
||||
{t("section.security.title")}
|
||||
</Text>
|
||||
</AccordionControl>
|
||||
<AccordionPanel>
|
||||
<SecurityAccordionComponent user={user} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
styles={{
|
||||
item: {
|
||||
borderColor: "rgba(248,81,73,0.4)",
|
||||
borderWidth: 4,
|
||||
},
|
||||
}}
|
||||
value="dangerZone"
|
||||
>
|
||||
<AccordionControl icon={<IconAlertTriangleFilled />}>
|
||||
<Text fw="bold" size="lg">
|
||||
{t("section.dangerZone.title")}
|
||||
</Text>
|
||||
</AccordionControl>
|
||||
<AccordionPanel
|
||||
styles={{ content: { paddingRight: 0, paddingLeft: 0 } }}
|
||||
>
|
||||
<DangerZoneAccordion user={user} />
|
||||
</AccordionPanel>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<DangerZoneRoot>
|
||||
<DangerZoneItem
|
||||
label={t("user.action.delete.label")}
|
||||
description={t("user.action.delete.description")}
|
||||
action={<DeleteUserButton user={user} />}
|
||||
/>
|
||||
</DangerZoneRoot>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"use client";
|
||||
|
||||
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 { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
|
||||
interface ChangePasswordFormProps {
|
||||
user: RouterOutputs["user"]["getById"];
|
||||
}
|
||||
|
||||
export const ChangePasswordForm = ({ user }: ChangePasswordFormProps) => {
|
||||
const { data: session } = useSession();
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.changePassword.useMutation({
|
||||
async onSettled() {
|
||||
await revalidatePathAction(`/manage/users/${user.id}`);
|
||||
},
|
||||
onSuccess() {
|
||||
showSuccessNotification({
|
||||
message: t("user.action.changePassword.notification.success.message"),
|
||||
});
|
||||
},
|
||||
onError() {
|
||||
showErrorNotification({
|
||||
message: t("user.action.changePassword.notification.error.message"),
|
||||
});
|
||||
},
|
||||
});
|
||||
const form = useForm<FormType>({
|
||||
initialValues: {
|
||||
previousPassword: "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
},
|
||||
validate: zodResolver(validation.user.changePassword),
|
||||
validateInputOnBlur: true,
|
||||
validateInputOnChange: true,
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate(
|
||||
{
|
||||
userId: user.id,
|
||||
...values,
|
||||
},
|
||||
{
|
||||
onSettled() {
|
||||
form.reset();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<Fieldset legend={t("user.action.changePassword.label")}>
|
||||
<Stack gap="xs">
|
||||
{/* Require previous password if the current user want's to change his password */}
|
||||
{session?.user.id === user.id && (
|
||||
<PasswordInput
|
||||
withAsterisk
|
||||
label={t("user.field.previousPassword.label")}
|
||||
{...form.getInputProps("previousPassword")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PasswordInput
|
||||
withAsterisk
|
||||
label={t("user.field.password.label")}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
|
||||
<PasswordInput
|
||||
withAsterisk
|
||||
label={t("user.field.passwordConfirm.label")}
|
||||
{...form.getInputProps("confirmPassword")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button type="submit" color="teal" loading={isPending}>
|
||||
{t("common.action.confirm")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
type FormType = Omit<RouterInputs["user"]["changePassword"], "userId">;
|
||||
@@ -0,0 +1,40 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Stack, Title } from "@mantine/core";
|
||||
|
||||
import { api } from "@homarr/api/server";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import { getScopedI18n } from "@homarr/translation/server";
|
||||
|
||||
import { catchTrpcNotFound } from "~/errors/trpc-not-found";
|
||||
import { canAccessUserEditPage } from "../access";
|
||||
import { ChangePasswordForm } from "./_change-password-form";
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function UserSecurityPage({ params }: Props) {
|
||||
const session = await auth();
|
||||
const tSecurity = await getScopedI18n(
|
||||
"management.page.user.setting.security",
|
||||
);
|
||||
const user = await api.user
|
||||
.getById({
|
||||
userId: params.userId,
|
||||
})
|
||||
.catch(catchTrpcNotFound);
|
||||
|
||||
if (!canAccessUserEditPage(session, user.id)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Title>{tSecurity("title")}</Title>
|
||||
|
||||
<ChangePasswordForm user={user} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,12 @@
|
||||
import {
|
||||
Card,
|
||||
CardSection,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
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 { DeleteGroup } from "./_delete-group";
|
||||
import { RenameGroupForm } from "./_rename-group-form";
|
||||
import { TransferGroupOwnership } from "./_transfer-group-ownership";
|
||||
@@ -34,42 +30,19 @@ export default async function GroupsDetailPage({
|
||||
|
||||
<RenameGroupForm group={group} />
|
||||
|
||||
<Stack gap="sm">
|
||||
<Title c="red.8" order={2}>
|
||||
{tGeneral("dangerZone")}
|
||||
</Title>
|
||||
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)" }}>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between" px="md">
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold" size="sm">
|
||||
{tGroupAction("transfer.label")}
|
||||
</Text>
|
||||
<Text size="sm">{tGroupAction("transfer.description")}</Text>
|
||||
</Stack>
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
<TransferGroupOwnership group={group} />
|
||||
</Group>
|
||||
</Group>
|
||||
<DangerZoneRoot>
|
||||
<DangerZoneItem
|
||||
label={tGroupAction("transfer.label")}
|
||||
description={tGroupAction("transfer.description")}
|
||||
action={<TransferGroupOwnership group={group} />}
|
||||
/>
|
||||
|
||||
<CardSection>
|
||||
<Divider />
|
||||
</CardSection>
|
||||
|
||||
<Group justify="space-between" px="md">
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold" size="sm">
|
||||
{tGroupAction("delete.label")}
|
||||
</Text>
|
||||
<Text size="sm">{tGroupAction("delete.description")}</Text>
|
||||
</Stack>
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
<DeleteGroup group={group} />
|
||||
</Group>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
<DangerZoneItem
|
||||
label={tGroupAction("delete.label")}
|
||||
description={tGroupAction("delete.description")}
|
||||
action={<DeleteGroup group={group} />}
|
||||
/>
|
||||
</DangerZoneRoot>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useAtomValue } from "jotai";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
loadWidgetDynamic,
|
||||
reduceWidgetOptionsWithDefaultValues,
|
||||
@@ -39,56 +39,69 @@ interface Props {
|
||||
|
||||
export const SectionContent = ({ items, refs }: Props) => {
|
||||
const board = useRequiredBoard();
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid-stack-item"
|
||||
data-id={item.id}
|
||||
gs-x={item.xOffset}
|
||||
gs-y={item.yOffset}
|
||||
gs-w={item.width}
|
||||
gs-h={item.height}
|
||||
gs-min-w={1}
|
||||
gs-min-h={1}
|
||||
gs-max-w={4}
|
||||
gs-max-h={4}
|
||||
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
||||
>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(
|
||||
classes.itemCard,
|
||||
"grid-stack-item-content",
|
||||
)}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": board.opacity / 100,
|
||||
},
|
||||
}}
|
||||
p={width >= 96 ? undefined : "xs"}
|
||||
>
|
||||
<BoardItem item={item} width={width + 32} height={height + 32} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{items.map((item) => (
|
||||
<BoardItem
|
||||
key={item.id}
|
||||
refs={refs}
|
||||
item={item}
|
||||
opacity={board.opacity}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemProps {
|
||||
item: Item;
|
||||
refs: UseGridstackRefs;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
const BoardItem = ({ refs, item, opacity }: ItemProps) => {
|
||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid-stack-item"
|
||||
data-id={item.id}
|
||||
gs-x={item.xOffset}
|
||||
gs-y={item.yOffset}
|
||||
gs-w={item.width}
|
||||
gs-h={item.height}
|
||||
gs-min-w={1}
|
||||
gs-min-h={1}
|
||||
gs-max-w={4}
|
||||
gs-max-h={4}
|
||||
ref={refs.items.current[item.id] as RefObject<HTMLDivElement>}
|
||||
>
|
||||
<Card
|
||||
ref={ref}
|
||||
className={combineClasses(classes.itemCard, "grid-stack-item-content")}
|
||||
withBorder
|
||||
styles={{
|
||||
root: {
|
||||
"--opacity": opacity / 100,
|
||||
},
|
||||
}}
|
||||
p={0}
|
||||
>
|
||||
<BoardItemContent item={item} width={width} height={height} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ItemContentProps {
|
||||
item: Item;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const BoardItem = ({ item, ...dimensions }: ItemProps) => {
|
||||
const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => {
|
||||
const board = useRequiredBoard();
|
||||
const editMode = useAtomValue(editModeAtom);
|
||||
const serverData = useServerDataFor(item.id);
|
||||
@@ -115,7 +128,8 @@ const BoardItem = ({ item, ...dimensions }: ItemProps) => {
|
||||
};
|
||||
|
||||
const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
|
||||
const t = useScopedI18n("item");
|
||||
const tItem = useScopedI18n("item");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(WidgetEditModal);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const isEditMode = useAtomValue(editModeAtom);
|
||||
@@ -160,8 +174,8 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
|
||||
|
||||
const openRemoveModal = () => {
|
||||
openConfirmModal({
|
||||
title: t("remove.title"),
|
||||
children: t("remove.message"),
|
||||
title: tItem("remove.title"),
|
||||
children: tItem("remove.message"),
|
||||
onConfirm: () => {
|
||||
removeItem({ itemId: item.id });
|
||||
},
|
||||
@@ -182,24 +196,24 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown miw={128}>
|
||||
<Menu.Label>{t("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconPencil size={16} />}
|
||||
onClick={openEditModal}
|
||||
>
|
||||
{t("action.edit")}
|
||||
{tItem("action.edit")}
|
||||
</Menu.Item>
|
||||
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>
|
||||
{t("action.move")}
|
||||
{tItem("action.move")}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Label c="red.6">{t("menu.label.dangerZone")}</Menu.Label>
|
||||
<Menu.Label c="red.6">{t("common.dangerZone")}</Menu.Label>
|
||||
<Menu.Item
|
||||
c="red.6"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={openRemoveModal}
|
||||
>
|
||||
{t("action.remove")}
|
||||
{tItem("action.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@@ -26,7 +26,9 @@ export const initializeGridstack = ({
|
||||
newGrid.current = GridStack.init(
|
||||
{
|
||||
column: sectionColumnCount,
|
||||
margin: 10,
|
||||
margin: Math.round(
|
||||
Math.max(Math.min(refs.wrapper.current.offsetWidth / 100, 10), 1),
|
||||
),
|
||||
cellHeight: 128,
|
||||
float: true,
|
||||
alwaysShowResizeHandle: true,
|
||||
|
||||
70
apps/nextjs/src/components/manage/danger-zone.tsx
Normal file
70
apps/nextjs/src/components/manage/danger-zone.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Fragment } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardSection,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { getI18n } from "@homarr/translation/server";
|
||||
|
||||
interface DangerZoneRootProps {
|
||||
children: React.ReactNode[] | React.ReactNode;
|
||||
}
|
||||
|
||||
export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
|
||||
const t = await getI18n();
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Title c="red.8" order={2}>
|
||||
{t("common.dangerZone")}
|
||||
</Title>
|
||||
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)" }}>
|
||||
<Stack gap="sm">
|
||||
{Array.isArray(children)
|
||||
? children.map((child, index) => (
|
||||
<Fragment key={index}>
|
||||
{child}
|
||||
{index + 1 !== children.length && (
|
||||
<CardSection>
|
||||
<Divider />
|
||||
</CardSection>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
: children}
|
||||
</Stack>
|
||||
</Card>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
interface DangerZoneItemProps {
|
||||
label: string;
|
||||
description: string;
|
||||
action: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DangerZoneItem = ({
|
||||
label,
|
||||
description,
|
||||
action,
|
||||
}: DangerZoneItemProps) => {
|
||||
return (
|
||||
<Group justify="space-between" px="md">
|
||||
<Stack gap={0}>
|
||||
<Text fw="bold" size="sm">
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="sm">{description}</Text>
|
||||
</Stack>
|
||||
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
|
||||
{action}
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
IconLogin,
|
||||
IconLogout,
|
||||
IconMoon,
|
||||
IconSettings,
|
||||
IconSun,
|
||||
IconTool,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -71,6 +72,15 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
||||
>
|
||||
{t("navigateDefaultBoard")}
|
||||
</Menu.Item>
|
||||
{Boolean(session.data) && (
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href={`/manage/users/${session.data?.user.id}`}
|
||||
leftSection={<IconSettings size="1rem" />}
|
||||
>
|
||||
{t("preferences")}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
component={Link}
|
||||
href="/manage"
|
||||
@@ -111,7 +121,7 @@ const LogoutModal = createModal<{ onTimeout: () => void }>(
|
||||
|
||||
useEffect(() => {
|
||||
start();
|
||||
}, []);
|
||||
}, [start]);
|
||||
|
||||
return (
|
||||
<Center h={200 - 2 * 16}>
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import type { AlertProps } from "@mantine/core";
|
||||
import { Alert } from "@mantine/core";
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
|
||||
interface ErrorDisplayProps extends AlertProps {
|
||||
title?: string;
|
||||
hidden?: boolean;
|
||||
message?: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ErrorDisplay({
|
||||
title = "There was an error",
|
||||
message,
|
||||
icon,
|
||||
hidden = false,
|
||||
...alertProps
|
||||
}: ErrorDisplayProps) {
|
||||
if (hidden) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Alert
|
||||
variant="filled"
|
||||
color="red"
|
||||
title={title}
|
||||
icon={icon ? icon : <IconAlertTriangle />}
|
||||
{...alertProps}
|
||||
>
|
||||
{message}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
12
apps/nextjs/src/errors/trpc-not-found.ts
Normal file
12
apps/nextjs/src/errors/trpc-not-found.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import "server-only";
|
||||
|
||||
import { notFound } from "next/navigation";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const catchTrpcNotFound = (err: unknown) => {
|
||||
if (err instanceof TRPCError && err.code === "NOT_FOUND") {
|
||||
notFound();
|
||||
}
|
||||
|
||||
throw err;
|
||||
};
|
||||
Reference in New Issue
Block a user