feat: add user groups (#376)

* feat: add user groups

* wip: add unit tests

* wip: add more tests and normalized name for creation and update

* test: add unit tests for group router

* fix: type issues, missing mysql schema, rename column creator_id to owner_id

* fix: lint and format issues

* fix: deepsource issues

* fix: forgot to add log message

* fix: build not working

* chore: address pull request feedback

* feat: add mysql migration and fix merge conflicts

* fix: format issue and test issue
This commit is contained in:
Meier Lukas
2024-04-29 21:46:30 +02:00
committed by GitHub
parent 621f6c81ae
commit 036925bf78
50 changed files with 3333 additions and 132 deletions

View File

@@ -1,11 +1,12 @@
"use client";
import { useCallback } from "react";
import { useCallback, useState } from "react";
import type { SelectProps } from "@mantine/core";
import {
Button,
Flex,
Group,
Loader,
Select,
Stack,
Table,
@@ -241,7 +242,8 @@ interface FormType {
interface InnerProps {
presentUserIds: string[];
onSelect: (props: { id: string; name: string }) => void;
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
confirmLabel?: string;
}
interface UserSelectFormType {
@@ -251,40 +253,45 @@ interface UserSelectFormType {
export const UserSelectModal = createModal<InnerProps>(
({ actions, innerProps }) => {
const t = useI18n();
const { data: users } = clientApi.user.selectable.useQuery();
const { data: users, isPending } = clientApi.user.selectable.useQuery();
const [loading, setLoading] = useState(false);
const form = useForm<UserSelectFormType>();
const handleSubmit = (values: UserSelectFormType) => {
const handleSubmit = async (values: UserSelectFormType) => {
const currentUser = users?.find((user) => user.id === values.userId);
if (!currentUser) return;
innerProps.onSelect({
setLoading(true);
await innerProps.onSelect({
id: currentUser.id,
name: currentUser.name ?? "",
});
setLoading(false);
actions.closeModal();
};
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
<Stack>
<Select
{...form.getInputProps("userId")}
label={t(
"board.setting.section.access.permission.userSelect.label",
)}
label={t("user.action.select.label")}
searchable
nothingFoundMessage={t(
"board.setting.section.access.permission.userSelect.notFound",
)}
leftSection={isPending ? <Loader size="xs" /> : undefined}
nothingFoundMessage={t("user.action.select.notFound")}
limit={5}
data={users
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
/>
<Group justify="end">
<Button onClick={actions.closeModal}>
<Button variant="default" onClick={actions.closeModal}>
{t("common.action.cancel")}
</Button>
<Button type="submit">{t("common.action.add")}</Button>
<Button type="submit" loading={loading}>
{confirmLabel}
</Button>
</Group>
</Stack>
</form>

View File

@@ -15,6 +15,7 @@ import {
IconTool,
IconUser,
IconUsers,
IconUsersGroup,
} from "@tabler/icons-react";
import { getScopedI18n } from "@homarr/translation/server";
@@ -51,6 +52,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
icon: IconMailForward,
href: "/manage/users/invites",
},
{
label: t("items.users.items.groups"),
icon: IconUsersGroup,
href: "/manage/users/groups",
},
],
},
{

View File

@@ -0,0 +1,80 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
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 { revalidatePathAction } from "~/app/revalidatePathAction";
interface DeleteGroupProps {
group: {
id: string;
name: string;
};
}
export const DeleteGroup = ({ group }: DeleteGroupProps) => {
const router = useRouter();
const { mutateAsync } = clientApi.group.deleteGroup.useMutation();
const { openConfirmModal } = useConfirmModal();
const tDelete = useScopedI18n("group.action.delete");
const tRoot = useI18n();
const handleDeletion = useCallback(() => {
openConfirmModal({
title: tDelete("label"),
children: tDelete("confirm", {
name: group.name,
}),
async onConfirm() {
await mutateAsync(
{
id: group.id,
},
{
onSuccess() {
void revalidatePathAction("/manage/users/groups");
router.push("/manage/users/groups");
showSuccessNotification({
title: tRoot("common.notification.delete.success"),
message: tDelete("notification.success.message", {
name: group.name,
}),
});
},
onError() {
showErrorNotification({
title: tRoot("common.notification.delete.error"),
message: tDelete("notification.error.message", {
name: group.name,
}),
});
},
},
);
},
});
}, [
tDelete,
tRoot,
openConfirmModal,
group.id,
group.name,
mutateAsync,
router,
]);
return (
<Button variant="subtle" color="red" onClick={handleDeletion}>
{tDelete("label")}
</Button>
);
};

View File

@@ -0,0 +1,26 @@
"use client";
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { NavLink } from "@mantine/core";
interface NavigationLinkProps {
href: string;
label: string;
icon: ReactNode;
}
export const NavigationLink = ({ href, icon, label }: NavigationLinkProps) => {
const pathName = usePathname();
return (
<NavLink
component={Link}
href={href}
active={pathName === href}
label={label}
leftSection={icon}
/>
);
};

View File

@@ -0,0 +1,83 @@
"use client";
import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface RenameGroupFormProps {
group: {
id: string;
name: string;
};
}
export const RenameGroupForm = ({ group }: RenameGroupFormProps) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.updateGroup.useMutation();
const form = useForm<FormType>({
initialValues: {
name: group.name,
},
});
const handleSubmit = useCallback(
(values: FormType) => {
mutate(
{
...values,
id: group.id,
},
{
onSuccess() {
void revalidatePathAction(`/users/groups/${group.id}`);
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("group.action.update.notification.success.message", {
name: values.name,
}),
});
},
onError() {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("group.action.update.notification.error.message", {
name: values.name,
}),
});
},
},
);
},
[group.id, mutate, t],
);
return (
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack>
<TextInput
label={t("group.field.name")}
{...form.getInputProps("name")}
/>
<Group justify="end">
<Button type="submit" color="teal" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
);
};
interface FormType {
name: string;
}

View File

@@ -0,0 +1,95 @@
"use client";
import { useCallback, useState } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access";
interface TransferGroupOwnershipProps {
group: {
id: string;
name: string;
ownerId: string | null;
};
}
export const TransferGroupOwnership = ({
group,
}: TransferGroupOwnershipProps) => {
const tTransfer = useScopedI18n("group.action.transfer");
const tRoot = useI18n();
const [innerOwnerId, setInnerOwnerId] = useState(group.ownerId);
const { openModal } = useModalAction(UserSelectModal);
const { openConfirmModal } = useConfirmModal();
const { mutateAsync } = clientApi.group.transferOwnership.useMutation();
const handleTransfer = useCallback(() => {
openModal(
{
confirmLabel: tRoot("common.action.continue"),
presentUserIds: innerOwnerId ? [innerOwnerId] : [],
onSelect: ({ id, name }) => {
openConfirmModal({
title: tTransfer("label"),
children: tTransfer("confirm", {
name: group.name,
username: name,
}),
onConfirm: async () => {
await mutateAsync(
{
groupId: group.id,
userId: id,
},
{
onSuccess() {
setInnerOwnerId(id);
showSuccessNotification({
title: tRoot("common.notification.transfer.success"),
message: tTransfer("notification.success.message", {
group: group.name,
user: name,
}),
});
},
onError() {
showErrorNotification({
title: tRoot("common.notification.transfer.error"),
message: tTransfer("notification.error.message"),
});
},
},
);
},
});
},
},
{
title: tTransfer("label"),
},
);
}, [
group.id,
group.name,
innerOwnerId,
mutateAsync,
openConfirmModal,
openModal,
tRoot,
tTransfer,
]);
return (
<Button variant="subtle" color="red" onClick={handleTransfer}>
{tTransfer("label")}
</Button>
);
};

View File

@@ -0,0 +1,76 @@
import type { PropsWithChildren } from "react";
import Link from "next/link";
import {
Button,
Container,
Grid,
GridCol,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { IconLock, IconSettings, IconUsersGroup } from "@tabler/icons-react";
import { api } from "@homarr/api/server";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { NavigationLink } from "./_navigation";
interface LayoutProps {
params: { id: string };
}
export default async function Layout({
children,
params,
}: PropsWithChildren<LayoutProps>) {
const t = await getI18n();
const tGroup = await getScopedI18n("management.page.group");
const group = await api.group.getById({ id: params.id });
return (
<Container size="xl">
<Grid>
<GridCol span={12}>
<Group justify="space-between" align="center">
<Stack gap={0}>
<Title order={3}>{group.name}</Title>
<Text c="gray.5">{t("group.name")}</Text>
</Stack>
<Button
component={Link}
href="/manage/users/groups"
color="gray"
variant="light"
>
{tGroup("back")}
</Button>
</Group>
</GridCol>
<GridCol span={{ xs: 12, md: 4, lg: 3, xl: 2 }}>
<Stack>
<Stack gap={0}>
<NavigationLink
href={`/manage/users/groups/${params.id}`}
label={tGroup("setting.general.title")}
icon={<IconSettings size="1rem" stroke={1.5} />}
/>
<NavigationLink
href={`/manage/users/groups/${params.id}/members`}
label={tGroup("setting.members.title")}
icon={<IconUsersGroup size="1rem" stroke={1.5} />}
/>
<NavigationLink
href={`/manage/users/groups/${params.id}/permissions`}
label={tGroup("setting.permissions.title")}
icon={<IconLock size="1rem" stroke={1.5} />}
/>
</Stack>
</Stack>
</GridCol>
<GridCol span={{ xs: 12, md: 8, lg: 9, xl: 10 }}>{children}</GridCol>
</Grid>
</Container>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
import { useCallback } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { UserSelectModal } from "~/app/[locale]/boards/[name]/settings/_access";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface AddGroupMemberProps {
groupId: string;
presentUserIds: string[];
}
export const AddGroupMember = ({
groupId,
presentUserIds,
}: AddGroupMemberProps) => {
const tMembersAdd = useScopedI18n("group.action.addMember");
const { mutateAsync } = clientApi.group.addMember.useMutation();
const { openModal } = useModalAction(UserSelectModal);
const handleAddMember = useCallback(() => {
openModal(
{
async onSelect({ id }) {
await mutateAsync({
userId: id,
groupId,
});
await revalidatePathAction(
`/manage/users/groups/${groupId}}/members`,
);
},
presentUserIds,
},
{
title: tMembersAdd("label"),
},
);
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]);
return (
<Button color="teal" onClick={handleAddMember}>
{tMembersAdd("label")}
</Button>
);
};

View File

@@ -0,0 +1,59 @@
"use client";
import { useCallback } from "react";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useConfirmModal } from "@homarr/modals";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
interface RemoveGroupMemberProps {
groupId: string;
user: { id: string; name: string | null };
}
export const RemoveGroupMember = ({
groupId,
user,
}: RemoveGroupMemberProps) => {
const t = useI18n();
const tRemoveMember = useScopedI18n("group.action.removeMember");
const { mutateAsync } = clientApi.group.removeMember.useMutation();
const { openConfirmModal } = useConfirmModal();
const handleRemove = useCallback(() => {
openConfirmModal({
title: tRemoveMember("label"),
children: tRemoveMember("confirm", {
user: user.name ?? "",
}),
onConfirm: async () => {
await mutateAsync({
groupId,
userId: user.id,
});
await revalidatePathAction(`/manage/users/groups/${groupId}/members`);
},
});
}, [
openConfirmModal,
mutateAsync,
groupId,
user.id,
user.name,
tRemoveMember,
]);
return (
<Button
variant="subtle"
color="red.9"
size="compact-sm"
onClick={handleRemove}
>
{t("common.action.remove")}
</Button>
);
};

View File

@@ -0,0 +1,99 @@
import Link from "next/link";
import {
Anchor,
Center,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTr,
Text,
Title,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { SearchInput, UserAvatar } from "@homarr/ui";
import { AddGroupMember } from "./_add-group-member";
import { RemoveGroupMember } from "./_remove-group-member";
interface GroupsDetailPageProps {
params: {
id: string;
};
searchParams: {
search: string | undefined;
};
}
export default async function GroupsDetailPage({
params,
searchParams,
}: GroupsDetailPageProps) {
const tMembers = await getScopedI18n("management.page.group.setting.members");
const group = await api.group.getById({ id: params.id });
const filteredMembers = searchParams.search
? group.members.filter((member) =>
member.name
?.toLowerCase()
.includes(searchParams.search!.trim().toLowerCase()),
)
: group.members;
return (
<Stack>
<Title>{tMembers("title")}</Title>
<Group justify="space-between">
<SearchInput
placeholder={`${tMembers("search")}...`}
defaultValue={searchParams.search}
/>
<AddGroupMember
groupId={group.id}
presentUserIds={group.members.map((member) => member.id)}
/>
</Group>
{filteredMembers.length === 0 && (
<Center py="sm">
<Text fw={500} c="gray.6">
{tMembers("notFound")}
</Text>
</Center>
)}
<Table striped highlightOnHover>
<TableTbody>
{filteredMembers.map((member) => (
<Row key={group.id} member={member} groupId={group.id} />
))}
</TableTbody>
</Table>
</Stack>
);
}
interface RowProps {
member: RouterOutputs["group"]["getPaginated"]["items"][number]["members"][number];
groupId: string;
}
const Row = ({ member, groupId }: RowProps) => {
return (
<TableTr>
<TableTd>
<Group>
<UserAvatar size="sm" user={member} />
<Anchor component={Link} href={`/manage/users/${member.id}`}>
{member.name}
</Anchor>
</Group>
</TableTd>
<TableTd w={100}>
<RemoveGroupMember user={member} groupId={groupId} />
</TableTd>
</TableTr>
);
};

View File

@@ -0,0 +1,75 @@
import {
Card,
CardSection,
Divider,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { api } from "@homarr/api/server";
import { getScopedI18n } from "@homarr/translation/server";
import { DeleteGroup } from "./_delete-group";
import { RenameGroupForm } from "./_rename-group-form";
import { TransferGroupOwnership } from "./_transfer-group-ownership";
interface GroupsDetailPageProps {
params: {
id: string;
};
}
export default async function GroupsDetailPage({
params,
}: GroupsDetailPageProps) {
const group = await api.group.getById({ id: params.id });
const tGeneral = await getScopedI18n("management.page.group.setting.general");
const tGroupAction = await getScopedI18n("group.action");
return (
<Stack>
<Title>{tGeneral("title")}</Title>
<RenameGroupForm group={group} />
<Stack gap="sm">
<Title c="red.8" order={2}>
{tGeneral("dangerZone")}
</Title>
<Card withBorder style={{ borderColor: "var(--mantine-color-red-8)" }}>
<Stack gap="sm">
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{tGroupAction("transfer.label")}
</Text>
<Text size="sm">{tGroupAction("transfer.description")}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<TransferGroupOwnership group={group} />
</Group>
</Group>
<CardSection>
<Divider />
</CardSection>
<Group justify="space-between" px="md">
<Stack gap={0}>
<Text fw="bold" size="sm">
{tGroupAction("delete.label")}
</Text>
<Text size="sm">{tGroupAction("delete.description")}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<DeleteGroup group={group} />
</Group>
</Group>
</Stack>
</Card>
</Stack>
</Stack>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { useCallback } from "react";
import type { PropsWithChildren } from "react";
import { Button, Card, Group, Switch, Text, Transition } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { objectEntries } from "@homarr/common";
import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissionKeys } from "@homarr/definitions";
import { createFormContext } from "@homarr/form";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
const [FormProvider, useFormContext, useForm] = createFormContext<FormType>();
interface PermissionFormProps {
initialPermissions: GroupPermissionKey[];
}
export const PermissionForm = ({
children,
initialPermissions,
}: PropsWithChildren<PermissionFormProps>) => {
const form = useForm({
initialValues: groupPermissionKeys.reduce((acc, key) => {
acc[key] = initialPermissions.includes(key);
return acc;
}, {} as FormType),
onValuesChange(values) {
const currentKeys = objectEntries(values)
.filter(([_key, value]) => Boolean(value))
.map(([key]) => key);
if (
currentKeys.every((key) => initialPermissions.includes(key)) &&
initialPermissions.every((key) => currentKeys.includes(key))
) {
form.resetDirty(); // Reset dirty state if all keys are the same as initial
}
},
});
return (
<form>
<FormProvider form={form}>{children}</FormProvider>
</form>
);
};
type FormType = {
[key in GroupPermissionKey]: boolean;
};
export const PermissionSwitch = ({ name }: { name: GroupPermissionKey }) => {
const form = useFormContext();
const props = form.getInputProps(name, {
withError: false,
type: "checkbox",
});
return <Switch {...props} />;
};
interface SaveAffixProps {
groupId: string;
}
export const SaveAffix = ({ groupId }: SaveAffixProps) => {
const t = useI18n();
const tForm = useScopedI18n("management.page.group.setting.permissions.form");
const tNotification = useScopedI18n(
"group.action.changePermissions.notification",
);
const form = useFormContext();
const { mutate, isPending } = clientApi.group.savePermissions.useMutation();
const handleSubmit = useCallback(() => {
const values = form.getValues();
mutate(
{
permissions: objectEntries(values)
.filter(([_, value]) => value)
.map(([key]) => key),
groupId,
},
{
onSuccess: () => {
// Set new initial values for discard and reset dirty state
form.setInitialValues(values);
showSuccessNotification({
title: tNotification("success.title"),
message: tNotification("success.message"),
});
},
onError() {
showErrorNotification({
title: tNotification("error.title"),
message: tNotification("error.message"),
});
},
},
);
}, [form, groupId, mutate, tNotification]);
return (
<div style={{ position: "sticky", bottom: 20 }}>
<Transition transition="slide-up" mounted={form.isDirty()}>
{(transitionStyles) => (
<Card style={transitionStyles} withBorder>
<Group justify="space-between">
<Text fw={500}>{tForm("unsavedChanges")}</Text>
<Group>
<Button disabled={isPending} onClick={form.reset}>
{t("common.action.discard")}
</Button>
<Button color="teal" loading={isPending} onClick={handleSubmit}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Group>
</Card>
)}
</Transition>
</div>
);
};

View File

@@ -0,0 +1,129 @@
import React from "react";
import {
Card,
CardSection,
Divider,
Group,
Stack,
Text,
Title,
} from "@mantine/core";
import { api } from "@homarr/api/server";
import { objectKeys } from "@homarr/common";
import type { GroupPermissionKey } from "@homarr/definitions";
import { groupPermissions } from "@homarr/definitions";
import { getI18n, getScopedI18n } from "@homarr/translation/server";
import {
PermissionForm,
PermissionSwitch,
SaveAffix,
} from "./_group-permission-form";
interface GroupPermissionsPageProps {
params: {
id: string;
};
}
export default async function GroupPermissionsPage({
params,
}: GroupPermissionsPageProps) {
const group = await api.group.getById({ id: params.id });
const tPermissions = await getScopedI18n("group.permission");
const t = await getI18n();
return (
<Stack>
<Title>{t("management.page.group.setting.permissions.title")}</Title>
<PermissionForm initialPermissions={group.permissions}>
<Stack pos="relative">
{objectKeys(groupPermissions).map((group) => {
const isDanger = group === "admin";
return (
<Stack key={group} gap="sm">
<Title order={2} c={isDanger ? "red.8" : undefined}>
{tPermissions(`${group}.title`)}
</Title>
<PermissionCard isDanger={isDanger} group={group} />
</Stack>
);
})}
<SaveAffix groupId={group.id} />
</Stack>
</PermissionForm>
</Stack>
);
}
interface PermissionCardProps {
group: keyof typeof groupPermissions;
isDanger: boolean;
}
const PermissionCard = async ({ group, isDanger }: PermissionCardProps) => {
const t = await getScopedI18n(`group.permission.${group}.item`);
const item = groupPermissions[group];
const permissions = typeof item !== "boolean" ? item : ([group] as "admin"[]);
return (
<Card
p="md"
withBorder
style={{
borderColor: isDanger ? "var(--mantine-color-red-8)" : undefined,
}}
>
<Stack gap="sm">
{permissions.map((permission, index) => (
<React.Fragment key={permission}>
<PermissionRow
name={createGroupPermissionKey(group, permission)}
label={t(`${permission}.label`)}
description={t(`${permission}.description`)}
/>
{index < permissions.length - 1 && (
<CardSection>
<Divider />
</CardSection>
)}
</React.Fragment>
))}
</Stack>
</Card>
);
};
const createGroupPermissionKey = (
group: keyof typeof groupPermissions,
permission: string,
): GroupPermissionKey => {
if (typeof groupPermissions[group] === "boolean") {
return group as GroupPermissionKey;
}
return `${group}-${permission}` as GroupPermissionKey;
};
interface PermissionRowProps {
name: GroupPermissionKey;
label: string;
description: string;
}
const PermissionRow = ({ name, label, description }: PermissionRowProps) => {
return (
<Group justify="space-between" align="center">
<Stack gap={0}>
<Text fw={500}>{label}</Text>
<Text c="gray.5">{description}</Text>
</Stack>
<PermissionSwitch name={name} />
</Group>
);
};

View File

@@ -0,0 +1,81 @@
"use client";
import { useCallback } from "react";
import { Button, Group, Stack, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { createModal, useModalAction } from "@homarr/modals";
import {
showErrorNotification,
showSuccessNotification,
} from "@homarr/notifications";
import { useI18n } from "@homarr/translation/client";
import { revalidatePathAction } from "~/app/revalidatePathAction";
export const AddGroup = () => {
const t = useI18n();
const { openModal } = useModalAction(AddGroupModal);
const handleAddGroup = useCallback(() => {
openModal();
}, [openModal]);
return (
<Button onClick={handleAddGroup} color="teal">
{t("group.action.create.label")}
</Button>
);
};
const AddGroupModal = createModal<void>(({ actions }) => {
const t = useI18n();
const { mutate, isPending } = clientApi.group.createGroup.useMutation();
const form = useForm({
initialValues: {
name: "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
mutate(values, {
onSuccess() {
actions.closeModal();
void revalidatePathAction("/manage/users/groups");
showSuccessNotification({
title: t("common.notification.create.success"),
message: t("group.action.create.notification.success.message"),
});
},
onError() {
showErrorNotification({
title: t("common.notification.create.error"),
message: t("group.action.create.notification.error.message"),
});
},
});
})}
>
<Stack>
<TextInput
label={t("group.field.name")}
data-autofocus
{...form.getInputProps("name")}
/>
<Group justify="right">
<Button onClick={actions.closeModal} variant="subtle" color="gray">
{t("common.action.cancel")}
</Button>
<Button loading={isPending} type="submit" color="teal">
{t("common.action.create")}
</Button>
</Group>
</Stack>
</form>
);
}).withOptions({
defaultTitle: (t) => t("group.action.create.label"),
});

View File

@@ -0,0 +1,102 @@
import Link from "next/link";
import {
Anchor,
Container,
Group,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Title,
} from "@mantine/core";
import type { RouterOutputs } from "@homarr/api";
import { api } from "@homarr/api/server";
import { getI18n } from "@homarr/translation/server";
import { SearchInput, TablePagination, UserAvatarGroup } from "@homarr/ui";
import { z } from "@homarr/validation";
import { AddGroup } from "./_add-group";
const searchParamsSchema = z.object({
search: z.string().optional(),
pageSize: z.string().regex(/\d+/).transform(Number).catch(10),
page: z.string().regex(/\d+/).transform(Number).catch(1),
});
type SearchParamsSchemaInputFromSchema<
TSchema extends Record<string, unknown>,
> = Partial<{
[K in keyof TSchema]: Exclude<TSchema[K], undefined> extends unknown[]
? string[]
: string;
}>;
interface GroupsListPageProps {
searchParams: SearchParamsSchemaInputFromSchema<
z.infer<typeof searchParamsSchema>
>;
}
export default async function GroupsListPage(props: GroupsListPageProps) {
const t = await getI18n();
const searchParams = searchParamsSchema.parse(props.searchParams);
const { items: groups, totalCount } =
await api.group.getPaginated(searchParams);
return (
<Container size="xl">
<Stack>
<Title>{t("group.title")}</Title>
<Group justify="space-between">
<SearchInput
placeholder={`${t("group.search")}...`}
defaultValue={searchParams.search}
/>
<AddGroup />
</Group>
<Table striped highlightOnHover>
<TableThead>
<TableTr>
<TableTh>{t("group.field.name")}</TableTh>
<TableTh>{t("group.field.members")}</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{groups.map((group) => (
<Row key={group.id} group={group} />
))}
</TableTbody>
</Table>
<Group justify="end">
<TablePagination
total={Math.ceil(totalCount / searchParams.pageSize)}
/>
</Group>
</Stack>
</Container>
);
}
interface RowProps {
group: RouterOutputs["group"]["getPaginated"]["items"][number];
}
const Row = ({ group }: RowProps) => {
return (
<TableTr>
<TableTd>
<Anchor component={Link} href={`/manage/users/groups/${group.id}`}>
{group.name}
</Anchor>
</TableTd>
<TableTd>
<UserAvatarGroup users={group.members} size="sm" limit={5} />
</TableTd>
</TableTr>
);
};

View File

@@ -12,7 +12,8 @@
"clean:workspaces": "turbo clean",
"db:push": "pnpm -F db push",
"db:studio": "pnpm -F db studio",
"db:migration:generate": "pnpm -F db migration:generate",
"db:migration:sqlite:generate": "pnpm -F db migration:sqlite:generate",
"db:migration:mysql:generate": "pnpm -F db migration:mysql:generate",
"db:migration:run": "pnpm -F db migration:run",
"dev": "turbo dev --parallel",
"docker:dev": "docker compose -f ./development.docker-compose.yml up",

View File

@@ -1,5 +1,6 @@
import { appRouter as innerAppRouter } from "./router/app";
import { boardRouter } from "./router/board";
import { groupRouter } from "./router/group";
import { integrationRouter } from "./router/integration";
import { inviteRouter } from "./router/invite";
import { locationRouter } from "./router/location";
@@ -10,6 +11,7 @@ import { createTRPCRouter } from "./trpc";
export const appRouter = createTRPCRouter({
user: userRouter,
group: groupRouter,
invite: inviteRouter,
integration: integrationRouter,
board: boardRouter,

View File

@@ -0,0 +1,232 @@
import { TRPCError } from "@trpc/server";
import type { Database } from "@homarr/db";
import { and, createId, eq, like, not, sql } from "@homarr/db";
import {
groupMembers,
groupPermissions,
groups,
} from "@homarr/db/schema/sqlite";
import { validation } from "@homarr/validation";
import { createTRPCRouter, protectedProcedure } from "../trpc";
export const groupRouter = createTRPCRouter({
getPaginated: protectedProcedure
.input(validation.group.paginated)
.query(async ({ input, ctx }) => {
const whereQuery = input.search
? like(groups.name, `%${input.search.trim()}%`)
: undefined;
const groupCount = await ctx.db
.select({
count: sql<number>`count(*)`,
})
.from(groups)
.where(whereQuery);
const dbGroups = await ctx.db.query.groups.findMany({
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
},
limit: input.pageSize,
offset: (input.page - 1) * input.pageSize,
where: whereQuery,
});
return {
items: dbGroups.map((group) => ({
...group,
members: group.members.map((member) => member.user),
})),
totalCount: groupCount[0]!.count,
};
}),
getById: protectedProcedure
.input(validation.group.byId)
.query(async ({ input, ctx }) => {
const group = await ctx.db.query.groups.findFirst({
where: eq(groups.id, input.id),
with: {
members: {
with: {
user: {
columns: {
id: true,
name: true,
email: true,
image: true,
},
},
},
},
permissions: {
columns: {
permission: true,
},
},
},
});
if (!group) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Group not found",
});
}
return {
...group,
members: group.members.map((member) => member.user),
permissions: group.permissions.map(
(permission) => permission.permission,
),
};
}),
createGroup: protectedProcedure
.input(validation.group.create)
.mutation(async ({ input, ctx }) => {
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrow(ctx.db, normalizedName);
const id = createId();
await ctx.db.insert(groups).values({
id,
name: normalizedName,
ownerId: ctx.session.user.id,
});
return id;
}),
updateGroup: protectedProcedure
.input(validation.group.update)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
const normalizedName = normalizeName(input.name);
await checkSimilarNameAndThrow(ctx.db, normalizedName, input.id);
await ctx.db
.update(groups)
.set({
name: normalizedName,
})
.where(eq(groups.id, input.id));
}),
savePermissions: protectedProcedure
.input(validation.group.savePermissions)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.delete(groupPermissions)
.where(eq(groupPermissions.groupId, input.groupId));
await ctx.db.insert(groupPermissions).values(
input.permissions.map((permission) => ({
groupId: input.groupId,
permission,
})),
);
}),
transferOwnership: protectedProcedure
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.update(groups)
.set({
ownerId: input.userId,
})
.where(eq(groups.id, input.groupId));
}),
deleteGroup: protectedProcedure
.input(validation.group.byId)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.id);
await ctx.db.delete(groups).where(eq(groups.id, input.id));
}),
addMember: protectedProcedure
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
const user = await ctx.db.query.users.findFirst({
where: eq(groups.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
await ctx.db.insert(groupMembers).values({
groupId: input.groupId,
userId: input.userId,
});
}),
removeMember: protectedProcedure
.input(validation.group.groupUser)
.mutation(async ({ input, ctx }) => {
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
await ctx.db
.delete(groupMembers)
.where(
and(
eq(groupMembers.groupId, input.groupId),
eq(groupMembers.userId, input.userId),
),
);
}),
});
const normalizeName = (name: string) => name.trim();
const checkSimilarNameAndThrow = async (
db: Database,
name: string,
ignoreId?: string,
) => {
const similar = await db.query.groups.findFirst({
where: and(
like(groups.name, `${name}`),
not(eq(groups.id, ignoreId ?? "")),
),
});
if (similar) {
throw new TRPCError({
code: "CONFLICT",
message: "Found group with similar name",
});
}
};
const throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
const group = await db.query.groups.findFirst({
where: eq(groups.id, id),
});
if (!group) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Group not found",
});
}
};

View File

@@ -0,0 +1,664 @@
import { describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId, eq } from "@homarr/db";
import {
groupMembers,
groupPermissions,
groups,
users,
} from "@homarr/db/schema/sqlite";
import { createDb } from "@homarr/db/test";
import { groupRouter } from "../group";
const defaultOwnerId = createId();
const defaultSession = {
user: {
id: defaultOwnerId,
},
expires: new Date().toISOString(),
} satisfies Session;
// Mock the auth module to return an empty session
vi.mock("@homarr/auth", async () => {
const mod = await import("@homarr/auth/security");
return { ...mod, auth: () => ({}) as Session };
});
describe("paginated should return a list of groups with pagination", () => {
test.each([
[1, 3],
[2, 2],
])(
"with 5 groups in database and pageSize set to 3 on page %s it should return %s groups",
async (page, expectedCount) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
id: number.toString(),
name: `Group ${number}`,
})),
);
// Act
const result = await caller.getPaginated({
page,
pageSize: 3,
});
// Assert
expect(result.items.length).toBe(expectedCount);
},
);
test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values(
[1, 2, 3, 4, 5].map((number) => ({
id: number.toString(),
name: `Group ${number}`,
})),
);
// Act
const result = await caller.getPaginated({
pageSize: 3,
});
// Assert
expect(result.totalCount).toBe(5);
});
test("groups should contain id, name, email and image of members", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const user = createDummyUser();
await db.insert(users).values(user);
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
});
await db.insert(groupMembers).values({
groupId,
userId: user.id,
});
// Act
const result = await caller.getPaginated({});
// Assert
const item = result.items[0];
expect(item).toBeDefined();
expect(item?.members.length).toBe(1);
const userKeys = Object.keys(item?.members[0] ?? {});
expect(userKeys.length).toBe(4);
expect(
["id", "name", "email", "image"].some((key) => userKeys.includes(key)),
);
});
test.each([
[undefined, 5, "first"],
["d", 2, "second"],
["th", 3, "third"],
["fi", 2, "first"],
])(
"groups should be searchable by name with contains pattern, query %s should result in %s results",
async (query, expectedCount, firstKey) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values(
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
id: index.toString(),
name: key,
})),
);
// Act
const result = await caller.getPaginated({
search: query,
});
// Assert
expect(result.totalCount).toBe(expectedCount);
expect(result.items.at(0)?.name).toBe(firstKey);
},
);
});
describe("byId should return group by id including members and permissions", () => {
test('should return group with id "1" with members and permissions', async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const user = createDummyUser();
const groupId = "1";
await db.insert(users).values(user);
await db.insert(groups).values([
{
id: groupId,
name: "Group",
},
{
id: createId(),
name: "Another group",
},
]);
await db.insert(groupMembers).values({
userId: user.id,
groupId,
});
await db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
// Act
const result = await caller.getById({
id: groupId,
});
// Assert
expect(result.id).toBe(groupId);
expect(result.members.length).toBe(1);
const userKeys = Object.keys(result?.members[0] ?? {});
expect(userKeys.length).toBe(4);
expect(
["id", "name", "email", "image"].some((key) => userKeys.includes(key)),
);
expect(result.permissions.length).toBe(1);
expect(result.permissions[0]).toBe("admin");
});
test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: "2",
name: "Group",
});
// Act
const act = async () => await caller.getById({ id: "1" });
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("create should create group in database", () => {
test("with valid input (64 character name) and non existing name it should be successful", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const name = "a".repeat(64);
await db.insert(users).values(defaultSession.user);
// Act
const result = await caller.createGroup({
name,
});
// Assert
const item = await db.query.groups.findFirst({
where: eq(groups.id, result),
});
expect(item).toBeDefined();
expect(item?.id).toBe(result);
expect(item?.ownerId).toBe(defaultOwnerId);
expect(item?.name).toBe(name);
});
test("with more than 64 characters name it should fail while validation", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const longName = "a".repeat(65);
// Act
const act = async () =>
await caller.createGroup({
name: longName,
});
// Assert
await expect(act()).rejects.toThrow("too_big");
});
test.each([
["test", "Test"],
["test", "Test "],
["test", "test"],
["test", " TeSt"],
])(
"with similar name %s it should fail to create %s",
async (similarName, nameToCreate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: createId(),
name: similarName,
});
// Act
const act = async () => await caller.createGroup({ name: nameToCreate });
// Assert
await expect(act()).rejects.toThrow("similar name");
},
);
});
describe("update should update name with value that is no duplicate", () => {
test.each([
["first", "second ", "second"],
["first", " first", "first"],
])(
"update should update name from %s to %s normalized",
async (initialValue, updateValue, expectedValue) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: initialValue,
},
{
id: createId(),
name: "Third",
},
]);
// Act
await caller.updateGroup({
id: groupId,
name: updateValue,
});
// Assert
const value = await db.query.groups.findFirst({
where: eq(groups.id, groupId),
});
expect(value?.name).toBe(expectedValue);
},
);
test.each([
["Second ", "second"],
[" seCond", "second"],
])(
"with similar name %s it should fail to update %s",
async (updateValue, initialDuplicate) => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: "Something",
},
{
id: createId(),
name: initialDuplicate,
},
]);
// Act
const act = async () =>
await caller.updateGroup({
id: groupId,
name: updateValue,
});
// Assert
await expect(act()).rejects.toThrow("similar name");
},
);
test("with non existing id it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: createId(),
name: "something",
});
// Act
const act = () =>
caller.updateGroup({
id: createId(),
name: "something else",
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("savePermissions should save permissions for group", () => {
test("with existing group and permissions it should save permissions", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
await db.insert(groups).values({
id: groupId,
name: "Group",
});
await db.insert(groupPermissions).values({
groupId,
permission: "admin",
});
// Act
await caller.savePermissions({
groupId,
permissions: ["integration-use-all", "board-full-access"],
});
// Assert
const permissions = await db.query.groupPermissions.findMany({
where: eq(groupPermissions.groupId, groupId),
});
expect(permissions.length).toBe(2);
expect(permissions.map(({ permission }) => permission)).toEqual([
"integration-use-all",
"board-full-access",
]);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: createId(),
name: "Group",
});
// Act
const act = async () =>
await caller.savePermissions({
groupId: createId(),
permissions: ["integration-create", "board-full-access"],
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("transferOwnership should transfer ownership of group", () => {
test("with existing group and user it should transfer ownership", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
const newUserId = createId();
await db.insert(users).values([
{
id: newUserId,
name: "New user",
},
{
id: defaultOwnerId,
name: "Old user",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
});
// Act
await caller.transferOwnership({
groupId,
userId: newUserId,
});
// Assert
const group = await db.query.groups.findFirst({
where: eq(groups.id, groupId),
});
expect(group?.ownerId).toBe(newUserId);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: createId(),
name: "Group",
});
// Act
const act = async () =>
await caller.transferOwnership({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("deleteGroup should delete group", () => {
test("with existing group it should delete group", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
await db.insert(groups).values([
{
id: groupId,
name: "Group",
},
{
id: createId(),
name: "Another group",
},
]);
// Act
await caller.deleteGroup({
id: groupId,
});
// Assert
const dbGroups = await db.query.groups.findMany();
expect(dbGroups.length).toBe(1);
expect(dbGroups[0]?.id).not.toBe(groupId);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(groups).values({
id: createId(),
name: "Group",
});
// Act
const act = async () =>
await caller.deleteGroup({
id: createId(),
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("addMember should add member to group", () => {
test("with existing group and user it should add member", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
});
// Act
await caller.addMember({
groupId,
userId,
});
// Assert
const members = await db.query.groupMembers.findMany({
where: eq(groupMembers.groupId, groupId),
});
expect(members.length).toBe(1);
expect(members[0]?.userId).toBe(userId);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(users).values({
id: createId(),
name: "User",
});
// Act
const act = async () =>
await caller.addMember({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
describe("removeMember should remove member from group", () => {
test("with existing group and user it should remove member", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
const groupId = createId();
const userId = createId();
await db.insert(users).values([
{
id: userId,
name: "User",
},
{
id: defaultOwnerId,
name: "Creator",
},
]);
await db.insert(groups).values({
id: groupId,
name: "Group",
ownerId: defaultOwnerId,
});
await db.insert(groupMembers).values({
groupId,
userId,
});
// Act
await caller.removeMember({
groupId,
userId,
});
// Assert
const members = await db.query.groupMembers.findMany({
where: eq(groupMembers.groupId, groupId),
});
expect(members.length).toBe(0);
});
test("with non existing group it should throw not found error", async () => {
// Arrange
const db = createDb();
const caller = groupRouter.createCaller({ db, session: defaultSession });
await db.insert(users).values({
id: createId(),
name: "User",
});
// Act
const act = async () =>
await caller.removeMember({
groupId: createId(),
userId: createId(),
});
// Assert
await expect(act()).rejects.toThrow("Group not found");
});
});
const createDummyUser = () => ({
id: createId(),
name: "username",
email: "user@gmail.com",
image: "example",
password: "secret",
salt: "secret",
});

View File

@@ -2,7 +2,7 @@ import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
const migrationsFolder = process.argv[2] ?? "./migrations";
const migrationsFolder = process.argv[2] ?? "./migrations/sqlite";
const sqlite = new Database(process.env.DB_URL?.replace("file:", ""));

View File

@@ -1,9 +0,0 @@
CREATE TABLE `invite` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`expiration_date` integer NOT NULL,
`creator_id` text NOT NULL,
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `invite_token_unique` ON `invite` (`token`);

View File

@@ -1,20 +0,0 @@
{
"version": "5",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1710878250235,
"tag": "0000_productive_changeling",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1712777046680,
"tag": "0001_sparkling_zaran",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,168 @@
CREATE TABLE `account` (
`userId` varchar(256) NOT NULL,
`type` text NOT NULL,
`provider` varchar(256) NOT NULL,
`providerAccountId` varchar(256) NOT NULL,
`refresh_token` text,
`access_token` text,
`expires_at` int,
`token_type` text,
`scope` text,
`id_token` text,
`session_state` text,
CONSTRAINT `account_provider_providerAccountId_pk` PRIMARY KEY(`provider`,`providerAccountId`)
);
--> statement-breakpoint
CREATE TABLE `app` (
`id` varchar(256) NOT NULL,
`name` text NOT NULL,
`description` text,
`icon_url` text NOT NULL,
`href` text,
CONSTRAINT `app_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `boardPermission` (
`board_id` text NOT NULL,
`user_id` text NOT NULL,
`permission` text NOT NULL,
CONSTRAINT `boardPermission_board_id_user_id_permission_pk` PRIMARY KEY(`board_id`,`user_id`,`permission`)
);
--> statement-breakpoint
CREATE TABLE `board` (
`id` varchar(256) NOT NULL,
`name` varchar(256) NOT NULL,
`is_public` boolean NOT NULL DEFAULT false,
`creator_id` text,
`page_title` text,
`meta_title` text,
`logo_image_url` text,
`favicon_image_url` text,
`background_image_url` text,
`background_image_attachment` text NOT NULL DEFAULT ('fixed'),
`background_image_repeat` text NOT NULL DEFAULT ('no-repeat'),
`background_image_size` text NOT NULL DEFAULT ('cover'),
`primary_color` text NOT NULL DEFAULT ('#fa5252'),
`secondary_color` text NOT NULL DEFAULT ('#fd7e14'),
`opacity` int NOT NULL DEFAULT 100,
`custom_css` text,
`column_count` int NOT NULL DEFAULT 10,
CONSTRAINT `board_id` PRIMARY KEY(`id`),
CONSTRAINT `board_name_unique` UNIQUE(`name`)
);
--> statement-breakpoint
CREATE TABLE `groupMember` (
`groupId` varchar(256) NOT NULL,
`userId` varchar(256) NOT NULL,
CONSTRAINT `groupMember_groupId_userId_pk` PRIMARY KEY(`groupId`,`userId`)
);
--> statement-breakpoint
CREATE TABLE `groupPermission` (
`groupId` varchar(256) NOT NULL,
`permission` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `group` (
`id` varchar(256) NOT NULL,
`name` varchar(64) NOT NULL,
`owner_id` varchar(256),
CONSTRAINT `group_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `integration_item` (
`item_id` varchar(256) NOT NULL,
`integration_id` varchar(256) NOT NULL,
CONSTRAINT `integration_item_item_id_integration_id_pk` PRIMARY KEY(`item_id`,`integration_id`)
);
--> statement-breakpoint
CREATE TABLE `integrationSecret` (
`kind` varchar(16) NOT NULL,
`value` text NOT NULL,
`updated_at` timestamp NOT NULL,
`integration_id` varchar(256) NOT NULL,
CONSTRAINT `integrationSecret_integration_id_kind_pk` PRIMARY KEY(`integration_id`,`kind`)
);
--> statement-breakpoint
CREATE TABLE `integration` (
`id` varchar(256) NOT NULL,
`name` text NOT NULL,
`url` text NOT NULL,
`kind` varchar(128) NOT NULL,
CONSTRAINT `integration_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `invite` (
`id` varchar(256) NOT NULL,
`token` varchar(512) NOT NULL,
`expiration_date` timestamp NOT NULL,
`creator_id` varchar(256) NOT NULL,
CONSTRAINT `invite_id` PRIMARY KEY(`id`),
CONSTRAINT `invite_token_unique` UNIQUE(`token`)
);
--> statement-breakpoint
CREATE TABLE `item` (
`id` varchar(256) NOT NULL,
`section_id` varchar(256) NOT NULL,
`kind` text NOT NULL,
`x_offset` int NOT NULL,
`y_offset` int NOT NULL,
`width` int NOT NULL,
`height` int NOT NULL,
`options` text NOT NULL DEFAULT ('{"json": {}}'),
CONSTRAINT `item_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `section` (
`id` varchar(256) NOT NULL,
`board_id` varchar(256) NOT NULL,
`kind` text NOT NULL,
`position` int NOT NULL,
`name` text,
CONSTRAINT `section_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `session` (
`sessionToken` varchar(512) NOT NULL,
`userId` varchar(256) NOT NULL,
`expires` timestamp NOT NULL,
CONSTRAINT `session_sessionToken` PRIMARY KEY(`sessionToken`)
);
--> statement-breakpoint
CREATE TABLE `user` (
`id` varchar(256) NOT NULL,
`name` text,
`email` text,
`emailVerified` timestamp,
`image` text,
`password` text,
`salt` text,
CONSTRAINT `user_id` PRIMARY KEY(`id`)
);
--> statement-breakpoint
CREATE TABLE `verificationToken` (
`identifier` varchar(256) NOT NULL,
`token` varchar(512) NOT NULL,
`expires` timestamp NOT NULL,
CONSTRAINT `verificationToken_identifier_token_pk` PRIMARY KEY(`identifier`,`token`)
);
--> statement-breakpoint
CREATE INDEX `userId_idx` ON `account` (`userId`);--> statement-breakpoint
CREATE INDEX `integration_secret__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint
CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint
CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint
CREATE INDEX `user_id_idx` ON `session` (`userId`);--> statement-breakpoint
ALTER TABLE `account` ADD CONSTRAINT `account_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `boardPermission` ADD CONSTRAINT `boardPermission_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `boardPermission` ADD CONSTRAINT `boardPermission_user_id_user_id_fk` FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `board` ADD CONSTRAINT `board_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_groupId_group_id_fk` FOREIGN KEY (`groupId`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupMember` ADD CONSTRAINT `groupMember_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `groupPermission` ADD CONSTRAINT `groupPermission_groupId_group_id_fk` FOREIGN KEY (`groupId`) REFERENCES `group`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `group` ADD CONSTRAINT `group_owner_id_user_id_fk` FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `integration_item` ADD CONSTRAINT `integration_item_item_id_item_id_fk` FOREIGN KEY (`item_id`) REFERENCES `item`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `integration_item` ADD CONSTRAINT `integration_item_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `integrationSecret` ADD CONSTRAINT `integrationSecret_integration_id_integration_id_fk` FOREIGN KEY (`integration_id`) REFERENCES `integration`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `invite` ADD CONSTRAINT `invite_creator_id_user_id_fk` FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `item` ADD CONSTRAINT `item_section_id_section_id_fk` FOREIGN KEY (`section_id`) REFERENCES `section`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `section` ADD CONSTRAINT `section_board_id_board_id_fk` FOREIGN KEY (`board_id`) REFERENCES `board`(`id`) ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE `session` ADD CONSTRAINT `session_userId_user_id_fk` FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON DELETE cascade ON UPDATE no action;

View File

@@ -1,7 +1,7 @@
{
"version": "5",
"dialect": "sqlite",
"id": "7c2291ee-febd-4b90-994c-85e6ef27102d",
"dialect": "mysql",
"id": "d0a05e9e-107f-4bed-ac54-a4a41369f0da",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
@@ -9,7 +9,7 @@
"columns": {
"userId": {
"name": "userId",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -23,14 +23,14 @@
},
"provider": {
"name": "provider",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"providerAccountId": {
"name": "providerAccountId",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -51,7 +51,7 @@
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": false,
"autoincrement": false
@@ -105,8 +105,8 @@
},
"compositePrimaryKeys": {
"account_provider_providerAccountId_pk": {
"columns": ["provider", "providerAccountId"],
"name": "account_provider_providerAccountId_pk"
"name": "account_provider_providerAccountId_pk",
"columns": ["provider", "providerAccountId"]
}
},
"uniqueConstraints": {}
@@ -116,8 +116,8 @@
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
@@ -152,7 +152,12 @@
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"app_id": {
"name": "app_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"boardPermission": {
@@ -203,8 +208,8 @@
},
"compositePrimaryKeys": {
"boardPermission_board_id_user_id_permission_pk": {
"columns": ["board_id", "permission", "user_id"],
"name": "boardPermission_board_id_user_id_permission_pk"
"name": "boardPermission_board_id_user_id_permission_pk",
"columns": ["board_id", "user_id", "permission"]
}
},
"uniqueConstraints": {}
@@ -214,21 +219,21 @@
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"is_public": {
"name": "is_public",
"type": "integer",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
@@ -282,7 +287,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'fixed'"
"default": "('fixed')"
},
"background_image_repeat": {
"name": "background_image_repeat",
@@ -290,7 +295,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'no-repeat'"
"default": "('no-repeat')"
},
"background_image_size": {
"name": "background_image_size",
@@ -298,7 +303,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'cover'"
"default": "('cover')"
},
"primary_color": {
"name": "primary_color",
@@ -306,7 +311,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#fa5252'"
"default": "('#fa5252')"
},
"secondary_color": {
"name": "secondary_color",
@@ -314,11 +319,11 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'#fd7e14'"
"default": "('#fd7e14')"
},
"opacity": {
"name": "opacity",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
@@ -333,20 +338,14 @@
},
"column_count": {
"name": "column_count",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 10
}
},
"indexes": {
"board_name_unique": {
"name": "board_name_unique",
"columns": ["name"],
"isUnique": true
}
},
"indexes": {},
"foreignKeys": {
"board_creator_id_user_id_fk": {
"name": "board_creator_id_user_id_fk",
@@ -358,22 +357,157 @@
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"board_id": {
"name": "board_id",
"columns": ["id"]
}
},
"uniqueConstraints": {
"board_name_unique": {
"name": "board_name_unique",
"columns": ["name"]
}
}
},
"groupMember": {
"name": "groupMember",
"columns": {
"groupId": {
"name": "groupId",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"groupMember_groupId_group_id_fk": {
"name": "groupMember_groupId_group_id_fk",
"tableFrom": "groupMember",
"tableTo": "group",
"columnsFrom": ["groupId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"groupMember_userId_user_id_fk": {
"name": "groupMember_userId_user_id_fk",
"tableFrom": "groupMember",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"groupMember_groupId_userId_pk": {
"name": "groupMember_groupId_userId_pk",
"columns": ["groupId", "userId"]
}
},
"uniqueConstraints": {}
},
"groupPermission": {
"name": "groupPermission",
"columns": {
"groupId": {
"name": "groupId",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"groupPermission_groupId_group_id_fk": {
"name": "groupPermission_groupId_group_id_fk",
"tableFrom": "groupPermission",
"tableTo": "group",
"columnsFrom": ["groupId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"group": {
"name": "group",
"columns": {
"id": {
"name": "id",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "varchar(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "varchar(256)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"group_owner_id_user_id_fk": {
"name": "group_owner_id_user_id_fk",
"tableFrom": "group",
"tableTo": "user",
"columnsFrom": ["owner_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"group_id": {
"name": "group_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"integration_item": {
"name": "integration_item",
"columns": {
"item_id": {
"name": "item_id",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"integration_id": {
"name": "integration_id",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -402,8 +536,8 @@
},
"compositePrimaryKeys": {
"integration_item_item_id_integration_id_pk": {
"columns": ["integration_id", "item_id"],
"name": "integration_item_item_id_integration_id_pk"
"name": "integration_item_item_id_integration_id_pk",
"columns": ["item_id", "integration_id"]
}
},
"uniqueConstraints": {}
@@ -413,7 +547,7 @@
"columns": {
"kind": {
"name": "kind",
"type": "text",
"type": "varchar(16)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -427,14 +561,14 @@
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"integration_id": {
"name": "integration_id",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -465,8 +599,8 @@
},
"compositePrimaryKeys": {
"integrationSecret_integration_id_kind_pk": {
"columns": ["integration_id", "kind"],
"name": "integrationSecret_integration_id_kind_pk"
"name": "integrationSecret_integration_id_kind_pk",
"columns": ["integration_id", "kind"]
}
},
"uniqueConstraints": {}
@@ -476,8 +610,8 @@
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
@@ -497,7 +631,7 @@
},
"kind": {
"name": "kind",
"type": "text",
"type": "varchar(128)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -511,22 +645,84 @@
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"integration_id": {
"name": "integration_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"invite": {
"name": "invite",
"columns": {
"id": {
"name": "id",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "varchar(512)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expiration_date": {
"name": "expiration_date",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"creator_id": {
"name": "creator_id",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"invite_creator_id_user_id_fk": {
"name": "invite_creator_id_user_id_fk",
"tableFrom": "invite",
"tableTo": "user",
"columnsFrom": ["creator_id"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"invite_id": {
"name": "invite_id",
"columns": ["id"]
}
},
"uniqueConstraints": {
"invite_token_unique": {
"name": "invite_token_unique",
"columns": ["token"]
}
}
},
"item": {
"name": "item",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"section_id": {
"name": "section_id",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -540,28 +736,28 @@
},
"x_offset": {
"name": "x_offset",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"y_offset": {
"name": "y_offset",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"width": {
"name": "width",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"height": {
"name": "height",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -572,7 +768,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"json\": {}}'"
"default": "('{\"json\": {}}')"
}
},
"indexes": {},
@@ -587,7 +783,12 @@
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"item_id": {
"name": "item_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"section": {
@@ -595,14 +796,14 @@
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"board_id": {
"name": "board_id",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -616,7 +817,7 @@
},
"position": {
"name": "position",
"type": "integer",
"type": "int",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -641,7 +842,12 @@
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"section_id": {
"name": "section_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"session": {
@@ -649,21 +855,21 @@
"columns": {
"sessionToken": {
"name": "sessionToken",
"type": "text",
"primaryKey": true,
"type": "varchar(512)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -687,7 +893,12 @@
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"session_sessionToken": {
"name": "session_sessionToken",
"columns": ["sessionToken"]
}
},
"uniqueConstraints": {}
},
"user": {
@@ -695,8 +906,8 @@
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
@@ -716,7 +927,7 @@
},
"emailVerified": {
"name": "emailVerified",
"type": "integer",
"type": "timestamp",
"primaryKey": false,
"notNull": false,
"autoincrement": false
@@ -745,7 +956,12 @@
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"compositePrimaryKeys": {
"user_id": {
"name": "user_id",
"columns": ["id"]
}
},
"uniqueConstraints": {}
},
"verificationToken": {
@@ -753,21 +969,21 @@
"columns": {
"identifier": {
"name": "identifier",
"type": "text",
"type": "varchar(256)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text",
"type": "varchar(512)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires": {
"name": "expires",
"type": "integer",
"type": "timestamp",
"primaryKey": false,
"notNull": true,
"autoincrement": false
@@ -777,14 +993,14 @@
"foreignKeys": {},
"compositePrimaryKeys": {
"verificationToken_identifier_token_pk": {
"columns": ["identifier", "token"],
"name": "verificationToken_identifier_token_pk"
"name": "verificationToken_identifier_token_pk",
"columns": ["identifier", "token"]
}
},
"uniqueConstraints": {}
}
},
"enums": {},
"schemas": {},
"_meta": {
"schemas": {},
"tables": {},

View File

@@ -0,0 +1,13 @@
{
"version": "5",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1714414260766,
"tag": "0000_chubby_darkhawk",
"breakpoints": true
}
]
}

View File

@@ -52,6 +52,27 @@ CREATE TABLE `board` (
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `groupMember` (
`groupId` text NOT NULL,
`userId` text NOT NULL,
PRIMARY KEY(`groupId`, `userId`),
FOREIGN KEY (`groupId`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `groupPermission` (
`groupId` text NOT NULL,
`permission` text NOT NULL,
FOREIGN KEY (`groupId`) REFERENCES `group`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `group` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`owner_id` text,
FOREIGN KEY (`owner_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE TABLE `integration_item` (
`item_id` text NOT NULL,
`integration_id` text NOT NULL,
@@ -76,6 +97,14 @@ CREATE TABLE `integration` (
`kind` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `invite` (
`id` text PRIMARY KEY NOT NULL,
`token` text NOT NULL,
`expiration_date` integer NOT NULL,
`creator_id` text NOT NULL,
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `item` (
`id` text PRIMARY KEY NOT NULL,
`section_id` text NOT NULL,
@@ -126,4 +155,5 @@ CREATE UNIQUE INDEX `board_name_unique` ON `board` (`name`);--> statement-breakp
CREATE INDEX `integration_secret__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint
CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> statement-breakpoint
CREATE INDEX `integration__kind_idx` ON `integration` (`kind`);--> statement-breakpoint
CREATE UNIQUE INDEX `invite_token_unique` ON `invite` (`token`);--> statement-breakpoint
CREATE INDEX `user_id_idx` ON `session` (`userId`);

View File

@@ -1,8 +1,8 @@
{
"version": "5",
"dialect": "sqlite",
"id": "c0a91279-dffa-4567-8cd2-d9d2d1a2e77c",
"prevId": "7c2291ee-febd-4b90-994c-85e6ef27102d",
"id": "e3ff4a97-d357-4a64-989b-78668b36c82d",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"account": {
"name": "account",
@@ -361,6 +361,126 @@
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"groupMember": {
"name": "groupMember",
"columns": {
"groupId": {
"name": "groupId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"userId": {
"name": "userId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"groupMember_groupId_group_id_fk": {
"name": "groupMember_groupId_group_id_fk",
"tableFrom": "groupMember",
"tableTo": "group",
"columnsFrom": ["groupId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
},
"groupMember_userId_user_id_fk": {
"name": "groupMember_userId_user_id_fk",
"tableFrom": "groupMember",
"tableTo": "user",
"columnsFrom": ["userId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"groupMember_groupId_userId_pk": {
"columns": ["groupId", "userId"],
"name": "groupMember_groupId_userId_pk"
}
},
"uniqueConstraints": {}
},
"groupPermission": {
"name": "groupPermission",
"columns": {
"groupId": {
"name": "groupId",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission": {
"name": "permission",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"groupPermission_groupId_group_id_fk": {
"name": "groupPermission_groupId_group_id_fk",
"tableFrom": "groupPermission",
"tableTo": "group",
"columnsFrom": ["groupId"],
"columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"group": {
"name": "group",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"owner_id": {
"name": "owner_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"group_owner_id_user_id_fk": {
"name": "group_owner_id_user_id_fk",
"tableFrom": "group",
"tableTo": "user",
"columnsFrom": ["owner_id"],
"columnsTo": ["id"],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {}
},
"integration_item": {
"name": "integration_item",
"columns": {

View File

@@ -0,0 +1,13 @@
{
"version": "5",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1714414359385,
"tag": "0000_abnormal_kree",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,17 @@
import * as dotenv from "dotenv";
import type { Config } from "drizzle-kit";
dotenv.config({ path: "../../.env" });
export default {
schema: "./schema",
driver: "mysql2",
dbCredentials: {
host: process.env.DB_HOST!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
port: parseInt(process.env.DB_PORT!),
},
out: "./migrations/mysql",
} satisfies Config;

View File

@@ -17,8 +17,9 @@
"clean": "rm -rf .turbo node_modules",
"lint": "eslint .",
"format": "prettier --check . --ignore-path ../../.gitignore",
"migration:generate": "drizzle-kit generate:sqlite",
"migration:sqlite:generate": "drizzle-kit generate:sqlite --config ./sqlite.config.ts",
"migration:run": "tsx ./migrate.ts",
"migration:mysql:generate": "drizzle-kit generate:mysql --config ./mysql.config.ts",
"push": "drizzle-kit push:sqlite",
"studio": "drizzle-kit studio",
"typecheck": "tsc --noEmit"

View File

@@ -16,6 +16,7 @@ import type {
BackgroundImageRepeat,
BackgroundImageSize,
BoardPermission,
GroupPermissionKey,
IntegrationKind,
IntegrationSecretKind,
SectionKind,
@@ -92,6 +93,38 @@ export const verificationTokens = mysqlTable(
}),
);
export const groupMembers = mysqlTable(
"groupMember",
{
groupId: varchar("groupId", { length: 256 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
userId: varchar("userId", { length: 256 })
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
(groupMember) => ({
compoundKey: primaryKey({
columns: [groupMember.groupId, groupMember.userId],
}),
}),
);
export const groups = mysqlTable("group", {
id: varchar("id", { length: 256 }).notNull().primaryKey(),
name: varchar("name", { length: 64 }).notNull(),
ownerId: varchar("owner_id", { length: 256 }).references(() => users.id, {
onDelete: "set null",
}),
});
export const groupPermissions = mysqlTable("groupPermission", {
groupId: varchar("groupId", { length: 256 })
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<GroupPermissionKey>().notNull(),
});
export const invites = mysqlTable("invite", {
id: varchar("id", { length: 256 }).notNull().primaryKey(),
token: varchar("token", { length: 512 }).notNull().unique(),
@@ -245,6 +278,8 @@ export const userRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
boards: many(boards),
boardPermissions: many(boardPermissions),
groups: many(groupMembers),
ownedGroups: many(groups),
invites: many(invites),
}));
@@ -262,6 +297,36 @@ export const sessionRelations = relations(sessions, ({ one }) => ({
}),
}));
export const groupMemberRelations = relations(groupMembers, ({ one }) => ({
group: one(groups, {
fields: [groupMembers.groupId],
references: [groups.id],
}),
user: one(users, {
fields: [groupMembers.userId],
references: [users.id],
}),
}));
export const groupRelations = relations(groups, ({ one, many }) => ({
permissions: many(groupPermissions),
members: many(groupMembers),
owner: one(users, {
fields: [groups.ownerId],
references: [users.id],
}),
}));
export const groupPermissionRelations = relations(
groupPermissions,
({ one }) => ({
group: one(groups, {
fields: [groupPermissions.groupId],
references: [groups.id],
}),
}),
);
export const boardPermissionRelations = relations(
boardPermissions,
({ one }) => ({

View File

@@ -20,6 +20,7 @@ import type {
BackgroundImageRepeat,
BackgroundImageSize,
BoardPermission,
GroupPermissionKey,
IntegrationKind,
IntegrationSecretKind,
SectionKind,
@@ -89,6 +90,38 @@ export const verificationTokens = sqliteTable(
}),
);
export const groupMembers = sqliteTable(
"groupMember",
{
groupId: text("groupId")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
},
(groupMember) => ({
compoundKey: primaryKey({
columns: [groupMember.groupId, groupMember.userId],
}),
}),
);
export const groups = sqliteTable("group", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
ownerId: text("owner_id").references(() => users.id, {
onDelete: "set null",
}),
});
export const groupPermissions = sqliteTable("groupPermission", {
groupId: text("groupId")
.notNull()
.references(() => groups.id, { onDelete: "cascade" }),
permission: text("permission").$type<GroupPermissionKey>().notNull(),
});
export const invites = sqliteTable("invite", {
id: text("id").notNull().primaryKey(),
token: text("token").notNull().unique(),
@@ -242,6 +275,8 @@ export const userRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
boards: many(boards),
boardPermissions: many(boardPermissions),
groups: many(groupMembers),
ownedGroups: many(groups),
invites: many(invites),
}));
@@ -259,6 +294,36 @@ export const sessionRelations = relations(sessions, ({ one }) => ({
}),
}));
export const groupMemberRelations = relations(groupMembers, ({ one }) => ({
group: one(groups, {
fields: [groupMembers.groupId],
references: [groups.id],
}),
user: one(users, {
fields: [groupMembers.userId],
references: [users.id],
}),
}));
export const groupRelations = relations(groups, ({ one, many }) => ({
permissions: many(groupPermissions),
members: many(groupMembers),
owner: one(users, {
fields: [groups.ownerId],
references: [users.id],
}),
}));
export const groupPermissionRelations = relations(
groupPermissions,
({ one }) => ({
group: one(groups, {
fields: [groupPermissions.groupId],
references: [groups.id],
}),
}),
);
export const boardPermissionRelations = relations(
boardPermissions,
({ one }) => ({

View File

@@ -7,5 +7,5 @@ export default {
schema: "./schema",
driver: "better-sqlite",
dbCredentials: { url: process.env.DB_URL! },
out: "./migrations",
out: "./migrations/sqlite",
} satisfies Config;

View File

@@ -8,7 +8,7 @@ export const createDb = () => {
const sqlite = new Database(":memory:");
const db = drizzle(sqlite, { schema });
migrate(db, {
migrationsFolder: "./packages/db/migrations",
migrationsFolder: "./packages/db/migrations/sqlite",
});
return db;
};

View File

@@ -1,3 +1,69 @@
import { objectKeys } from "@homarr/common";
export const boardPermissions = ["board-view", "board-change"] as const;
export const groupPermissions = {
board: ["create", "view-all", "modify-all", "full-access"],
integration: ["create", "use-all", "interact-all", "full-access"],
admin: true,
} as const;
/**
* In the following object is described how the permissions are related to each other.
* For example everybody with the permission "board-modify-all" also has the permission "board-view-all".
* Or admin has all permissions (board-full-access and integration-full-access which will resolve in an array of every permission).
*/
const groupPermissionParents = {
"board-modify-all": ["board-view-all"],
"board-full-access": ["board-modify-all", "board-create"],
"integration-interact-all": ["integration-use-all"],
"integration-full-access": ["integration-interact-all", "integration-create"],
admin: ["board-full-access", "integration-full-access"],
} satisfies Partial<Record<GroupPermissionKey, GroupPermissionKey[]>>;
const getPermissionsInner = (
permissionSet: Set<GroupPermissionKey>,
permissions: GroupPermissionKey[],
) => {
permissions.forEach((permission) => {
const children =
groupPermissionParents[permission as keyof typeof groupPermissionParents];
if (children) {
getPermissionsInner(permissionSet, children);
}
permissionSet.add(permission);
});
};
export const getPermissionsWithChildren = (
permissions: GroupPermissionKey[],
) => {
const permissionSet = new Set<GroupPermissionKey>();
getPermissionsInner(permissionSet, permissions);
return Array.from(permissionSet);
};
type GroupPermissions = typeof groupPermissions;
export type GroupPermissionKey = {
[key in keyof GroupPermissions]: GroupPermissions[key] extends readonly string[]
? `${key}-${GroupPermissions[key][number]}`
: key;
}[keyof GroupPermissions];
export const groupPermissionKeys = objectKeys(groupPermissions).reduce(
(acc, key) => {
const item = groupPermissions[key];
if (typeof item !== "boolean") {
acc.push(
...item.map((subKey) => `${key}-${subKey}` as GroupPermissionKey),
);
} else {
acc.push(key as GroupPermissionKey);
}
return acc;
},
[] as GroupPermissionKey[],
);
export type BoardPermission = (typeof boardPermissions)[number];

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useState } from "react";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import type { ButtonProps, GroupProps } from "@mantine/core";
import { Box, Button, Group } from "@mantine/core";
@@ -33,6 +33,7 @@ export interface ConfirmModalProps {
export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
({ actions, innerProps }) => {
const [loading, setLoading] = useState(false);
const t = useI18n();
const {
children,
@@ -65,10 +66,12 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
const handleConfirm = useCallback(
async (event: React.MouseEvent<HTMLButtonElement>) => {
setLoading(true);
typeof confirmProps?.onClick === "function" &&
confirmProps?.onClick(event);
typeof onConfirm === "function" && (await onConfirm());
closeOnConfirm && actions.closeModal();
setLoading(false);
},
[confirmProps?.onClick, onConfirm, actions.closeModal],
);
@@ -82,7 +85,12 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
{cancelProps?.children || translateIfNecessary(t, cancelLabel)}
</Button>
<Button {...confirmProps} onClick={handleConfirm} color="red.9">
<Button
{...confirmProps}
onClick={handleConfirm}
color="red.9"
loading={loading}
>
{confirmProps?.children || translateIfNecessary(t, confirmLabel)}
</Button>
</Group>

View File

@@ -3,11 +3,11 @@
import { createI18nClient } from "next-international/client";
import { languageMapping } from "./lang";
import en from "./lang/en";
import enTranslation from "./lang/en";
export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient(
languageMapping(),
{
fallbackLocale: en,
fallbackLocale: enTranslation,
},
);

View File

@@ -29,6 +29,150 @@ export default {
action: {
login: "Login",
create: "Create user",
select: {
label: "Select user",
notFound: "No user found",
},
transfer: {
label: "Select new owner",
},
},
},
group: {
title: "Groups",
name: "Group",
search: "Find a group",
field: {
name: "Name",
members: "Members",
},
permission: {
admin: {
title: "Admin",
item: {
admin: {
label: "Administrator",
description:
"Members with this permission have full access to all features and settings",
},
},
},
board: {
title: "Boards",
item: {
create: {
label: "Create boards",
description: "Allow members to create boards",
},
"view-all": {
label: "View all boards",
description: "Allow members to view all boards",
},
"modify-all": {
label: "Modify all boards",
description:
"Allow members to modify all boards (Does not include access control and danger zone)",
},
"full-access": {
label: "Full board access",
description:
"Allow members to view, modify, and delete all boards (Including access control and danger zone)",
},
},
},
integration: {
title: "Integrations",
item: {
create: {
label: "Create integrations",
description: "Allow members to create integrations",
},
"use-all": {
label: "Use all integrations",
description:
"Allows members to add any integrations to their boards",
},
"interact-all": {
label: "Interact with any integration",
description: "Allow members to interact with any integration",
},
"full-access": {
label: "Full integration access",
description:
"Allow members to manage, use and interact with any integration",
},
},
},
},
action: {
create: {
label: "New group",
notification: {
success: {
message: "The app was successfully created",
},
error: {
message: "The app could not be created",
},
},
},
transfer: {
label: "Transfer ownership",
description: "Transfer ownership of this group to another user.",
confirm:
"Are you sure you want to transfer ownership for the group {name} to {username}?",
notification: {
success: {
message: "Transfered group {group} successfully to {user}",
},
error: {
message: "Unable to transfer ownership",
},
},
},
addMember: {
label: "Add member",
},
removeMember: {
label: "Remove member",
confirm: "Are you sure you want to remove {user} from this group?",
},
delete: {
label: "Delete group",
description:
"Once you delete a group, there is no going back. Please be certain.",
confirm: "Are you sure you want to delete the group {name}?",
notification: {
success: {
message: "Deleted group {name} successfully",
},
error: {
message: "Unable to delete group {name}",
},
},
},
changePermissions: {
notification: {
success: {
title: "Permissions saved",
message: "Permissions have been saved successfully",
},
error: {
title: "Permissions not saved",
message: "Permissions have not been saved",
},
},
},
update: {
notification: {
success: {
message: "The group {name} was saved successfully",
},
error: {
message: "Unable to save group {name}",
},
},
},
},
},
app: {
@@ -204,11 +348,31 @@ export default {
save: "Save",
saveChanges: "Save changes",
cancel: "Cancel",
discard: "Discard",
confirm: "Confirm",
continue: "Continue",
previous: "Previous",
next: "Next",
checkoutDocs: "Check out the documentation",
},
notification: {
create: {
success: "Creation successful",
error: "Creation failed",
},
delete: {
success: "Deletion successful",
error: "Deletion failed",
},
update: {
success: "Changes applied successfully",
error: "Unable to apply changes",
},
transfer: {
success: "Transfer successful",
error: "Transfer failed",
},
},
multiSelect: {
placeholder: "Pick one or more values",
},
@@ -708,8 +872,6 @@ export default {
permission: {
userSelect: {
title: "Add user permission",
label: "Select user",
notFound: "No user found",
},
field: {
user: {
@@ -803,6 +965,7 @@ export default {
items: {
manage: "Manage",
invites: "Invites",
groups: "Groups",
},
},
tools: {
@@ -958,6 +1121,26 @@ export default {
},
},
},
group: {
back: "Back to groups",
setting: {
general: {
title: "General",
dangerZone: "Danger zone",
},
members: {
title: "Members",
search: "Find a member",
notFound: "No members found",
},
permissions: {
title: "Permissions",
form: {
unsavedChanges: "You have unsaved changes!",
},
},
},
},
about: {
version: "Version {version}",
text: "Homarr is a community driven open source project that is being maintained by volunteers. Thanks to these people, Homarr has been a growing project since 2021. Our team is working completely remote from many different countries on Homarr in their leisure time for no compensation.",

View File

@@ -1,11 +1,11 @@
import { createI18nServer } from "next-international/server";
import { languageMapping } from "./lang";
import en from "./lang/en";
import enTranslation from "./lang/en";
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(
languageMapping(),
{
fallbackLocale: en,
fallbackLocale: enTranslation,
},
);

View File

@@ -28,6 +28,9 @@
"eslint": "^8.57.0",
"typescript": "^5.4.5"
},
"dependencies": {
"@homarr/log": "workspace:^0.1.0"
},
"eslintConfig": {
"extends": [
"@homarr/eslint-config/base"

View File

@@ -1,3 +1,7 @@
export * from "./count-badge";
export * from "./select-with-description";
export * from "./select-with-description-and-badge";
export { UserAvatar } from "./user-avatar";
export { UserAvatarGroup } from "./user-avatar-group";
export { TablePagination } from "./table-pagination";
export { SearchInput } from "./search-input";

View File

@@ -0,0 +1,59 @@
"use client";
import type { ChangeEvent } from "react";
import { useCallback, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { Loader, TextInput } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
import { IconSearch } from "@tabler/icons-react";
interface SearchInputProps {
defaultValue?: string;
placeholder: string;
}
export const SearchInput = ({
placeholder,
defaultValue,
}: SearchInputProps) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { replace } = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const [loading, setLoading] = useState(false);
const handleSearchDebounced = useDebouncedCallback((value: string) => {
const params = new URLSearchParams(searchParams);
params.set("search", value.toString());
if (params.has("page")) params.set("page", "1"); // Reset page to 1
replace(`${pathName}?${params.toString()}`);
setLoading(false);
}, 250);
const handleSearch = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setLoading(true);
handleSearchDebounced(event.currentTarget.value);
},
[setLoading, handleSearchDebounced],
);
return (
<TextInput
leftSection={<LeftSection loading={loading} />}
defaultValue={defaultValue}
onChange={handleSearch}
placeholder={placeholder}
/>
);
};
interface LeftSectionProps {
loading: boolean;
}
const LeftSection = ({ loading }: LeftSectionProps) => {
if (loading) {
return <Loader size="xs" />;
}
return <IconSearch size={20} stroke={1.5} />;
};

View File

@@ -0,0 +1,80 @@
"use client";
import { useCallback } from "react";
import Link from "next/link";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import type { PaginationProps } from "@mantine/core";
import { Pagination } from "@mantine/core";
interface TablePaginationProps {
total: number;
}
export const TablePagination = ({ total }: TablePaginationProps) => {
// eslint-disable-next-line @typescript-eslint/unbound-method
const { replace } = useRouter();
const pathName = usePathname();
const searchParams = useSearchParams();
const current = Number(searchParams.get("page")) || 1;
const getItemProps = useCallback(
(page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
return {
component: Link,
href: `?${params.toString()}`,
};
},
[searchParams],
);
const getControlProps = useCallback(
(control: ControlType) => {
return getItemProps(calculatePageFor(control, current, total));
},
[current],
);
const handleChange = useCallback(
(page: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", page.toString());
replace(`${pathName}?${params.toString()}`);
},
[pathName, searchParams],
);
return (
<Pagination
total={total}
getItemProps={getItemProps}
getControlProps={getControlProps}
onChange={handleChange}
/>
);
};
type ControlType = Parameters<
Exclude<PaginationProps["getControlProps"], undefined>
>[0];
const calculatePageFor = (
type: ControlType,
current: number,
total: number,
) => {
switch (type) {
case "first":
return 1;
case "previous":
return Math.max(current - 1, 1);
case "next":
return current + 1;
case "last":
return total;
default:
console.error(`Unknown pagination control type: ${type as string}`);
return 1;
}
};

View File

@@ -0,0 +1,48 @@
import type { MantineSize } from "@mantine/core";
import { Avatar, AvatarGroup, Tooltip, TooltipGroup } from "@mantine/core";
import type { UserProps } from "./user-avatar";
import { UserAvatar } from "./user-avatar";
interface UserAvatarGroupProps {
size: MantineSize;
limit: number;
users: UserProps[];
}
export const UserAvatarGroup = ({
size,
limit,
users,
}: UserAvatarGroupProps) => {
return (
<TooltipGroup openDelay={300} closeDelay={300}>
<AvatarGroup>
{users.slice(0, limit).map((user) => (
<Tooltip key={user.name} label={user.name} withArrow>
<UserAvatar user={user} size={size} />
</Tooltip>
))}
<MoreUsers size={size} users={users} offset={limit} />
</AvatarGroup>
</TooltipGroup>
);
};
interface MoreUsersProps {
size: MantineSize;
users: unknown[];
offset: number;
}
const MoreUsers = ({ size, users, offset }: MoreUsersProps) => {
if (users.length <= offset) return null;
const moreAmount = users.length - offset;
return (
<Avatar size={size} radius="xl">
+{moreAmount}
</Avatar>
);
};

View File

@@ -0,0 +1,28 @@
import { Avatar } from "@mantine/core";
import type { AvatarProps, MantineSize } from "@mantine/core";
export interface UserProps {
name: string | null;
image: string | null;
}
interface UserAvatarProps {
user: UserProps | null;
size: MantineSize;
}
export const UserAvatar = ({ user, size }: UserAvatarProps) => {
const commonProps = {
size,
color: "primaryColor",
} satisfies Partial<AvatarProps>;
if (!user?.name) return <Avatar {...commonProps} />;
if (user.image) {
return <Avatar {...commonProps} src={user.image} alt={user.name} />;
}
return (
<Avatar {...commonProps}>{user.name.substring(0, 2).toUpperCase()}</Avatar>
);
};

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { groupPermissionKeys } from "@homarr/definitions";
import { zodEnumFromArray } from "./enums";
const paginatedSchema = z.object({
search: z.string().optional(),
pageSize: z.number().int().positive().default(10),
page: z.number().int().positive().default(1),
});
const byIdSchema = z.object({
id: z.string(),
});
const createSchema = z.object({
name: z.string().max(64),
});
const updateSchema = createSchema.merge(byIdSchema);
const savePermissionsSchema = z.object({
groupId: z.string(),
permissions: z.array(zodEnumFromArray(groupPermissionKeys)),
});
const groupUserSchema = z.object({ groupId: z.string(), userId: z.string() });
export const groupSchemas = {
paginated: paginatedSchema,
byId: byIdSchema,
create: createSchema,
update: updateSchema,
savePermissions: savePermissionsSchema,
groupUser: groupUserSchema,
};

View File

@@ -1,5 +1,6 @@
import { appSchemas } from "./app";
import { boardSchemas } from "./board";
import { groupSchemas } from "./group";
import { integrationSchemas } from "./integration";
import { locationSchemas } from "./location";
import { userSchemas } from "./user";
@@ -7,6 +8,7 @@ import { widgetSchemas } from "./widgets";
export const validation = {
user: userSchemas,
group: groupSchemas,
integration: integrationSchemas,
board: boardSchemas,
app: appSchemas,

4
pnpm-lock.yaml generated
View File

@@ -724,6 +724,10 @@ importers:
version: 5.4.5
packages/ui:
dependencies:
'@homarr/log':
specifier: workspace:^0.1.0
version: link:../log
devDependencies:
'@homarr/eslint-config':
specifier: workspace:^0.2.0

1
scripts/docker-run.cmd Normal file
View File

@@ -0,0 +1 @@
docker run -p 3000:3000 -p 3001:3001 homarr:latest

View File

@@ -1,5 +1,5 @@
# Run migrations
node ./db/migrate.cjs ./db/migrations
node ./db/migrate.cjs ./db/migrations/sqlite
# Start Redis
redis-server &