mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
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:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"),
|
||||
});
|
||||
102
apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx
Normal file
102
apps/nextjs/src/app/[locale]/manage/users/groups/page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
232
packages/api/src/router/group.ts
Normal file
232
packages/api/src/router/group.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
};
|
||||
664
packages/api/src/router/test/group.spec.ts
Normal file
664
packages/api/src/router/test/group.spec.ts
Normal 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",
|
||||
});
|
||||
@@ -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:", ""));
|
||||
|
||||
|
||||
@@ -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`);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
168
packages/db/migrations/mysql/0000_chubby_darkhawk.sql
Normal file
168
packages/db/migrations/mysql/0000_chubby_darkhawk.sql
Normal 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;
|
||||
@@ -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": {},
|
||||
13
packages/db/migrations/mysql/meta/_journal.json
Normal file
13
packages/db/migrations/mysql/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "mysql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1714414260766,
|
||||
"tag": "0000_chubby_darkhawk",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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`);
|
||||
@@ -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": {
|
||||
13
packages/db/migrations/sqlite/meta/_journal.json
Normal file
13
packages/db/migrations/sqlite/meta/_journal.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "5",
|
||||
"when": 1714414359385,
|
||||
"tag": "0000_abnormal_kree",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
17
packages/db/mysql.config.ts
Normal file
17
packages/db/mysql.config.ts
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -7,5 +7,5 @@ export default {
|
||||
schema: "./schema",
|
||||
driver: "better-sqlite",
|
||||
dbCredentials: { url: process.env.DB_URL! },
|
||||
out: "./migrations",
|
||||
out: "./migrations/sqlite",
|
||||
} satisfies Config;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
59
packages/ui/src/components/search-input.tsx
Normal file
59
packages/ui/src/components/search-input.tsx
Normal 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} />;
|
||||
};
|
||||
80
packages/ui/src/components/table-pagination.tsx
Normal file
80
packages/ui/src/components/table-pagination.tsx
Normal 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;
|
||||
}
|
||||
};
|
||||
48
packages/ui/src/components/user-avatar-group.tsx
Normal file
48
packages/ui/src/components/user-avatar-group.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
packages/ui/src/components/user-avatar.tsx
Normal file
28
packages/ui/src/components/user-avatar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
37
packages/validation/src/group.ts
Normal file
37
packages/validation/src/group.ts
Normal 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,
|
||||
};
|
||||
@@ -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
4
pnpm-lock.yaml
generated
@@ -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
1
scripts/docker-run.cmd
Normal file
@@ -0,0 +1 @@
|
||||
docker run -p 3000:3000 -p 3001:3001 homarr:latest
|
||||
@@ -1,5 +1,5 @@
|
||||
# Run migrations
|
||||
node ./db/migrate.cjs ./db/migrations
|
||||
node ./db/migrate.cjs ./db/migrations/sqlite
|
||||
|
||||
# Start Redis
|
||||
redis-server &
|
||||
|
||||
Reference in New Issue
Block a user