Merge branch 'dev' into ajnart/fix-duplicate-users
This commit is contained in:
@@ -52,10 +52,14 @@ FROM base AS runner
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache redis
|
RUN apk add --no-cache redis
|
||||||
|
RUN mkdir /appdata
|
||||||
|
RUN mkdir /appdata/db
|
||||||
|
RUN mkdir /appdata/redis
|
||||||
|
|
||||||
# Don't run production as root
|
# Don't run production as root
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
RUN adduser --system --uid 1001 nextjs
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
RUN chown -R nextjs:nodejs /appdata
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
COPY --from=installer /app/apps/nextjs/next.config.mjs .
|
COPY --from=installer /app/apps/nextjs/next.config.mjs .
|
||||||
@@ -73,8 +77,9 @@ COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/standalone ./
|
|||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/.next/static ./apps/nextjs/.next/static
|
||||||
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
COPY --from=installer --chown=nextjs:nodejs /app/apps/nextjs/public ./apps/nextjs/public
|
||||||
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
COPY --chown=nextjs:nodejs scripts/run.sh ./run.sh
|
||||||
|
COPY --chown=nextjs:nodejs packages/redis/redis.conf /app/redis.conf
|
||||||
|
|
||||||
ENV DB_URL='/app/db/db.sqlite'
|
ENV DB_URL='/appdata/db/db.sqlite'
|
||||||
ENV DB_DIALECT='sqlite'
|
ENV DB_DIALECT='sqlite'
|
||||||
ENV DB_DRIVER='better-sqlite3'
|
ENV DB_DRIVER='better-sqlite3'
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,10 @@
|
|||||||
"@mantine/modals": "^7.9.1",
|
"@mantine/modals": "^7.9.1",
|
||||||
"@mantine/tiptap": "^7.9.1",
|
"@mantine/tiptap": "^7.9.1",
|
||||||
"@t3-oss/env-nextjs": "^0.10.1",
|
"@t3-oss/env-nextjs": "^0.10.1",
|
||||||
"@tanstack/react-query": "^5.35.5",
|
"@tanstack/react-query": "^5.36.2",
|
||||||
"@tanstack/react-query-devtools": "^5.35.5",
|
"@tanstack/react-query-devtools": "^5.36.2",
|
||||||
"@tanstack/react-query-next-experimental": "5.35.5",
|
"@tanstack/react-query-next-experimental": "5.36.2",
|
||||||
"@trpc/client": "11.0.0-rc.366",
|
"@trpc/client": "11.0.0-rc.373",
|
||||||
"@trpc/next": "next",
|
"@trpc/next": "next",
|
||||||
"@trpc/react-query": "next",
|
"@trpc/react-query": "next",
|
||||||
"@trpc/server": "next",
|
"@trpc/server": "next",
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
"chroma-js": "^2.4.2",
|
"chroma-js": "^2.4.2",
|
||||||
"dayjs": "^1.11.11",
|
"dayjs": "^1.11.11",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"glob": "^10.3.14",
|
"glob": "^10.3.15",
|
||||||
"jotai": "^2.8.0",
|
"jotai": "^2.8.0",
|
||||||
"next": "^14.2.3",
|
"next": "^14.2.3",
|
||||||
"postcss-preset-mantine": "^1.15.0",
|
"postcss-preset-mantine": "^1.15.0",
|
||||||
@@ -62,13 +62,13 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/chroma-js": "2.4.4",
|
"@types/chroma-js": "2.4.4",
|
||||||
"@types/node": "^20.12.11",
|
"@types/node": "^20.12.12",
|
||||||
"@types/react": "^18.3.2",
|
"@types/react": "^18.3.2",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"tsx": "4.10.0",
|
"tsx": "4.10.3",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
|||||||
@@ -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 { signIn } from "@homarr/auth/client";
|
||||||
import { useForm, zodResolver } from "@homarr/form";
|
import { useForm, zodResolver } from "@homarr/form";
|
||||||
|
import {
|
||||||
|
showErrorNotification,
|
||||||
|
showSuccessNotification,
|
||||||
|
} from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
@@ -44,11 +48,19 @@ export const LoginForm = () => {
|
|||||||
throw response?.error;
|
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) => {
|
.catch((error: Error | string) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(error.toString());
|
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")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" fullWidth loading={isLoading}>
|
<Button type="submit" fullWidth loading={isLoading}>
|
||||||
{t("action.login")}
|
{t("action.login.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</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 function NotFound() {
|
||||||
|
return notFound();
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 && (
|
{hasFullAccess && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Label c="red.7">
|
<Menu.Label c="red.7">{tCommon("dangerZone")}</Menu.Label>
|
||||||
{tCommon("menu.section.dangerZone.title")}
|
|
||||||
</Menu.Label>
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
c="red.7"
|
c="red.7"
|
||||||
leftSection={<IconTrash {...iconProps} />}
|
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 { 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() {
|
export async function generateMetadata() {
|
||||||
const t = await getScopedI18n("management");
|
const t = await getScopedI18n("management");
|
||||||
@@ -14,20 +24,76 @@ export async function generateMetadata() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default async function ManagementPage() {
|
export default async function ManagementPage() {
|
||||||
const t = await getScopedI18n("management.title");
|
const statistics = await api.home.getStats();
|
||||||
|
const t = await getScopedI18n("management.page.home");
|
||||||
const dateNow = new Date();
|
|
||||||
const timeOfDay =
|
|
||||||
dateNow.getHours() < 10
|
|
||||||
? "morning"
|
|
||||||
: dateNow.getHours() < 17
|
|
||||||
? "afternoon"
|
|
||||||
: "evening";
|
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Title>{t(timeOfDay, { username: "admin" })}</Title>
|
<HeroBanner />
|
||||||
<Test />
|
<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 { notFound } from "next/navigation";
|
||||||
import {
|
import { Box, Group, Stack, Title } from "@mantine/core";
|
||||||
Accordion,
|
|
||||||
AccordionControl,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionPanel,
|
|
||||||
Avatar,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
import {
|
|
||||||
IconAlertTriangleFilled,
|
|
||||||
IconSettingsFilled,
|
|
||||||
IconShieldLockFilled,
|
|
||||||
IconUserFilled,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
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 {
|
||||||
import { ProfileAccordion } from "./_components/profile.accordion";
|
DangerZoneItem,
|
||||||
import { SecurityAccordionComponent } from "./_components/security.accordion";
|
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 {
|
interface Props {
|
||||||
params: {
|
params: {
|
||||||
@@ -31,9 +22,17 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }: Props) {
|
export async function generateMetadata({ params }: Props) {
|
||||||
const user = await api.user.getById({
|
const session = await auth();
|
||||||
userId: params.userId,
|
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 t = await getScopedI18n("management.page.user.edit");
|
||||||
const metaTitle = `${t("metaTitle", { username: user?.name })} • Homarr`;
|
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) {
|
export default async function EditUserPage({ params }: Props) {
|
||||||
const t = await getScopedI18n("management.page.user.edit");
|
const t = await getI18n();
|
||||||
const user = await api.user.getById({
|
const tGeneral = await getScopedI18n("management.page.user.setting.general");
|
||||||
userId: params.userId,
|
const session = await auth();
|
||||||
});
|
const user = await api.user
|
||||||
|
.getById({
|
||||||
|
userId: params.userId,
|
||||||
|
})
|
||||||
|
.catch(catchTrpcNotFound);
|
||||||
|
|
||||||
if (!user) {
|
if (!canAccessUserEditPage(session, user.id)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Group mb="md">
|
<Title>{tGeneral("title")}</Title>
|
||||||
<Avatar>{user.name?.substring(0, 2)}</Avatar>
|
<Group gap="xl">
|
||||||
<Title>{user.name}</Title>
|
<Box flex={1}>
|
||||||
|
<UserProfileForm user={user} />
|
||||||
|
</Box>
|
||||||
|
<Box w={{ base: "100%", lg: 200 }}>
|
||||||
|
<UserProfileAvatarForm user={user} />
|
||||||
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
<Accordion variant="separated" defaultValue="general">
|
|
||||||
<AccordionItem value="general">
|
<DangerZoneRoot>
|
||||||
<AccordionControl icon={<IconUserFilled />}>
|
<DangerZoneItem
|
||||||
<Text fw="bold" size="lg">
|
label={t("user.action.delete.label")}
|
||||||
{t("section.profile.title")}
|
description={t("user.action.delete.description")}
|
||||||
</Text>
|
action={<DeleteUserButton user={user} />}
|
||||||
</AccordionControl>
|
/>
|
||||||
<AccordionPanel>
|
</DangerZoneRoot>
|
||||||
<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>
|
|
||||||
</Stack>
|
</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 {
|
import { Stack, Title } from "@mantine/core";
|
||||||
Card,
|
|
||||||
CardSection,
|
|
||||||
Divider,
|
|
||||||
Group,
|
|
||||||
Stack,
|
|
||||||
Text,
|
|
||||||
Title,
|
|
||||||
} from "@mantine/core";
|
|
||||||
|
|
||||||
import { api } from "@homarr/api/server";
|
import { api } from "@homarr/api/server";
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DangerZoneItem,
|
||||||
|
DangerZoneRoot,
|
||||||
|
} from "~/components/manage/danger-zone";
|
||||||
import { DeleteGroup } from "./_delete-group";
|
import { DeleteGroup } from "./_delete-group";
|
||||||
import { RenameGroupForm } from "./_rename-group-form";
|
import { RenameGroupForm } from "./_rename-group-form";
|
||||||
import { TransferGroupOwnership } from "./_transfer-group-ownership";
|
import { TransferGroupOwnership } from "./_transfer-group-ownership";
|
||||||
@@ -34,42 +30,19 @@ export default async function GroupsDetailPage({
|
|||||||
|
|
||||||
<RenameGroupForm group={group} />
|
<RenameGroupForm group={group} />
|
||||||
|
|
||||||
<Stack gap="sm">
|
<DangerZoneRoot>
|
||||||
<Title c="red.8" order={2}>
|
<DangerZoneItem
|
||||||
{tGeneral("dangerZone")}
|
label={tGroupAction("transfer.label")}
|
||||||
</Title>
|
description={tGroupAction("transfer.description")}
|
||||||
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)" }}>
|
action={<TransferGroupOwnership group={group} />}
|
||||||
<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>
|
|
||||||
|
|
||||||
<CardSection>
|
<DangerZoneItem
|
||||||
<Divider />
|
label={tGroupAction("delete.label")}
|
||||||
</CardSection>
|
description={tGroupAction("delete.description")}
|
||||||
|
action={<DeleteGroup group={group} />}
|
||||||
<Group justify="space-between" px="md">
|
/>
|
||||||
<Stack gap={0}>
|
</DangerZoneRoot>
|
||||||
<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>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { useAtomValue } from "jotai";
|
|||||||
|
|
||||||
import { clientApi } from "@homarr/api/client";
|
import { clientApi } from "@homarr/api/client";
|
||||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||||
import {
|
import {
|
||||||
loadWidgetDynamic,
|
loadWidgetDynamic,
|
||||||
reduceWidgetOptionsWithDefaultValues,
|
reduceWidgetOptionsWithDefaultValues,
|
||||||
@@ -39,56 +39,69 @@ interface Props {
|
|||||||
|
|
||||||
export const SectionContent = ({ items, refs }: Props) => {
|
export const SectionContent = ({ items, refs }: Props) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const { ref, width, height } = useElementSize<HTMLDivElement>();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{items.map((item) => {
|
{items.map((item) => (
|
||||||
return (
|
<BoardItem
|
||||||
<div
|
key={item.id}
|
||||||
key={item.id}
|
refs={refs}
|
||||||
className="grid-stack-item"
|
item={item}
|
||||||
data-id={item.id}
|
opacity={board.opacity}
|
||||||
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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ItemProps {
|
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;
|
item: Item;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BoardItem = ({ item, ...dimensions }: ItemProps) => {
|
const BoardItemContent = ({ item, ...dimensions }: ItemContentProps) => {
|
||||||
const board = useRequiredBoard();
|
const board = useRequiredBoard();
|
||||||
const editMode = useAtomValue(editModeAtom);
|
const editMode = useAtomValue(editModeAtom);
|
||||||
const serverData = useServerDataFor(item.id);
|
const serverData = useServerDataFor(item.id);
|
||||||
@@ -115,7 +128,8 @@ const BoardItem = ({ item, ...dimensions }: ItemProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
|
const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
|
||||||
const t = useScopedI18n("item");
|
const tItem = useScopedI18n("item");
|
||||||
|
const t = useI18n();
|
||||||
const { openModal } = useModalAction(WidgetEditModal);
|
const { openModal } = useModalAction(WidgetEditModal);
|
||||||
const { openConfirmModal } = useConfirmModal();
|
const { openConfirmModal } = useConfirmModal();
|
||||||
const isEditMode = useAtomValue(editModeAtom);
|
const isEditMode = useAtomValue(editModeAtom);
|
||||||
@@ -160,8 +174,8 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
|
|||||||
|
|
||||||
const openRemoveModal = () => {
|
const openRemoveModal = () => {
|
||||||
openConfirmModal({
|
openConfirmModal({
|
||||||
title: t("remove.title"),
|
title: tItem("remove.title"),
|
||||||
children: t("remove.message"),
|
children: tItem("remove.message"),
|
||||||
onConfirm: () => {
|
onConfirm: () => {
|
||||||
removeItem({ itemId: item.id });
|
removeItem({ itemId: item.id });
|
||||||
},
|
},
|
||||||
@@ -182,24 +196,24 @@ const ItemMenu = ({ offset, item }: { offset: number; item: Item }) => {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
<Menu.Dropdown miw={128}>
|
<Menu.Dropdown miw={128}>
|
||||||
<Menu.Label>{t("menu.label.settings")}</Menu.Label>
|
<Menu.Label>{tItem("menu.label.settings")}</Menu.Label>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconPencil size={16} />}
|
leftSection={<IconPencil size={16} />}
|
||||||
onClick={openEditModal}
|
onClick={openEditModal}
|
||||||
>
|
>
|
||||||
{t("action.edit")}
|
{tItem("action.edit")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>
|
<Menu.Item leftSection={<IconLayoutKanban size={16} />}>
|
||||||
{t("action.move")}
|
{tItem("action.move")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
<Menu.Divider />
|
<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
|
<Menu.Item
|
||||||
c="red.6"
|
c="red.6"
|
||||||
leftSection={<IconTrash size={16} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
onClick={openRemoveModal}
|
onClick={openRemoveModal}
|
||||||
>
|
>
|
||||||
{t("action.remove")}
|
{tItem("action.remove")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ export const initializeGridstack = ({
|
|||||||
newGrid.current = GridStack.init(
|
newGrid.current = GridStack.init(
|
||||||
{
|
{
|
||||||
column: sectionColumnCount,
|
column: sectionColumnCount,
|
||||||
margin: 10,
|
margin: Math.round(
|
||||||
|
Math.max(Math.min(refs.wrapper.current.offsetWidth / 100, 10), 1),
|
||||||
|
),
|
||||||
cellHeight: 128,
|
cellHeight: 128,
|
||||||
float: true,
|
float: true,
|
||||||
alwaysShowResizeHandle: 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,
|
IconLogin,
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconMoon,
|
IconMoon,
|
||||||
|
IconSettings,
|
||||||
IconSun,
|
IconSun,
|
||||||
IconTool,
|
IconTool,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
@@ -71,6 +72,15 @@ export const UserAvatarMenu = ({ children }: UserAvatarMenuProps) => {
|
|||||||
>
|
>
|
||||||
{t("navigateDefaultBoard")}
|
{t("navigateDefaultBoard")}
|
||||||
</Menu.Item>
|
</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
|
<Menu.Item
|
||||||
component={Link}
|
component={Link}
|
||||||
href="/manage"
|
href="/manage"
|
||||||
@@ -111,7 +121,7 @@ const LogoutModal = createModal<{ onTimeout: () => void }>(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
start();
|
start();
|
||||||
}, []);
|
}, [start]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Center h={200 - 2 * 16}>
|
<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;
|
||||||
|
};
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
"@homarr/prettier-config": "workspace:^0.1.0",
|
"@homarr/prettier-config": "workspace:^0.1.0",
|
||||||
"@homarr/tsconfig": "workspace:^0.1.0",
|
"@homarr/tsconfig": "workspace:^0.1.0",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/node": "^20.12.11",
|
"@types/node": "^20.12.12",
|
||||||
"dotenv-cli": "^7.4.2",
|
"dotenv-cli": "^7.4.2",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"prettier": "^3.2.5",
|
"prettier": "^3.2.5",
|
||||||
"tsx": "4.10.0",
|
"tsx": "4.10.3",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
},
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Stopwatch } from "@homarr/common";
|
import { Stopwatch } from "@homarr/common";
|
||||||
import { db, eq } from "@homarr/db";
|
import type { InferInsertModel } from "@homarr/db";
|
||||||
|
import { db, inArray } from "@homarr/db";
|
||||||
import { createId } from "@homarr/db/client";
|
import { createId } from "@homarr/db/client";
|
||||||
import { iconRepositories, icons } from "@homarr/db/schema/sqlite";
|
import { iconRepositories, icons } from "@homarr/db/schema/sqlite";
|
||||||
import { fetchIconsAsync } from "@homarr/icons";
|
import { fetchIconsAsync } from "@homarr/icons";
|
||||||
@@ -34,52 +35,67 @@ export const iconsUpdaterJob = createCronJob(EVERY_WEEK, {
|
|||||||
logger.info("Updating icons in database...");
|
logger.info("Updating icons in database...");
|
||||||
stopWatch.reset();
|
stopWatch.reset();
|
||||||
|
|
||||||
await db.transaction(async (transaction) => {
|
const newIconRepositories: InferInsertModel<typeof iconRepositories>[] = [];
|
||||||
for (const repositoryIconGroup of repositoryIconGroups) {
|
const newIcons: InferInsertModel<typeof icons>[] = [];
|
||||||
if (!repositoryIconGroup.success) {
|
|
||||||
|
for (const repositoryIconGroup of repositoryIconGroups) {
|
||||||
|
if (!repositoryIconGroup.success) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repositoryInDb = databaseIconGroups.find(
|
||||||
|
(dbIconGroup) => dbIconGroup.slug === repositoryIconGroup.slug,
|
||||||
|
);
|
||||||
|
const repositoryIconGroupId: string = repositoryInDb?.id ?? createId();
|
||||||
|
if (!repositoryInDb?.id) {
|
||||||
|
newIconRepositories.push({
|
||||||
|
id: repositoryIconGroupId,
|
||||||
|
slug: repositoryIconGroup.slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const icon of repositoryIconGroup.icons) {
|
||||||
|
if (
|
||||||
|
databaseIconGroups
|
||||||
|
.flatMap((group) => group.icons)
|
||||||
|
.some((dbIcon) => dbIcon.checksum === icon.checksum)
|
||||||
|
) {
|
||||||
|
skippedChecksums.push(icon.checksum);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const repositoryInDb = databaseIconGroups.find(
|
newIcons.push({
|
||||||
(dbIconGroup) => dbIconGroup.slug === repositoryIconGroup.slug,
|
id: createId(),
|
||||||
);
|
checksum: icon.checksum,
|
||||||
const repositoryIconGroupId: string = repositoryInDb?.id ?? createId();
|
name: icon.fileNameWithExtension,
|
||||||
if (!repositoryInDb?.id) {
|
url: icon.imageUrl.href,
|
||||||
await transaction.insert(iconRepositories).values({
|
iconRepositoryId: repositoryIconGroupId,
|
||||||
id: repositoryIconGroupId,
|
});
|
||||||
slug: repositoryIconGroup.slug,
|
countInserted++;
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const icon of repositoryIconGroup.icons) {
|
const deadIcons = databaseIconGroups
|
||||||
if (
|
.flatMap((group) => group.icons)
|
||||||
databaseIconGroups
|
.filter((icon) => !skippedChecksums.includes(icon.checksum));
|
||||||
.flatMap((group) => group.icons)
|
|
||||||
.some((dbIcon) => dbIcon.checksum === icon.checksum)
|
|
||||||
) {
|
|
||||||
skippedChecksums.push(icon.checksum);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await transaction.insert(icons).values({
|
await db.transaction(async (transaction) => {
|
||||||
id: createId(),
|
if (newIconRepositories.length >= 1) {
|
||||||
checksum: icon.checksum,
|
await transaction.insert(iconRepositories).values(newIconRepositories);
|
||||||
name: icon.fileNameWithExtension,
|
|
||||||
url: icon.imageUrl.href,
|
|
||||||
iconRepositoryId: repositoryIconGroupId,
|
|
||||||
});
|
|
||||||
countInserted++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const deadIcons = databaseIconGroups
|
if (newIcons.length >= 1) {
|
||||||
.flatMap((group) => group.icons)
|
await transaction.insert(icons).values(newIcons);
|
||||||
.filter((icon) => !skippedChecksums.includes(icon.checksum));
|
|
||||||
|
|
||||||
for (const icon of deadIcons) {
|
|
||||||
await transaction.delete(icons).where(eq(icons.checksum, icon.checksum));
|
|
||||||
countDeleted++;
|
|
||||||
}
|
}
|
||||||
|
await transaction.delete(icons).where(
|
||||||
|
deadIcons.length >= 1
|
||||||
|
? inArray(
|
||||||
|
icons.checksum,
|
||||||
|
deadIcons.map((icon) => icon.checksum),
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
countDeleted += deadIcons.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.13.1"
|
"node": ">=20.13.1"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.1.0",
|
"packageManager": "pnpm@9.1.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
"clean": "git clean -xdf node_modules",
|
"clean": "git clean -xdf node_modules",
|
||||||
"clean:workspaces": "turbo clean",
|
"clean:workspaces": "turbo clean",
|
||||||
"db:push": "pnpm -F db push",
|
"db:push": "pnpm -F db push:sqlite",
|
||||||
"db:studio": "pnpm -F db studio",
|
"db:studio": "pnpm -F db studio",
|
||||||
"db:migration:sqlite:generate": "pnpm -F db migration:sqlite:generate",
|
"db:migration:sqlite:generate": "pnpm -F db migration:sqlite:generate",
|
||||||
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
|
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
|
||||||
@@ -43,8 +43,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^7.9.1",
|
"@mantine/core": "^7.9.1",
|
||||||
"@mantine/dates": "^7.9.1",
|
"@mantine/dates": "^7.9.1",
|
||||||
"@tabler/icons-react": "^3.3.0",
|
"@tabler/icons-react": "^3.4.0",
|
||||||
"mantine-react-table": "2.0.0-beta.2"
|
"mantine-react-table": "2.0.0-beta.3"
|
||||||
},
|
},
|
||||||
"prettier": "@homarr/prettier-config"
|
"prettier": "@homarr/prettier-config"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { appRouter as innerAppRouter } from "./router/app";
|
import { appRouter as innerAppRouter } from "./router/app";
|
||||||
import { boardRouter } from "./router/board";
|
import { boardRouter } from "./router/board";
|
||||||
import { groupRouter } from "./router/group";
|
import { groupRouter } from "./router/group";
|
||||||
|
import { homeRouter } from "./router/home";
|
||||||
import { iconsRouter } from "./router/icons";
|
import { iconsRouter } from "./router/icons";
|
||||||
import { integrationRouter } from "./router/integration";
|
import { integrationRouter } from "./router/integration";
|
||||||
import { inviteRouter } from "./router/invite";
|
import { inviteRouter } from "./router/invite";
|
||||||
@@ -21,6 +22,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
location: locationRouter,
|
location: locationRouter,
|
||||||
log: logRouter,
|
log: logRouter,
|
||||||
icon: iconsRouter,
|
icon: iconsRouter,
|
||||||
|
home: homeRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
31
packages/api/src/router/home.ts
Normal file
31
packages/api/src/router/home.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { count } from "@homarr/db";
|
||||||
|
import {
|
||||||
|
apps,
|
||||||
|
boards,
|
||||||
|
groups,
|
||||||
|
integrations,
|
||||||
|
invites,
|
||||||
|
users,
|
||||||
|
} from "@homarr/db/schema/sqlite";
|
||||||
|
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||||
|
|
||||||
|
export const homeRouter = createTRPCRouter({
|
||||||
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return {
|
||||||
|
countBoards:
|
||||||
|
(await ctx.db.select({ count: count() }).from(boards))[0]?.count ?? 0,
|
||||||
|
countUsers:
|
||||||
|
(await ctx.db.select({ count: count() }).from(users))[0]?.count ?? 0,
|
||||||
|
countGroups:
|
||||||
|
(await ctx.db.select({ count: count() }).from(groups))[0]?.count ?? 0,
|
||||||
|
countInvites:
|
||||||
|
(await ctx.db.select({ count: count() }).from(invites))[0]?.count ?? 0,
|
||||||
|
countIntegrations:
|
||||||
|
(await ctx.db.select({ count: count() }).from(integrations))[0]
|
||||||
|
?.count ?? 0,
|
||||||
|
countApps:
|
||||||
|
(await ctx.db.select({ count: count() }).from(apps))[0]?.count ?? 0,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, test, vi } from "vitest";
|
|||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { createId, eq, schema } from "@homarr/db";
|
import { createId, eq, schema } from "@homarr/db";
|
||||||
|
import { users } from "@homarr/db/schema/sqlite";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
|
|
||||||
import { userRouter } from "../user";
|
import { userRouter } from "../user";
|
||||||
@@ -91,7 +92,106 @@ describe("initUser should initialize the first user", () => {
|
|||||||
|
|
||||||
await expect(act()).rejects.toThrow("too_small");
|
await expect(act()).rejects.toThrow("too_small");
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("register should create a user with valid invitation", () => {
|
||||||
|
test("register should create a user with valid invitation", async () => {
|
||||||
|
// Arrange
|
||||||
|
const db = createDb();
|
||||||
|
const caller = userRouter.createCaller({
|
||||||
|
db,
|
||||||
|
session: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userId = createId();
|
||||||
|
const inviteId = createId();
|
||||||
|
const inviteToken = "123";
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date(2024, 0, 3));
|
||||||
|
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
await db.insert(schema.invites).values({
|
||||||
|
id: inviteId,
|
||||||
|
token: inviteToken,
|
||||||
|
creatorId: userId,
|
||||||
|
expirationDate: new Date(2024, 0, 5),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await caller.register({
|
||||||
|
inviteId,
|
||||||
|
token: inviteToken,
|
||||||
|
username: "test",
|
||||||
|
password: "12345678",
|
||||||
|
confirmPassword: "12345678",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const user = await db.query.users.findMany({
|
||||||
|
columns: {
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const invite = await db.query.invites.findMany({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(user).toHaveLength(2);
|
||||||
|
expect(invite).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[{ token: "fakeToken" }, new Date(2024, 0, 3)],
|
||||||
|
[{ inviteId: "fakeInviteId" }, new Date(2024, 0, 3)],
|
||||||
|
[{}, new Date(2024, 0, 5, 0, 0, 1)],
|
||||||
|
])(
|
||||||
|
"register should throw an error with input %s and date %s if the invitation is invalid",
|
||||||
|
async (partialInput, systemTime) => {
|
||||||
|
// Arrange
|
||||||
|
const db = createDb();
|
||||||
|
const caller = userRouter.createCaller({
|
||||||
|
db,
|
||||||
|
session: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const userId = createId();
|
||||||
|
const inviteId = createId();
|
||||||
|
const inviteToken = "123";
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(systemTime);
|
||||||
|
|
||||||
|
await db.insert(users).values({
|
||||||
|
id: userId,
|
||||||
|
});
|
||||||
|
await db.insert(schema.invites).values({
|
||||||
|
id: inviteId,
|
||||||
|
token: inviteToken,
|
||||||
|
creatorId: userId,
|
||||||
|
expirationDate: new Date(2024, 0, 5),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
const act = async () =>
|
||||||
|
await caller.register({
|
||||||
|
inviteId,
|
||||||
|
token: inviteToken,
|
||||||
|
username: "test",
|
||||||
|
password: "12345678",
|
||||||
|
confirmPassword: "12345678",
|
||||||
|
...partialInput,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
await expect(act()).rejects.toThrow("Invalid invite");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("editProfile shoud update user", () => {
|
||||||
test("editProfile should update users and not update emailVerified when email not dirty", async () => {
|
test("editProfile should update users and not update emailVerified when email not dirty", async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
@@ -112,11 +212,9 @@ describe("initUser should initialize the first user", () => {
|
|||||||
|
|
||||||
// act
|
// act
|
||||||
await caller.editProfile({
|
await caller.editProfile({
|
||||||
userId: id,
|
id: id,
|
||||||
form: {
|
name: "ABC",
|
||||||
name: "ABC",
|
email: "",
|
||||||
email: "",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
@@ -156,11 +254,9 @@ describe("initUser should initialize the first user", () => {
|
|||||||
|
|
||||||
// act
|
// act
|
||||||
await caller.editProfile({
|
await caller.editProfile({
|
||||||
userId: id,
|
id,
|
||||||
form: {
|
name: "ABC",
|
||||||
name: "ABC",
|
email: "myNewEmail@gmail.com",
|
||||||
email: "myNewEmail@gmail.com",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
@@ -180,7 +276,9 @@ describe("initUser should initialize the first user", () => {
|
|||||||
image: null,
|
image: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delete should delete user", () => {
|
||||||
test("delete should delete user", async () => {
|
test("delete should delete user", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = userRouter.createCaller({
|
const caller = userRouter.createCaller({
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ import { observable } from "@trpc/server/observable";
|
|||||||
|
|
||||||
import { createSalt, hashPassword } from "@homarr/auth";
|
import { createSalt, hashPassword } from "@homarr/auth";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { createId, eq, schema } from "@homarr/db";
|
import { and, createId, eq, schema } from "@homarr/db";
|
||||||
import { users } from "@homarr/db/schema/sqlite";
|
import { invites, users } from "@homarr/db/schema/sqlite";
|
||||||
import { exampleChannel } from "@homarr/redis";
|
import { exampleChannel } from "@homarr/redis";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
|
|
||||||
export const userRouter = createTRPCRouter({
|
export const userRouter = createTRPCRouter({
|
||||||
initUser: publicProcedure
|
initUser: publicProcedure
|
||||||
@@ -29,19 +29,86 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await createUser(ctx.db, input);
|
await createUser(ctx.db, input);
|
||||||
}),
|
}),
|
||||||
|
register: publicProcedure
|
||||||
|
.input(validation.user.registrationApi)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const inviteWhere = and(
|
||||||
|
eq(invites.id, input.inviteId),
|
||||||
|
eq(invites.token, input.token),
|
||||||
|
);
|
||||||
|
const dbInvite = await ctx.db.query.invites.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
expirationDate: true,
|
||||||
|
},
|
||||||
|
where: inviteWhere,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbInvite || dbInvite.expirationDate < new Date()) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Invalid invite",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
|
||||||
|
|
||||||
|
await createUser(ctx.db, input);
|
||||||
|
// Delete invite as it's used
|
||||||
|
await ctx.db.delete(invites).where(inviteWhere);
|
||||||
|
}),
|
||||||
create: publicProcedure
|
create: publicProcedure
|
||||||
.input(validation.user.create)
|
.input(validation.user.create)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const user = await ctx.db.query.users.findFirst({
|
await checkUsernameAlreadyTakenAndThrowAsync(ctx.db, input.username);
|
||||||
where: eq(users.name, input.username.toLowerCase()),
|
|
||||||
});
|
await createUser(ctx.db, input);
|
||||||
if (user !== undefined) {
|
}),
|
||||||
|
setProfileImage: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
// Max image size of 256KB, only png and jpeg are allowed
|
||||||
|
image: z
|
||||||
|
.string()
|
||||||
|
.regex(/^data:image\/(png|jpeg|gif|webp);base64,[A-Za-z0-9/+]+=*$/g)
|
||||||
|
.max(262144)
|
||||||
|
.nullable(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
// Only admins can change other users profile images
|
||||||
|
if (
|
||||||
|
ctx.session.user.id !== input.userId &&
|
||||||
|
!ctx.session.user.permissions.includes("admin")
|
||||||
|
) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "CONFLICT",
|
code: "FORBIDDEN",
|
||||||
message: "User already exists",
|
message: "You are not allowed to change other users profile images",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await createUser(ctx.db, input);
|
|
||||||
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
image: true,
|
||||||
|
},
|
||||||
|
where: eq(users.id, input.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
image: input.image,
|
||||||
|
})
|
||||||
|
.where(eq(users.id, input.userId));
|
||||||
}),
|
}),
|
||||||
getAll: publicProcedure.query(async ({ ctx }) => {
|
getAll: publicProcedure.query(async ({ ctx }) => {
|
||||||
return ctx.db.query.users.findMany({
|
return ctx.db.query.users.findMany({
|
||||||
@@ -66,7 +133,7 @@ export const userRouter = createTRPCRouter({
|
|||||||
getById: publicProcedure
|
getById: publicProcedure
|
||||||
.input(z.object({ userId: z.string() }))
|
.input(z.object({ userId: z.string() }))
|
||||||
.query(async ({ input, ctx }) => {
|
.query(async ({ input, ctx }) => {
|
||||||
return ctx.db.query.users.findFirst({
|
const user = await ctx.db.query.users.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
@@ -76,47 +143,96 @@ export const userRouter = createTRPCRouter({
|
|||||||
},
|
},
|
||||||
where: eq(users.id, input.userId),
|
where: eq(users.id, input.userId),
|
||||||
});
|
});
|
||||||
}),
|
|
||||||
editProfile: publicProcedure
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
form: validation.user.editProfile,
|
|
||||||
userId: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mutation(async ({ input, ctx }) => {
|
|
||||||
const user = await ctx.db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.id, input.userId))
|
|
||||||
.limit(1);
|
|
||||||
const existingUser = await ctx.db.query.users.findFirst({
|
|
||||||
where: eq(users.name, input.form.name.toLowerCase()),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existingUser !== undefined) {
|
if (!user) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: "CONFLICT",
|
code: "NOT_FOUND",
|
||||||
message: `User ${input.form.name} already exists`,
|
message: "User not found",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const emailDirty =
|
|
||||||
input.form.email && user[0]?.email !== input.form.email;
|
return user;
|
||||||
|
}),
|
||||||
|
editProfile: publicProcedure
|
||||||
|
.input(validation.user.editProfile)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
const user = await ctx.db.query.users.findFirst({
|
||||||
|
columns: { email: true },
|
||||||
|
where: eq(users.id, input.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkUsernameAlreadyTakenAndThrowAsync(
|
||||||
|
ctx.db,
|
||||||
|
input.name,
|
||||||
|
input.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailDirty = input.email && user.email !== input.email;
|
||||||
await ctx.db
|
await ctx.db
|
||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
name: input.form.name,
|
name: input.name,
|
||||||
email: emailDirty === true ? input.form.email : undefined,
|
email: emailDirty === true ? input.email : undefined,
|
||||||
emailVerified: emailDirty === true ? null : undefined,
|
emailVerified: emailDirty === true ? null : undefined,
|
||||||
})
|
})
|
||||||
.where(eq(users.id, input.userId));
|
.where(eq(users.id, input.id));
|
||||||
}),
|
}),
|
||||||
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
|
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
|
||||||
await ctx.db.delete(users).where(eq(users.id, input));
|
await ctx.db.delete(users).where(eq(users.id, input));
|
||||||
}),
|
}),
|
||||||
changePassword: publicProcedure
|
changePassword: protectedProcedure
|
||||||
.input(validation.user.changePassword)
|
.input(validation.user.changePasswordApi)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
const user = ctx.session.user;
|
||||||
|
// Only admins can change other users' passwords
|
||||||
|
if (!user.permissions.includes("admin") && user.id !== input.userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins can change the password of other users without providing the previous password
|
||||||
|
const isPreviousPasswordRequired = ctx.session.user.id === input.userId;
|
||||||
|
|
||||||
|
if (isPreviousPasswordRequired) {
|
||||||
|
const dbUser = await ctx.db.query.users.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
password: true,
|
||||||
|
salt: true,
|
||||||
|
},
|
||||||
|
where: eq(users.id, input.userId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dbUser) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousPasswordHash = await hashPassword(
|
||||||
|
input.previousPassword,
|
||||||
|
dbUser.salt ?? "",
|
||||||
|
);
|
||||||
|
const isValid = previousPasswordHash === dbUser.password;
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: "Invalid password",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const salt = await createSalt();
|
const salt = await createSalt();
|
||||||
const hashedPassword = await hashPassword(input.password, salt);
|
const hashedPassword = await hashPassword(input.password, salt);
|
||||||
await ctx.db
|
await ctx.db
|
||||||
@@ -155,3 +271,21 @@ const createUser = async (
|
|||||||
salt,
|
salt,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const checkUsernameAlreadyTakenAndThrowAsync = async (
|
||||||
|
db: Database,
|
||||||
|
username: string,
|
||||||
|
ignoreId?: string,
|
||||||
|
) => {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
where: eq(users.name, username.toLowerCase()),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) return;
|
||||||
|
if (ignoreId && user.id === ignoreId) return;
|
||||||
|
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "CONFLICT",
|
||||||
|
message: "Username already taken",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
7
packages/common/src/array.ts
Normal file
7
packages/common/src/array.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const splitToNChunks = <T>(array: T[], chunks: number): T[][] => {
|
||||||
|
const result: T[][] = [];
|
||||||
|
for (let i = chunks; i > 0; i--) {
|
||||||
|
result.push(array.splice(0, Math.ceil(array.length / i)));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./object";
|
export * from "./object";
|
||||||
export * from "./string";
|
export * from "./string";
|
||||||
export * from "./cookie";
|
export * from "./cookie";
|
||||||
|
export * from "./array";
|
||||||
export * from "./stopwatch";
|
export * from "./stopwatch";
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts",
|
"migration:mysql:run": "drizzle-kit migrate --config ./configs/mysql.config.ts",
|
||||||
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
|
"push:sqlite": "drizzle-kit push --config ./configs/sqlite.config.ts",
|
||||||
"push:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
|
"push:mysql": "drizzle-kit push --config ./configs/mysql.config.ts",
|
||||||
"studio": "drizzle-kit studio",
|
"studio": "drizzle-kit studio --config ./configs/sqlite.config.ts",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -33,10 +33,10 @@
|
|||||||
"@homarr/definitions": "workspace:^0.1.0",
|
"@homarr/definitions": "workspace:^0.1.0",
|
||||||
"@homarr/log": "workspace:^0.1.0",
|
"@homarr/log": "workspace:^0.1.0",
|
||||||
"@paralleldrive/cuid2": "^2.2.2",
|
"@paralleldrive/cuid2": "^2.2.2",
|
||||||
"better-sqlite3": "^9.6.0",
|
"better-sqlite3": "^10.0.0",
|
||||||
"drizzle-orm": "^0.30.10",
|
"drizzle-orm": "^0.30.10",
|
||||||
"mysql2": "3.9.7",
|
"mysql2": "3.9.7",
|
||||||
"drizzle-kit": "^0.21.1"
|
"drizzle-kit": "^0.21.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@homarr/eslint-config": "workspace:^0.2.0",
|
"@homarr/eslint-config": "workspace:^0.2.0",
|
||||||
|
|||||||
5
packages/redis/redis.conf
Normal file
5
packages/redis/redis.conf
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Directory to store dump.rdb
|
||||||
|
dir /appdata/redis
|
||||||
|
|
||||||
|
# Save the data to disk every 60 seconds if at least 1 key changed
|
||||||
|
save 60 1
|
||||||
@@ -2,11 +2,18 @@ import "dayjs/locale/en";
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
user: {
|
user: {
|
||||||
|
title: "Users",
|
||||||
|
name: "User",
|
||||||
page: {
|
page: {
|
||||||
login: {
|
login: {
|
||||||
title: "Log in to your account",
|
title: "Log in to your account",
|
||||||
subtitle: "Welcome back! Please enter your credentials",
|
subtitle: "Welcome back! Please enter your credentials",
|
||||||
},
|
},
|
||||||
|
invite: {
|
||||||
|
title: "Join Homarr",
|
||||||
|
subtitle: "Welcome to Homarr! Please create your account",
|
||||||
|
description: "You were invited by {username}",
|
||||||
|
},
|
||||||
init: {
|
init: {
|
||||||
title: "New Homarr installation",
|
title: "New Homarr installation",
|
||||||
subtitle: "Please create the initial administator user",
|
subtitle: "Please create the initial administator user",
|
||||||
@@ -25,10 +32,98 @@ export default {
|
|||||||
passwordConfirm: {
|
passwordConfirm: {
|
||||||
label: "Confirm password",
|
label: "Confirm password",
|
||||||
},
|
},
|
||||||
|
previousPassword: {
|
||||||
|
label: "Previous password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
usernameTaken: "Username already taken",
|
||||||
},
|
},
|
||||||
action: {
|
action: {
|
||||||
login: "Login",
|
login: {
|
||||||
|
label: "Login",
|
||||||
|
notification: {
|
||||||
|
success: {
|
||||||
|
title: "Login successful",
|
||||||
|
message: "You are now logged in",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
title: "Login failed",
|
||||||
|
message: "Your login failed",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
register: {
|
||||||
|
label: "Create account",
|
||||||
|
notification: {
|
||||||
|
success: {
|
||||||
|
title: "Account created",
|
||||||
|
message: "Please log in to continue",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
title: "Account creation failed",
|
||||||
|
message: "Your account could not be created",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
create: "Create user",
|
create: "Create user",
|
||||||
|
changePassword: {
|
||||||
|
label: "Change password",
|
||||||
|
notification: {
|
||||||
|
success: {
|
||||||
|
message: "Password changed successfully",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
message: "Unable to change password",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
manageAvatar: {
|
||||||
|
changeImage: {
|
||||||
|
label: "Change image",
|
||||||
|
notification: {
|
||||||
|
success: {
|
||||||
|
message: "The image changed successfully",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
message: "Unable to change image",
|
||||||
|
},
|
||||||
|
toLarge: {
|
||||||
|
title: "Image is too large",
|
||||||
|
message: "Max image size is {size}",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
removeImage: {
|
||||||
|
label: "Remove image",
|
||||||
|
confirm: "Are you sure you want to remove the image?",
|
||||||
|
notification: {
|
||||||
|
success: {
|
||||||
|
message: "Image removed successfully",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
message: "Unable to remove image",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
editProfile: {
|
||||||
|
notification: {
|
||||||
|
success: {
|
||||||
|
message: "Profile updated successfully",
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
message: "Unable to update profile",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
delete: {
|
||||||
|
label: "Delete user permanently",
|
||||||
|
description:
|
||||||
|
"Deletes this user including their preferences. Will not delete any boards. User will not be notified.",
|
||||||
|
confirm:
|
||||||
|
"Are you sure, that you want to delete the user {username} with his preferences?",
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
label: "Select user",
|
label: "Select user",
|
||||||
notFound: "No user found",
|
notFound: "No user found",
|
||||||
@@ -109,10 +204,10 @@ export default {
|
|||||||
label: "New group",
|
label: "New group",
|
||||||
notification: {
|
notification: {
|
||||||
success: {
|
success: {
|
||||||
message: "The app was successfully created",
|
message: "The group was successfully created",
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
message: "The app could not be created",
|
message: "The group could not be created",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -353,6 +448,7 @@ export default {
|
|||||||
save: "Save",
|
save: "Save",
|
||||||
saveChanges: "Save changes",
|
saveChanges: "Save changes",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
|
delete: "Delete",
|
||||||
discard: "Discard",
|
discard: "Discard",
|
||||||
confirm: "Confirm",
|
confirm: "Confirm",
|
||||||
continue: "Continue",
|
continue: "Continue",
|
||||||
@@ -406,19 +502,14 @@ export default {
|
|||||||
switchToDarkMode: "Switch to dark mode",
|
switchToDarkMode: "Switch to dark mode",
|
||||||
switchToLightMode: "Switch to light mode",
|
switchToLightMode: "Switch to light mode",
|
||||||
management: "Management",
|
management: "Management",
|
||||||
|
preferences: "Your preferences",
|
||||||
logout: "Logout",
|
logout: "Logout",
|
||||||
login: "Login",
|
login: "Login",
|
||||||
navigateDefaultBoard: "Navigate to default board",
|
navigateDefaultBoard: "Navigate to default board",
|
||||||
loggedOut: "Logged out",
|
loggedOut: "Logged out",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
menu: {
|
dangerZone: "Danger zone",
|
||||||
section: {
|
|
||||||
dangerZone: {
|
|
||||||
title: "Danger Zone",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
noResults: "No results found",
|
noResults: "No results found",
|
||||||
preview: {
|
preview: {
|
||||||
show: "Show preview",
|
show: "Show preview",
|
||||||
@@ -472,7 +563,6 @@ export default {
|
|||||||
menu: {
|
menu: {
|
||||||
label: {
|
label: {
|
||||||
settings: "Settings",
|
settings: "Settings",
|
||||||
dangerZone: "Danger Zone",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
@@ -917,7 +1007,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
dangerZone: {
|
dangerZone: {
|
||||||
title: "Danger Zone",
|
title: "Danger zone",
|
||||||
action: {
|
action: {
|
||||||
rename: {
|
rename: {
|
||||||
label: "Rename board",
|
label: "Rename board",
|
||||||
@@ -1012,6 +1102,22 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
page: {
|
page: {
|
||||||
|
home: {
|
||||||
|
statistic: {
|
||||||
|
countBoards: "Boards",
|
||||||
|
createUser: "Create new user",
|
||||||
|
createInvite: "Create new invite",
|
||||||
|
addIntegration: "Create integration",
|
||||||
|
addApp: "Add app",
|
||||||
|
manageRoles: "Manage roles",
|
||||||
|
},
|
||||||
|
statisticLabel: {
|
||||||
|
boards: "Boards",
|
||||||
|
resources: "Resources",
|
||||||
|
authentication: "Authentication",
|
||||||
|
authorization: "Authorization",
|
||||||
|
},
|
||||||
|
},
|
||||||
board: {
|
board: {
|
||||||
title: "Your boards",
|
title: "Your boards",
|
||||||
action: {
|
action: {
|
||||||
@@ -1047,46 +1153,21 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
|
back: "Back to users",
|
||||||
|
setting: {
|
||||||
|
general: {
|
||||||
|
title: "General",
|
||||||
|
},
|
||||||
|
security: {
|
||||||
|
title: "Security",
|
||||||
|
},
|
||||||
|
},
|
||||||
list: {
|
list: {
|
||||||
metaTitle: "Manage users",
|
metaTitle: "Manage users",
|
||||||
title: "Users",
|
title: "Users",
|
||||||
},
|
},
|
||||||
edit: {
|
edit: {
|
||||||
metaTitle: "Edit user {username}",
|
metaTitle: "Edit user {username}",
|
||||||
section: {
|
|
||||||
profile: {
|
|
||||||
title: "Profile",
|
|
||||||
editProfile: {
|
|
||||||
title: "Edit profile",
|
|
||||||
message: {
|
|
||||||
profileUpdated: "Updated profile",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
preferences: {
|
|
||||||
title: "Preferences",
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
title: "Security",
|
|
||||||
changePassword: {
|
|
||||||
title: "Change password",
|
|
||||||
message: {
|
|
||||||
passwordUpdated: "Updated password",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
dangerZone: {
|
|
||||||
title: "Danger zone",
|
|
||||||
action: {
|
|
||||||
delete: {
|
|
||||||
label: "Delete user permanently",
|
|
||||||
description:
|
|
||||||
"Deletes this user including their preferences. Will not delete any boards. User will not be notified.",
|
|
||||||
button: "Delete",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
metaTitle: "Create user",
|
metaTitle: "Create user",
|
||||||
@@ -1159,7 +1240,6 @@ export default {
|
|||||||
setting: {
|
setting: {
|
||||||
general: {
|
general: {
|
||||||
title: "General",
|
title: "General",
|
||||||
dangerZone: "Danger zone",
|
|
||||||
},
|
},
|
||||||
members: {
|
members: {
|
||||||
title: "Members",
|
title: "Members",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import type { AvatarProps } from "@mantine/core";
|
||||||
import { Avatar } from "@mantine/core";
|
import { Avatar } from "@mantine/core";
|
||||||
import type { AvatarProps, MantineSize } from "@mantine/core";
|
|
||||||
|
|
||||||
export interface UserProps {
|
export interface UserProps {
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -8,7 +8,7 @@ export interface UserProps {
|
|||||||
|
|
||||||
interface UserAvatarProps {
|
interface UserAvatarProps {
|
||||||
user: UserProps | null;
|
user: UserProps | null;
|
||||||
size: MantineSize;
|
size: AvatarProps["size"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserAvatar = ({ user, size }: UserAvatarProps) => {
|
export const UserAvatar = ({ user, size }: UserAvatarProps) => {
|
||||||
|
|||||||
@@ -22,7 +22,26 @@ const signInSchema = z.object({
|
|||||||
password: z.string(),
|
password: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const registrationSchema = z
|
||||||
|
.object({
|
||||||
|
username: usernameSchema,
|
||||||
|
password: passwordSchema,
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
message: "Passwords do not match",
|
||||||
|
});
|
||||||
|
|
||||||
|
const registrationSchemaApi = registrationSchema.and(
|
||||||
|
z.object({
|
||||||
|
inviteId: z.string(),
|
||||||
|
token: z.string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const editProfileSchema = z.object({
|
const editProfileSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
name: usernameSchema,
|
name: usernameSchema,
|
||||||
email: z
|
email: z
|
||||||
.string()
|
.string()
|
||||||
@@ -33,16 +52,29 @@ const editProfileSchema = z.object({
|
|||||||
.nullable(),
|
.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const changePasswordSchema = z.object({
|
const changePasswordSchema = z
|
||||||
userId: z.string(),
|
.object({
|
||||||
password: passwordSchema,
|
previousPassword: z.string(),
|
||||||
});
|
password: passwordSchema,
|
||||||
|
confirmPassword: z.string(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
path: ["confirmPassword"],
|
||||||
|
message: "Passwords do not match",
|
||||||
|
});
|
||||||
|
|
||||||
|
const changePasswordApiSchema = changePasswordSchema.and(
|
||||||
|
z.object({ userId: z.string() }),
|
||||||
|
);
|
||||||
|
|
||||||
export const userSchemas = {
|
export const userSchemas = {
|
||||||
signIn: signInSchema,
|
signIn: signInSchema,
|
||||||
|
registration: registrationSchema,
|
||||||
|
registrationApi: registrationSchemaApi,
|
||||||
init: initUserSchema,
|
init: initUserSchema,
|
||||||
create: createUserSchema,
|
create: createUserSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
editProfile: editProfileSchema,
|
editProfile: editProfileSchema,
|
||||||
changePassword: changePasswordSchema,
|
changePassword: changePasswordSchema,
|
||||||
|
changePasswordApi: changePasswordApiSchema,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,21 +45,21 @@
|
|||||||
"@homarr/translation": "workspace:^0.1.0",
|
"@homarr/translation": "workspace:^0.1.0",
|
||||||
"@homarr/ui": "workspace:^0.1.0",
|
"@homarr/ui": "workspace:^0.1.0",
|
||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@tiptap/extension-link": "^2.3.2",
|
"@tiptap/extension-link": "^2.4.0",
|
||||||
"@tiptap/react": "^2.3.2",
|
"@tiptap/react": "^2.4.0",
|
||||||
"@tiptap/starter-kit": "^2.3.2",
|
"@tiptap/starter-kit": "^2.4.0",
|
||||||
"@tiptap/extension-color": "2.3.2",
|
"@tiptap/extension-color": "2.4.0",
|
||||||
"@tiptap/extension-highlight": "2.3.2",
|
"@tiptap/extension-highlight": "2.4.0",
|
||||||
"@tiptap/extension-image": "2.3.2",
|
"@tiptap/extension-image": "2.4.0",
|
||||||
"@tiptap/extension-table": "2.3.2",
|
"@tiptap/extension-table": "2.4.0",
|
||||||
"@tiptap/extension-table-cell": "2.3.2",
|
"@tiptap/extension-table-cell": "2.4.0",
|
||||||
"@tiptap/extension-table-header": "2.3.2",
|
"@tiptap/extension-table-header": "2.4.0",
|
||||||
"@tiptap/extension-table-row": "2.3.2",
|
"@tiptap/extension-table-row": "2.4.0",
|
||||||
"@tiptap/extension-task-item": "2.3.2",
|
"@tiptap/extension-task-item": "2.4.0",
|
||||||
"@tiptap/extension-task-list": "2.3.2",
|
"@tiptap/extension-task-list": "2.4.0",
|
||||||
"@tiptap/extension-text-align": "2.3.2",
|
"@tiptap/extension-text-align": "2.4.0",
|
||||||
"@tiptap/extension-text-style": "2.3.2",
|
"@tiptap/extension-text-style": "2.4.0",
|
||||||
"@tiptap/extension-underline": "2.3.2",
|
"@tiptap/extension-underline": "2.4.0",
|
||||||
"video.js": "^8.12.0"
|
"video.js": "^8.12.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export default function AppWidget({
|
|||||||
return (
|
return (
|
||||||
<Tooltip.Floating label={t("error.notFound.tooltip")}>
|
<Tooltip.Floating label={t("error.notFound.tooltip")}>
|
||||||
<Stack gap="xs" align="center" justify="center" h="100%" w="100%">
|
<Stack gap="xs" align="center" justify="center" h="100%" w="100%">
|
||||||
<IconDeviceDesktopX size={width >= 96 ? "2rem" : "1.5rem"} />
|
<IconDeviceDesktopX size={width >= 96 ? "2rem" : "1rem"} />
|
||||||
{width >= 96 && (
|
{width >= 96 && (
|
||||||
<Text ta="center" size="sm">
|
<Text ta="center" size="sm">
|
||||||
{t("error.notFound.label")}
|
{t("error.notFound.label")}
|
||||||
|
|||||||
810
pnpm-lock.yaml
generated
810
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@
|
|||||||
node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
|
node ./db/migrations/$DB_DIALECT/migrate.cjs ./db/migrations/$DB_DIALECT
|
||||||
|
|
||||||
# Start Redis
|
# Start Redis
|
||||||
redis-server &
|
redis-server /app/redis.conf &
|
||||||
|
|
||||||
# Run the tasks backend
|
# Run the tasks backend
|
||||||
node apps/tasks/tasks.cjs &
|
node apps/tasks/tasks.cjs &
|
||||||
@@ -10,4 +10,6 @@ node apps/tasks/tasks.cjs &
|
|||||||
node apps/websocket/wssServer.cjs &
|
node apps/websocket/wssServer.cjs &
|
||||||
|
|
||||||
# Run the nextjs server
|
# Run the nextjs server
|
||||||
node apps/nextjs/server.js
|
node apps/nextjs/server.js & PID=$!
|
||||||
|
|
||||||
|
wait $PID
|
||||||
|
|||||||
@@ -16,8 +16,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "^14.2.3",
|
"@next/eslint-plugin-next": "^14.2.3",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.8.0",
|
"@typescript-eslint/eslint-plugin": "^7.9.0",
|
||||||
"@typescript-eslint/parser": "^7.8.0",
|
"@typescript-eslint/parser": "^7.9.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-config-turbo": "^1.13.3",
|
"eslint-config-turbo": "^1.13.3",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user