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";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import type { SelectProps } from "@mantine/core";
|
import type { SelectProps } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Flex,
|
Flex,
|
||||||
Group,
|
Group,
|
||||||
|
Loader,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Table,
|
||||||
@@ -241,7 +242,8 @@ interface FormType {
|
|||||||
|
|
||||||
interface InnerProps {
|
interface InnerProps {
|
||||||
presentUserIds: string[];
|
presentUserIds: string[];
|
||||||
onSelect: (props: { id: string; name: string }) => void;
|
onSelect: (props: { id: string; name: string }) => void | Promise<void>;
|
||||||
|
confirmLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserSelectFormType {
|
interface UserSelectFormType {
|
||||||
@@ -251,40 +253,45 @@ interface UserSelectFormType {
|
|||||||
export const UserSelectModal = createModal<InnerProps>(
|
export const UserSelectModal = createModal<InnerProps>(
|
||||||
({ actions, innerProps }) => {
|
({ actions, innerProps }) => {
|
||||||
const t = useI18n();
|
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 form = useForm<UserSelectFormType>();
|
||||||
const handleSubmit = (values: UserSelectFormType) => {
|
const handleSubmit = async (values: UserSelectFormType) => {
|
||||||
const currentUser = users?.find((user) => user.id === values.userId);
|
const currentUser = users?.find((user) => user.id === values.userId);
|
||||||
if (!currentUser) return;
|
if (!currentUser) return;
|
||||||
innerProps.onSelect({
|
setLoading(true);
|
||||||
|
await innerProps.onSelect({
|
||||||
id: currentUser.id,
|
id: currentUser.id,
|
||||||
name: currentUser.name ?? "",
|
name: currentUser.name ?? "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
actions.closeModal();
|
actions.closeModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmLabel = innerProps.confirmLabel ?? t("common.action.add");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
<form onSubmit={form.onSubmit((values) => void handleSubmit(values))}>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Select
|
<Select
|
||||||
{...form.getInputProps("userId")}
|
{...form.getInputProps("userId")}
|
||||||
label={t(
|
label={t("user.action.select.label")}
|
||||||
"board.setting.section.access.permission.userSelect.label",
|
|
||||||
)}
|
|
||||||
searchable
|
searchable
|
||||||
nothingFoundMessage={t(
|
leftSection={isPending ? <Loader size="xs" /> : undefined}
|
||||||
"board.setting.section.access.permission.userSelect.notFound",
|
nothingFoundMessage={t("user.action.select.notFound")}
|
||||||
)}
|
|
||||||
limit={5}
|
limit={5}
|
||||||
data={users
|
data={users
|
||||||
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
|
?.filter((user) => !innerProps.presentUserIds.includes(user.id))
|
||||||
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
|
.map((user) => ({ value: user.id, label: user.name ?? "" }))}
|
||||||
/>
|
/>
|
||||||
<Group justify="end">
|
<Group justify="end">
|
||||||
<Button onClick={actions.closeModal}>
|
<Button variant="default" onClick={actions.closeModal}>
|
||||||
{t("common.action.cancel")}
|
{t("common.action.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit">{t("common.action.add")}</Button>
|
<Button type="submit" loading={loading}>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
IconTool,
|
IconTool,
|
||||||
IconUser,
|
IconUser,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
|
IconUsersGroup,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
import { getScopedI18n } from "@homarr/translation/server";
|
import { getScopedI18n } from "@homarr/translation/server";
|
||||||
@@ -51,6 +52,11 @@ export default async function ManageLayout({ children }: PropsWithChildren) {
|
|||||||
icon: IconMailForward,
|
icon: IconMailForward,
|
||||||
href: "/manage/users/invites",
|
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",
|
"clean:workspaces": "turbo clean",
|
||||||
"db:push": "pnpm -F db push",
|
"db:push": "pnpm -F db push",
|
||||||
"db:studio": "pnpm -F db studio",
|
"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",
|
"db:migration:run": "pnpm -F db migration:run",
|
||||||
"dev": "turbo dev --parallel",
|
"dev": "turbo dev --parallel",
|
||||||
"docker:dev": "docker compose -f ./development.docker-compose.yml up",
|
"docker:dev": "docker compose -f ./development.docker-compose.yml up",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { appRouter as innerAppRouter } from "./router/app";
|
import { appRouter as innerAppRouter } from "./router/app";
|
||||||
import { boardRouter } from "./router/board";
|
import { boardRouter } from "./router/board";
|
||||||
|
import { groupRouter } from "./router/group";
|
||||||
import { integrationRouter } from "./router/integration";
|
import { integrationRouter } from "./router/integration";
|
||||||
import { inviteRouter } from "./router/invite";
|
import { inviteRouter } from "./router/invite";
|
||||||
import { locationRouter } from "./router/location";
|
import { locationRouter } from "./router/location";
|
||||||
@@ -10,6 +11,7 @@ import { createTRPCRouter } from "./trpc";
|
|||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
user: userRouter,
|
user: userRouter,
|
||||||
|
group: groupRouter,
|
||||||
invite: inviteRouter,
|
invite: inviteRouter,
|
||||||
integration: integrationRouter,
|
integration: integrationRouter,
|
||||||
board: boardRouter,
|
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 { drizzle } from "drizzle-orm/better-sqlite3";
|
||||||
import { migrate } from "drizzle-orm/better-sqlite3/migrator";
|
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:", ""));
|
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",
|
"version": "5",
|
||||||
"dialect": "sqlite",
|
"dialect": "mysql",
|
||||||
"id": "7c2291ee-febd-4b90-994c-85e6ef27102d",
|
"id": "d0a05e9e-107f-4bed-ac54-a4a41369f0da",
|
||||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"account": {
|
"account": {
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
"columns": {
|
"columns": {
|
||||||
"userId": {
|
"userId": {
|
||||||
"name": "userId",
|
"name": "userId",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -23,14 +23,14 @@
|
|||||||
},
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"name": "provider",
|
"name": "provider",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"providerAccountId": {
|
"providerAccountId": {
|
||||||
"name": "providerAccountId",
|
"name": "providerAccountId",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
},
|
},
|
||||||
"expires_at": {
|
"expires_at": {
|
||||||
"name": "expires_at",
|
"name": "expires_at",
|
||||||
"type": "integer",
|
"type": "int",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -105,8 +105,8 @@
|
|||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"account_provider_providerAccountId_pk": {
|
"account_provider_providerAccountId_pk": {
|
||||||
"columns": ["provider", "providerAccountId"],
|
"name": "account_provider_providerAccountId_pk",
|
||||||
"name": "account_provider_providerAccountId_pk"
|
"columns": ["provider", "providerAccountId"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
@@ -116,8 +116,8 @@
|
|||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
@@ -152,7 +152,12 @@
|
|||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {
|
||||||
|
"app_id": {
|
||||||
|
"name": "app_id",
|
||||||
|
"columns": ["id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
},
|
},
|
||||||
"boardPermission": {
|
"boardPermission": {
|
||||||
@@ -203,8 +208,8 @@
|
|||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"boardPermission_board_id_user_id_permission_pk": {
|
"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": {}
|
"uniqueConstraints": {}
|
||||||
@@ -214,21 +219,21 @@
|
|||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"name": "name",
|
"name": "name",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"is_public": {
|
"is_public": {
|
||||||
"name": "is_public",
|
"name": "is_public",
|
||||||
"type": "integer",
|
"type": "boolean",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -282,7 +287,7 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "'fixed'"
|
"default": "('fixed')"
|
||||||
},
|
},
|
||||||
"background_image_repeat": {
|
"background_image_repeat": {
|
||||||
"name": "background_image_repeat",
|
"name": "background_image_repeat",
|
||||||
@@ -290,7 +295,7 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "'no-repeat'"
|
"default": "('no-repeat')"
|
||||||
},
|
},
|
||||||
"background_image_size": {
|
"background_image_size": {
|
||||||
"name": "background_image_size",
|
"name": "background_image_size",
|
||||||
@@ -298,7 +303,7 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "'cover'"
|
"default": "('cover')"
|
||||||
},
|
},
|
||||||
"primary_color": {
|
"primary_color": {
|
||||||
"name": "primary_color",
|
"name": "primary_color",
|
||||||
@@ -306,7 +311,7 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "'#fa5252'"
|
"default": "('#fa5252')"
|
||||||
},
|
},
|
||||||
"secondary_color": {
|
"secondary_color": {
|
||||||
"name": "secondary_color",
|
"name": "secondary_color",
|
||||||
@@ -314,11 +319,11 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "'#fd7e14'"
|
"default": "('#fd7e14')"
|
||||||
},
|
},
|
||||||
"opacity": {
|
"opacity": {
|
||||||
"name": "opacity",
|
"name": "opacity",
|
||||||
"type": "integer",
|
"type": "int",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
@@ -333,20 +338,14 @@
|
|||||||
},
|
},
|
||||||
"column_count": {
|
"column_count": {
|
||||||
"name": "column_count",
|
"name": "column_count",
|
||||||
"type": "integer",
|
"type": "int",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": 10
|
"default": 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {
|
"indexes": {},
|
||||||
"board_name_unique": {
|
|
||||||
"name": "board_name_unique",
|
|
||||||
"columns": ["name"],
|
|
||||||
"isUnique": true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"foreignKeys": {
|
"foreignKeys": {
|
||||||
"board_creator_id_user_id_fk": {
|
"board_creator_id_user_id_fk": {
|
||||||
"name": "board_creator_id_user_id_fk",
|
"name": "board_creator_id_user_id_fk",
|
||||||
@@ -358,22 +357,157 @@
|
|||||||
"onUpdate": "no action"
|
"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": {},
|
"compositePrimaryKeys": {},
|
||||||
"uniqueConstraints": {}
|
"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": {
|
"integration_item": {
|
||||||
"name": "integration_item",
|
"name": "integration_item",
|
||||||
"columns": {
|
"columns": {
|
||||||
"item_id": {
|
"item_id": {
|
||||||
"name": "item_id",
|
"name": "item_id",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"integration_id": {
|
"integration_id": {
|
||||||
"name": "integration_id",
|
"name": "integration_id",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -402,8 +536,8 @@
|
|||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"integration_item_item_id_integration_id_pk": {
|
"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": {}
|
"uniqueConstraints": {}
|
||||||
@@ -413,7 +547,7 @@
|
|||||||
"columns": {
|
"columns": {
|
||||||
"kind": {
|
"kind": {
|
||||||
"name": "kind",
|
"name": "kind",
|
||||||
"type": "text",
|
"type": "varchar(16)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -427,14 +561,14 @@
|
|||||||
},
|
},
|
||||||
"updated_at": {
|
"updated_at": {
|
||||||
"name": "updated_at",
|
"name": "updated_at",
|
||||||
"type": "integer",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"integration_id": {
|
"integration_id": {
|
||||||
"name": "integration_id",
|
"name": "integration_id",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -465,8 +599,8 @@
|
|||||||
},
|
},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"integrationSecret_integration_id_kind_pk": {
|
"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": {}
|
"uniqueConstraints": {}
|
||||||
@@ -476,8 +610,8 @@
|
|||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
@@ -497,7 +631,7 @@
|
|||||||
},
|
},
|
||||||
"kind": {
|
"kind": {
|
||||||
"name": "kind",
|
"name": "kind",
|
||||||
"type": "text",
|
"type": "varchar(128)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -511,22 +645,84 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {
|
||||||
|
"integration_id": {
|
||||||
|
"name": "integration_id",
|
||||||
|
"columns": ["id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"uniqueConstraints": {}
|
"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": {
|
"item": {
|
||||||
"name": "item",
|
"name": "item",
|
||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"section_id": {
|
"section_id": {
|
||||||
"name": "section_id",
|
"name": "section_id",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -540,28 +736,28 @@
|
|||||||
},
|
},
|
||||||
"x_offset": {
|
"x_offset": {
|
||||||
"name": "x_offset",
|
"name": "x_offset",
|
||||||
"type": "integer",
|
"type": "int",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"y_offset": {
|
"y_offset": {
|
||||||
"name": "y_offset",
|
"name": "y_offset",
|
||||||
"type": "integer",
|
"type": "int",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"width": {
|
"width": {
|
||||||
"name": "width",
|
"name": "width",
|
||||||
"type": "integer",
|
"type": "int",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"height": {
|
"height": {
|
||||||
"name": "height",
|
"name": "height",
|
||||||
"type": "integer",
|
"type": "int",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -572,7 +768,7 @@
|
|||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false,
|
"autoincrement": false,
|
||||||
"default": "'{\"json\": {}}'"
|
"default": "('{\"json\": {}}')"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
@@ -587,7 +783,12 @@
|
|||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {
|
||||||
|
"item_id": {
|
||||||
|
"name": "item_id",
|
||||||
|
"columns": ["id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
},
|
},
|
||||||
"section": {
|
"section": {
|
||||||
@@ -595,14 +796,14 @@
|
|||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"board_id": {
|
"board_id": {
|
||||||
"name": "board_id",
|
"name": "board_id",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -616,7 +817,7 @@
|
|||||||
},
|
},
|
||||||
"position": {
|
"position": {
|
||||||
"name": "position",
|
"name": "position",
|
||||||
"type": "integer",
|
"type": "int",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -641,7 +842,12 @@
|
|||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {
|
||||||
|
"section_id": {
|
||||||
|
"name": "section_id",
|
||||||
|
"columns": ["id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
},
|
},
|
||||||
"session": {
|
"session": {
|
||||||
@@ -649,21 +855,21 @@
|
|||||||
"columns": {
|
"columns": {
|
||||||
"sessionToken": {
|
"sessionToken": {
|
||||||
"name": "sessionToken",
|
"name": "sessionToken",
|
||||||
"type": "text",
|
"type": "varchar(512)",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"userId": {
|
"userId": {
|
||||||
"name": "userId",
|
"name": "userId",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"expires": {
|
"expires": {
|
||||||
"name": "expires",
|
"name": "expires",
|
||||||
"type": "integer",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -687,7 +893,12 @@
|
|||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {
|
||||||
|
"session_sessionToken": {
|
||||||
|
"name": "session_sessionToken",
|
||||||
|
"columns": ["sessionToken"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
@@ -695,8 +906,8 @@
|
|||||||
"columns": {
|
"columns": {
|
||||||
"id": {
|
"id": {
|
||||||
"name": "id",
|
"name": "id",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": true,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
@@ -716,7 +927,7 @@
|
|||||||
},
|
},
|
||||||
"emailVerified": {
|
"emailVerified": {
|
||||||
"name": "emailVerified",
|
"name": "emailVerified",
|
||||||
"type": "integer",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -745,7 +956,12 @@
|
|||||||
},
|
},
|
||||||
"indexes": {},
|
"indexes": {},
|
||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"columns": ["id"]
|
||||||
|
}
|
||||||
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
},
|
},
|
||||||
"verificationToken": {
|
"verificationToken": {
|
||||||
@@ -753,21 +969,21 @@
|
|||||||
"columns": {
|
"columns": {
|
||||||
"identifier": {
|
"identifier": {
|
||||||
"name": "identifier",
|
"name": "identifier",
|
||||||
"type": "text",
|
"type": "varchar(256)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"token": {
|
"token": {
|
||||||
"name": "token",
|
"name": "token",
|
||||||
"type": "text",
|
"type": "varchar(512)",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
"expires": {
|
"expires": {
|
||||||
"name": "expires",
|
"name": "expires",
|
||||||
"type": "integer",
|
"type": "timestamp",
|
||||||
"primaryKey": false,
|
"primaryKey": false,
|
||||||
"notNull": true,
|
"notNull": true,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
@@ -777,14 +993,14 @@
|
|||||||
"foreignKeys": {},
|
"foreignKeys": {},
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"verificationToken_identifier_token_pk": {
|
"verificationToken_identifier_token_pk": {
|
||||||
"columns": ["identifier", "token"],
|
"name": "verificationToken_identifier_token_pk",
|
||||||
"name": "verificationToken_identifier_token_pk"
|
"columns": ["identifier", "token"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {}
|
"uniqueConstraints": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"enums": {},
|
"schemas": {},
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"schemas": {},
|
"schemas": {},
|
||||||
"tables": {},
|
"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
|
FOREIGN KEY (`creator_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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` (
|
CREATE TABLE `integration_item` (
|
||||||
`item_id` text NOT NULL,
|
`item_id` text NOT NULL,
|
||||||
`integration_id` text NOT NULL,
|
`integration_id` text NOT NULL,
|
||||||
@@ -76,6 +97,14 @@ CREATE TABLE `integration` (
|
|||||||
`kind` text NOT NULL
|
`kind` text NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> 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` (
|
CREATE TABLE `item` (
|
||||||
`id` text PRIMARY KEY NOT NULL,
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
`section_id` text 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__kind_idx` ON `integrationSecret` (`kind`);--> statement-breakpoint
|
||||||
CREATE INDEX `integration_secret__updated_at_idx` ON `integrationSecret` (`updated_at`);--> 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 `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`);
|
CREATE INDEX `user_id_idx` ON `session` (`userId`);
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"version": "5",
|
"version": "5",
|
||||||
"dialect": "sqlite",
|
"dialect": "sqlite",
|
||||||
"id": "c0a91279-dffa-4567-8cd2-d9d2d1a2e77c",
|
"id": "e3ff4a97-d357-4a64-989b-78668b36c82d",
|
||||||
"prevId": "7c2291ee-febd-4b90-994c-85e6ef27102d",
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
"tables": {
|
"tables": {
|
||||||
"account": {
|
"account": {
|
||||||
"name": "account",
|
"name": "account",
|
||||||
@@ -361,6 +361,126 @@
|
|||||||
"compositePrimaryKeys": {},
|
"compositePrimaryKeys": {},
|
||||||
"uniqueConstraints": {}
|
"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": {
|
"integration_item": {
|
||||||
"name": "integration_item",
|
"name": "integration_item",
|
||||||
"columns": {
|
"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",
|
"clean": "rm -rf .turbo node_modules",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
"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:run": "tsx ./migrate.ts",
|
||||||
|
"migration:mysql:generate": "drizzle-kit generate:mysql --config ./mysql.config.ts",
|
||||||
"push": "drizzle-kit push:sqlite",
|
"push": "drizzle-kit push:sqlite",
|
||||||
"studio": "drizzle-kit studio",
|
"studio": "drizzle-kit studio",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
BackgroundImageRepeat,
|
BackgroundImageRepeat,
|
||||||
BackgroundImageSize,
|
BackgroundImageSize,
|
||||||
BoardPermission,
|
BoardPermission,
|
||||||
|
GroupPermissionKey,
|
||||||
IntegrationKind,
|
IntegrationKind,
|
||||||
IntegrationSecretKind,
|
IntegrationSecretKind,
|
||||||
SectionKind,
|
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", {
|
export const invites = mysqlTable("invite", {
|
||||||
id: varchar("id", { length: 256 }).notNull().primaryKey(),
|
id: varchar("id", { length: 256 }).notNull().primaryKey(),
|
||||||
token: varchar("token", { length: 512 }).notNull().unique(),
|
token: varchar("token", { length: 512 }).notNull().unique(),
|
||||||
@@ -245,6 +278,8 @@ export const userRelations = relations(users, ({ many }) => ({
|
|||||||
accounts: many(accounts),
|
accounts: many(accounts),
|
||||||
boards: many(boards),
|
boards: many(boards),
|
||||||
boardPermissions: many(boardPermissions),
|
boardPermissions: many(boardPermissions),
|
||||||
|
groups: many(groupMembers),
|
||||||
|
ownedGroups: many(groups),
|
||||||
invites: many(invites),
|
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(
|
export const boardPermissionRelations = relations(
|
||||||
boardPermissions,
|
boardPermissions,
|
||||||
({ one }) => ({
|
({ one }) => ({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type {
|
|||||||
BackgroundImageRepeat,
|
BackgroundImageRepeat,
|
||||||
BackgroundImageSize,
|
BackgroundImageSize,
|
||||||
BoardPermission,
|
BoardPermission,
|
||||||
|
GroupPermissionKey,
|
||||||
IntegrationKind,
|
IntegrationKind,
|
||||||
IntegrationSecretKind,
|
IntegrationSecretKind,
|
||||||
SectionKind,
|
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", {
|
export const invites = sqliteTable("invite", {
|
||||||
id: text("id").notNull().primaryKey(),
|
id: text("id").notNull().primaryKey(),
|
||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
@@ -242,6 +275,8 @@ export const userRelations = relations(users, ({ many }) => ({
|
|||||||
accounts: many(accounts),
|
accounts: many(accounts),
|
||||||
boards: many(boards),
|
boards: many(boards),
|
||||||
boardPermissions: many(boardPermissions),
|
boardPermissions: many(boardPermissions),
|
||||||
|
groups: many(groupMembers),
|
||||||
|
ownedGroups: many(groups),
|
||||||
invites: many(invites),
|
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(
|
export const boardPermissionRelations = relations(
|
||||||
boardPermissions,
|
boardPermissions,
|
||||||
({ one }) => ({
|
({ one }) => ({
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ export default {
|
|||||||
schema: "./schema",
|
schema: "./schema",
|
||||||
driver: "better-sqlite",
|
driver: "better-sqlite",
|
||||||
dbCredentials: { url: process.env.DB_URL! },
|
dbCredentials: { url: process.env.DB_URL! },
|
||||||
out: "./migrations",
|
out: "./migrations/sqlite",
|
||||||
} satisfies Config;
|
} satisfies Config;
|
||||||
@@ -8,7 +8,7 @@ export const createDb = () => {
|
|||||||
const sqlite = new Database(":memory:");
|
const sqlite = new Database(":memory:");
|
||||||
const db = drizzle(sqlite, { schema });
|
const db = drizzle(sqlite, { schema });
|
||||||
migrate(db, {
|
migrate(db, {
|
||||||
migrationsFolder: "./packages/db/migrations",
|
migrationsFolder: "./packages/db/migrations/sqlite",
|
||||||
});
|
});
|
||||||
return db;
|
return db;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,69 @@
|
|||||||
|
import { objectKeys } from "@homarr/common";
|
||||||
|
|
||||||
export const boardPermissions = ["board-view", "board-change"] as const;
|
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];
|
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 { ComponentPropsWithoutRef, ReactNode } from "react";
|
||||||
import type { ButtonProps, GroupProps } from "@mantine/core";
|
import type { ButtonProps, GroupProps } from "@mantine/core";
|
||||||
import { Box, Button, Group } from "@mantine/core";
|
import { Box, Button, Group } from "@mantine/core";
|
||||||
@@ -33,6 +33,7 @@ export interface ConfirmModalProps {
|
|||||||
|
|
||||||
export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
|
export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
|
||||||
({ actions, innerProps }) => {
|
({ actions, innerProps }) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const {
|
const {
|
||||||
children,
|
children,
|
||||||
@@ -65,10 +66,12 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
|
|||||||
|
|
||||||
const handleConfirm = useCallback(
|
const handleConfirm = useCallback(
|
||||||
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
async (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
setLoading(true);
|
||||||
typeof confirmProps?.onClick === "function" &&
|
typeof confirmProps?.onClick === "function" &&
|
||||||
confirmProps?.onClick(event);
|
confirmProps?.onClick(event);
|
||||||
typeof onConfirm === "function" && (await onConfirm());
|
typeof onConfirm === "function" && (await onConfirm());
|
||||||
closeOnConfirm && actions.closeModal();
|
closeOnConfirm && actions.closeModal();
|
||||||
|
setLoading(false);
|
||||||
},
|
},
|
||||||
[confirmProps?.onClick, onConfirm, actions.closeModal],
|
[confirmProps?.onClick, onConfirm, actions.closeModal],
|
||||||
);
|
);
|
||||||
@@ -82,7 +85,12 @@ export const ConfirmModal = createModal<Omit<ConfirmModalProps, "title">>(
|
|||||||
{cancelProps?.children || translateIfNecessary(t, cancelLabel)}
|
{cancelProps?.children || translateIfNecessary(t, cancelLabel)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button {...confirmProps} onClick={handleConfirm} color="red.9">
|
<Button
|
||||||
|
{...confirmProps}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
color="red.9"
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
{confirmProps?.children || translateIfNecessary(t, confirmLabel)}
|
{confirmProps?.children || translateIfNecessary(t, confirmLabel)}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
import { createI18nClient } from "next-international/client";
|
import { createI18nClient } from "next-international/client";
|
||||||
|
|
||||||
import { languageMapping } from "./lang";
|
import { languageMapping } from "./lang";
|
||||||
import en from "./lang/en";
|
import enTranslation from "./lang/en";
|
||||||
|
|
||||||
export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient(
|
export const { useI18n, useScopedI18n, I18nProviderClient } = createI18nClient(
|
||||||
languageMapping(),
|
languageMapping(),
|
||||||
{
|
{
|
||||||
fallbackLocale: en,
|
fallbackLocale: enTranslation,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -29,6 +29,150 @@ export default {
|
|||||||
action: {
|
action: {
|
||||||
login: "Login",
|
login: "Login",
|
||||||
create: "Create user",
|
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: {
|
app: {
|
||||||
@@ -204,11 +348,31 @@ export default {
|
|||||||
save: "Save",
|
save: "Save",
|
||||||
saveChanges: "Save changes",
|
saveChanges: "Save changes",
|
||||||
cancel: "Cancel",
|
cancel: "Cancel",
|
||||||
|
discard: "Discard",
|
||||||
confirm: "Confirm",
|
confirm: "Confirm",
|
||||||
|
continue: "Continue",
|
||||||
previous: "Previous",
|
previous: "Previous",
|
||||||
next: "Next",
|
next: "Next",
|
||||||
checkoutDocs: "Check out the documentation",
|
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: {
|
multiSelect: {
|
||||||
placeholder: "Pick one or more values",
|
placeholder: "Pick one or more values",
|
||||||
},
|
},
|
||||||
@@ -708,8 +872,6 @@ export default {
|
|||||||
permission: {
|
permission: {
|
||||||
userSelect: {
|
userSelect: {
|
||||||
title: "Add user permission",
|
title: "Add user permission",
|
||||||
label: "Select user",
|
|
||||||
notFound: "No user found",
|
|
||||||
},
|
},
|
||||||
field: {
|
field: {
|
||||||
user: {
|
user: {
|
||||||
@@ -803,6 +965,7 @@ export default {
|
|||||||
items: {
|
items: {
|
||||||
manage: "Manage",
|
manage: "Manage",
|
||||||
invites: "Invites",
|
invites: "Invites",
|
||||||
|
groups: "Groups",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tools: {
|
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: {
|
about: {
|
||||||
version: "Version {version}",
|
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.",
|
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 { createI18nServer } from "next-international/server";
|
||||||
|
|
||||||
import { languageMapping } from "./lang";
|
import { languageMapping } from "./lang";
|
||||||
import en from "./lang/en";
|
import enTranslation from "./lang/en";
|
||||||
|
|
||||||
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(
|
export const { getI18n, getScopedI18n, getStaticParams } = createI18nServer(
|
||||||
languageMapping(),
|
languageMapping(),
|
||||||
{
|
{
|
||||||
fallbackLocale: en,
|
fallbackLocale: enTranslation,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"typescript": "^5.4.5"
|
"typescript": "^5.4.5"
|
||||||
},
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@homarr/log": "workspace:^0.1.0"
|
||||||
|
},
|
||||||
"eslintConfig": {
|
"eslintConfig": {
|
||||||
"extends": [
|
"extends": [
|
||||||
"@homarr/eslint-config/base"
|
"@homarr/eslint-config/base"
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
export * from "./count-badge";
|
export * from "./count-badge";
|
||||||
export * from "./select-with-description";
|
export * from "./select-with-description";
|
||||||
export * from "./select-with-description-and-badge";
|
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 { appSchemas } from "./app";
|
||||||
import { boardSchemas } from "./board";
|
import { boardSchemas } from "./board";
|
||||||
|
import { groupSchemas } from "./group";
|
||||||
import { integrationSchemas } from "./integration";
|
import { integrationSchemas } from "./integration";
|
||||||
import { locationSchemas } from "./location";
|
import { locationSchemas } from "./location";
|
||||||
import { userSchemas } from "./user";
|
import { userSchemas } from "./user";
|
||||||
@@ -7,6 +8,7 @@ import { widgetSchemas } from "./widgets";
|
|||||||
|
|
||||||
export const validation = {
|
export const validation = {
|
||||||
user: userSchemas,
|
user: userSchemas,
|
||||||
|
group: groupSchemas,
|
||||||
integration: integrationSchemas,
|
integration: integrationSchemas,
|
||||||
board: boardSchemas,
|
board: boardSchemas,
|
||||||
app: appSchemas,
|
app: appSchemas,
|
||||||
|
|||||||
4
pnpm-lock.yaml
generated
4
pnpm-lock.yaml
generated
@@ -724,6 +724,10 @@ importers:
|
|||||||
version: 5.4.5
|
version: 5.4.5
|
||||||
|
|
||||||
packages/ui:
|
packages/ui:
|
||||||
|
dependencies:
|
||||||
|
'@homarr/log':
|
||||||
|
specifier: workspace:^0.1.0
|
||||||
|
version: link:../log
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@homarr/eslint-config':
|
'@homarr/eslint-config':
|
||||||
specifier: workspace:^0.2.0
|
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
|
# Run migrations
|
||||||
node ./db/migrate.cjs ./db/migrations
|
node ./db/migrate.cjs ./db/migrations/sqlite
|
||||||
|
|
||||||
# Start Redis
|
# Start Redis
|
||||||
redis-server &
|
redis-server &
|
||||||
|
|||||||
Reference in New Issue
Block a user