mirror of
https://github.com/ajnart/homarr.git
synced 2026-02-26 16:30:57 +01:00
feat: add user invite management (#338)
* feat: add invite management page * refactor: improve existing translations * test: add test for invite router * feat: update mysql schema to match sqlite schema * fix: format issues * fix: deepsource issues * fix: lint issues * chore: address pull request feedback
This commit is contained in:
@@ -10,10 +10,8 @@ import {
|
||||
import { useForm } from "@homarr/form";
|
||||
import type { TranslationObject } from "@homarr/translation";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import {
|
||||
SelectItemWithDescriptionBadge,
|
||||
SelectWithDescriptionBadge,
|
||||
} from "@homarr/ui";
|
||||
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
|
||||
import { SelectWithDescriptionBadge } from "@homarr/ui";
|
||||
|
||||
import type { Board } from "../../_types";
|
||||
import { useSavePartialSettingsMutation } from "./_shared";
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button, Stack, TextInput } from "@mantine/core";
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm, zodResolver } from "@homarr/form";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { validation } from "@homarr/validation";
|
||||
|
||||
import { revalidatePathAction } from "~/app/revalidatePathAction";
|
||||
@@ -15,7 +15,7 @@ interface ProfileAccordionProps {
|
||||
}
|
||||
|
||||
export const ProfileAccordion = ({ user }: ProfileAccordionProps) => {
|
||||
const t = useScopedI18n("management.page.user.edit.section.profile");
|
||||
const t = useI18n();
|
||||
const { mutate, isPending } = clientApi.user.editProfile.useMutation({
|
||||
onSettled: async () => {
|
||||
await revalidatePathAction("/manage/users");
|
||||
@@ -42,16 +42,16 @@ export const ProfileAccordion = ({ user }: ProfileAccordionProps) => {
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t("form.username.label")}
|
||||
label={t("user.field.username.label")}
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<TextInput
|
||||
label={t("form.email.label")}
|
||||
label={t("user.field.email.label")}
|
||||
{...form.getInputProps("email")}
|
||||
/>
|
||||
<Button type="submit" disabled={!form.isValid()} loading={isPending}>
|
||||
Submit
|
||||
{t("common.action.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</form>
|
||||
|
||||
@@ -66,9 +66,7 @@ const ChangePasswordForm = ({
|
||||
)}
|
||||
</Title>
|
||||
<PasswordInput
|
||||
label={t(
|
||||
"management.page.user.edit.section.security.changePassword.form.password.label",
|
||||
)}
|
||||
label={t("user.field.password.label")}
|
||||
{...form.getInputProps("password")}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
interface UserListComponentProps {
|
||||
initialUserList: RouterOutputs["user"]["getAll"];
|
||||
@@ -18,7 +18,8 @@ interface UserListComponentProps {
|
||||
export const UserListComponent = ({
|
||||
initialUserList,
|
||||
}: UserListComponentProps) => {
|
||||
const t = useScopedI18n("management.page.user.list");
|
||||
const tUserList = useScopedI18n("management.page.user.list");
|
||||
const t = useI18n();
|
||||
const { data, isLoading } = clientApi.user.getAll.useQuery(undefined, {
|
||||
initialData: initialUserList,
|
||||
});
|
||||
@@ -29,7 +30,7 @@ export const UserListComponent = ({
|
||||
() => [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: "Name",
|
||||
header: t("user.field.username.label"),
|
||||
grow: 100,
|
||||
Cell: ({ renderedCellValue, row }) => (
|
||||
<Link href={`/manage/users/${row.original.id}`}>
|
||||
@@ -42,7 +43,7 @@ export const UserListComponent = ({
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
header: t("user.field.email.label"),
|
||||
Cell: ({ renderedCellValue, row }) => (
|
||||
<Group>
|
||||
{row.original.email ? renderedCellValue : <Text>-</Text>}
|
||||
@@ -55,7 +56,7 @@ export const UserListComponent = ({
|
||||
),
|
||||
},
|
||||
],
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const table = useMantineReactTable({
|
||||
@@ -75,13 +76,13 @@ export const UserListComponent = ({
|
||||
</Button>
|
||||
),
|
||||
state: {
|
||||
isLoading: isLoading,
|
||||
isLoading,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title mb="md">{t("title")}</Title>
|
||||
<Title mb="md">{tUserList("title")}</Title>
|
||||
<MantineReactTable table={table} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,7 @@ import { StepperNavigationComponent } from "./stepper-navigation.component";
|
||||
|
||||
export const UserCreateStepperComponent = () => {
|
||||
const t = useScopedI18n("management.page.user.create");
|
||||
const tUserField = useScopedI18n("user.field");
|
||||
|
||||
const stepperMax = 4;
|
||||
const [active, setActive] = useState(0);
|
||||
@@ -122,14 +123,14 @@ export const UserCreateStepperComponent = () => {
|
||||
<Card p="xl">
|
||||
<Stack gap="md">
|
||||
<TextInput
|
||||
label={t("step.personalInformation.field.username.label")}
|
||||
label={tUserField("username.label")}
|
||||
variant="filled"
|
||||
withAsterisk
|
||||
{...generalForm.getInputProps("username")}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label={t("step.personalInformation.field.email.label")}
|
||||
label={tUserField("email.label")}
|
||||
variant="filled"
|
||||
{...generalForm.getInputProps("email")}
|
||||
/>
|
||||
@@ -146,13 +147,13 @@ export const UserCreateStepperComponent = () => {
|
||||
<Card p="xl">
|
||||
<Stack gap="md">
|
||||
<PasswordInput
|
||||
label={t("step.security.field.password.label")}
|
||||
label={tUserField("password.label")}
|
||||
variant="filled"
|
||||
withAsterisk
|
||||
{...securityForm.getInputProps("password")}
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t("step.security.field.confirmPassword.label")}
|
||||
label={tUserField("passwordConfirm.label")}
|
||||
variant="filled"
|
||||
withAsterisk
|
||||
{...securityForm.getInputProps("confirmPassword")}
|
||||
|
||||
@@ -56,14 +56,14 @@ export const StepperNavigationComponent = ({
|
||||
leftSection={<IconRotate size="1rem" />}
|
||||
onClick={reset}
|
||||
>
|
||||
{t("management.page.user.create.buttons.createAnother")}
|
||||
{t("management.page.user.create.action.createAnother")}
|
||||
</Button>
|
||||
<Button
|
||||
leftSection={<IconArrowBackUp size="1rem" />}
|
||||
component={Link}
|
||||
href="/manage/users"
|
||||
>
|
||||
{t("management.page.user.create.buttons.return")}
|
||||
{t("management.page.user.create.action.back")}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Button, CopyButton, Mark, Stack, Text } from "@mantine/core";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
export const InviteCopyModal = createModal<
|
||||
RouterOutputs["invite"]["createInvite"]
|
||||
>(({ actions, innerProps }) => {
|
||||
const t = useScopedI18n("management.page.user.invite");
|
||||
const inviteUrl = useInviteUrl(innerProps);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Text>{t("action.copy.description")}</Text>
|
||||
{/* TODO: When next-international v2 is released the descriptions bold element can be implemented, see https://github.com/QuiiBz/next-international/pull/361 for progress */}
|
||||
<Link href={createPath(innerProps)}>{t("action.copy.link")}</Link>
|
||||
<Stack gap="xs">
|
||||
<Text fw="bold">{t("field.id.label")}:</Text>
|
||||
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
|
||||
{innerProps.id}
|
||||
</Mark>
|
||||
|
||||
<Text fw="bold">{t("field.token.label")}:</Text>
|
||||
<Mark style={{ borderRadius: 4 }} color="gray" px={5}>
|
||||
{innerProps.token}
|
||||
</Mark>
|
||||
</Stack>
|
||||
<CopyButton value={inviteUrl}>
|
||||
{({ copy }) => (
|
||||
<Button
|
||||
onClick={() => {
|
||||
copy();
|
||||
actions.closeModal();
|
||||
}}
|
||||
variant="default"
|
||||
fullWidth
|
||||
>
|
||||
{t("action.copy.button")}
|
||||
</Button>
|
||||
)}
|
||||
</CopyButton>
|
||||
</Stack>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("management.page.user.invite.action.copy.title");
|
||||
},
|
||||
});
|
||||
|
||||
const createPath = ({ id, token }: RouterOutputs["invite"]["createInvite"]) =>
|
||||
`/auth/invite/${id}?token=${token}`;
|
||||
|
||||
const useInviteUrl = ({
|
||||
id,
|
||||
token,
|
||||
}: RouterOutputs["invite"]["createInvite"]) => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return window.location.href.replace(pathname, createPath({ id, token }));
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import React from "react";
|
||||
import { Button, Group, Stack, Text } from "@mantine/core";
|
||||
import { DateTimePicker } from "@mantine/dates";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal, useModalAction } from "@homarr/modals";
|
||||
import { useI18n, useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InviteCopyModal } from "./invite-copy-modal";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface FormType {
|
||||
expirationDate: Date;
|
||||
}
|
||||
|
||||
export const InviteCreateModal = createModal<void>(({ actions }) => {
|
||||
const tInvite = useScopedI18n("management.page.user.invite");
|
||||
const t = useI18n();
|
||||
const { openModal } = useModalAction(InviteCopyModal);
|
||||
|
||||
const utils = clientApi.useUtils();
|
||||
const { mutate, isPending } = clientApi.invite.createInvite.useMutation();
|
||||
const minDate = dayjs().add(1, "hour").toDate();
|
||||
const maxDate = dayjs().add(6, "months").toDate();
|
||||
|
||||
const form = useForm<FormType>({
|
||||
initialValues: {
|
||||
expirationDate: dayjs().add(4, "hours").toDate(),
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (values: FormType) => {
|
||||
mutate(values, {
|
||||
onSuccess: (result) => {
|
||||
void utils.invite.getAll.invalidate();
|
||||
actions.closeModal();
|
||||
openModal(result);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack>
|
||||
<Text>{tInvite("action.new.description")}</Text>
|
||||
|
||||
<DateTimePicker
|
||||
popoverProps={{ withinPortal: true }}
|
||||
minDate={minDate}
|
||||
maxDate={maxDate}
|
||||
withAsterisk
|
||||
valueFormat="DD MMM YYYY HH:mm"
|
||||
label={tInvite("field.expirationDate.label")}
|
||||
variant="filled"
|
||||
{...form.getInputProps("expirationDate")}
|
||||
/>
|
||||
|
||||
<Group justify="end">
|
||||
<Button onClick={actions.closeModal} variant="subtle" color="gray">
|
||||
{t("common.action.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={isPending} color="teal">
|
||||
{t("common.action.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}).withOptions({
|
||||
defaultTitle(t) {
|
||||
return t("management.page.user.invite.action.new.title");
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { ActionIcon, Button, Title } from "@mantine/core";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import type { MRT_ColumnDef, MRT_Row } from "mantine-react-table";
|
||||
import { MantineReactTable, useMantineReactTable } from "mantine-react-table";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useConfirmModal, useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
import { InviteCreateModal } from "./invite-create-modal";
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface InviteListComponentProps {
|
||||
initialInvites: RouterOutputs["invite"]["getAll"];
|
||||
}
|
||||
|
||||
export const InviteListComponent = ({
|
||||
initialInvites,
|
||||
}: InviteListComponentProps) => {
|
||||
const t = useScopedI18n("management.page.user.invite");
|
||||
const { data, isLoading } = clientApi.invite.getAll.useQuery(undefined, {
|
||||
initialData: initialInvites,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
||||
const columns = useMemo<
|
||||
MRT_ColumnDef<RouterOutputs["invite"]["getAll"][number]>[]
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: t("field.id.label"),
|
||||
grow: 100,
|
||||
Cell: ({ renderedCellValue }) => renderedCellValue,
|
||||
},
|
||||
{
|
||||
accessorKey: "creator",
|
||||
header: t("field.creator.label"),
|
||||
Cell: ({ row }) => row.original.creator.name,
|
||||
},
|
||||
{
|
||||
accessorKey: "expirationDate",
|
||||
header: t("field.expirationDate.label"),
|
||||
Cell: ({ row }) => dayjs(row.original.expirationDate).fromNow(false),
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const table = useMantineReactTable({
|
||||
columns,
|
||||
data,
|
||||
positionActionsColumn: "last",
|
||||
renderRowActions: RenderRowActions,
|
||||
enableRowSelection: true,
|
||||
enableColumnOrdering: true,
|
||||
enableGlobalFilter: false,
|
||||
enableRowActions: true,
|
||||
enableDensityToggle: false,
|
||||
enableFullScreenToggle: false,
|
||||
layoutMode: "grid-no-grow",
|
||||
getRowId: (row) => row.id,
|
||||
renderTopToolbarCustomActions: RenderTopToolbarCustomActions,
|
||||
state: {
|
||||
isLoading,
|
||||
},
|
||||
initialState: {
|
||||
sorting: [{ id: "expirationDate", desc: false }],
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title mb="md">{t("title")}</Title>
|
||||
<MantineReactTable table={table} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderTopToolbarCustomActions = () => {
|
||||
const t = useScopedI18n("management.page.user.invite");
|
||||
const { openModal } = useModalAction(InviteCreateModal);
|
||||
const handleNewInvite = useCallback(() => {
|
||||
openModal();
|
||||
}, [openModal]);
|
||||
|
||||
return (
|
||||
<Button color="teal" onClick={handleNewInvite}>
|
||||
{t("action.new.title")}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
const RenderRowActions = ({
|
||||
row,
|
||||
}: {
|
||||
row: MRT_Row<RouterOutputs["invite"]["getAll"][number]>;
|
||||
}) => {
|
||||
const t = useScopedI18n("management.page.user.invite");
|
||||
const { mutate, isPending } = clientApi.invite.deleteInvite.useMutation();
|
||||
const utils = clientApi.useUtils();
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const handleDelete = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title: t("action.delete.title"),
|
||||
children: t("action.delete.description"),
|
||||
onConfirm: () => {
|
||||
mutate({ id: row.original.id });
|
||||
void utils.invite.getAll.invalidate();
|
||||
},
|
||||
});
|
||||
}, [openConfirmModal, row.original.id, mutate, utils, t]);
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={handleDelete}
|
||||
loading={isPending}
|
||||
>
|
||||
<IconTrash color="red" size={20} stroke={1.5} />
|
||||
</ActionIcon>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { api } from "@homarr/api/server";
|
||||
|
||||
import { InviteListComponent } from "./_components/invite-list";
|
||||
|
||||
export default async function InvitesOverviewPage() {
|
||||
const initialInvites = await api.invite.getAll();
|
||||
return <InviteListComponent initialInvites={initialInvites} />;
|
||||
}
|
||||
@@ -38,9 +38,7 @@ export const AddBoardModal = createModal<InnerProps>(
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t(
|
||||
"management.page.board.modal.createBoard.field.name.label",
|
||||
)}
|
||||
label={t("board.field.name.label")}
|
||||
data-autofocus
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { appRouter as innerAppRouter } from "./router/app";
|
||||
import { boardRouter } from "./router/board";
|
||||
import { integrationRouter } from "./router/integration";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { locationRouter } from "./router/location";
|
||||
import { logRouter } from "./router/log";
|
||||
import { userRouter } from "./router/user";
|
||||
@@ -9,6 +10,7 @@ import { createTRPCRouter } from "./trpc";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
user: userRouter,
|
||||
invite: inviteRouter,
|
||||
integration: integrationRouter,
|
||||
board: boardRouter,
|
||||
app: innerAppRouter,
|
||||
|
||||
70
packages/api/src/router/invite.ts
Normal file
70
packages/api/src/router/invite.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
import { asc, createId, eq } from "@homarr/db";
|
||||
import { invites } from "@homarr/db/schema/sqlite";
|
||||
import { z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const inviteRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
const dbInvites = await ctx.db.query.invites.findMany({
|
||||
orderBy: asc(invites.expirationDate),
|
||||
columns: {
|
||||
token: false,
|
||||
},
|
||||
with: {
|
||||
creator: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return dbInvites;
|
||||
}),
|
||||
createInvite: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
expirationDate: z.date(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const id = createId();
|
||||
const token = randomBytes(20).toString("hex");
|
||||
|
||||
await ctx.db.insert(invites).values({
|
||||
id,
|
||||
expirationDate: input.expirationDate,
|
||||
creatorId: ctx.session.user.id,
|
||||
token,
|
||||
});
|
||||
|
||||
return {
|
||||
id,
|
||||
token,
|
||||
};
|
||||
}),
|
||||
deleteInvite: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const dbInvite = await ctx.db.query.invites.findFirst({
|
||||
where: eq(invites.id, input.id),
|
||||
});
|
||||
|
||||
if (!dbInvite) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invite not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(invites).where(eq(invites.id, input.id));
|
||||
}),
|
||||
});
|
||||
190
packages/api/src/router/test/invite.spec.ts
Normal file
190
packages/api/src/router/test/invite.spec.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import { createId } from "@homarr/db";
|
||||
import { invites, users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
|
||||
import { inviteRouter } from "../invite";
|
||||
|
||||
const defaultSession = {
|
||||
user: {
|
||||
id: createId(),
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 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("all should return all existing invites without sensitive informations", () => {
|
||||
test("invites should not contain sensitive informations", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = inviteRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const userId = createId();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: "someone",
|
||||
});
|
||||
|
||||
const inviteId = createId();
|
||||
await db.insert(invites).values({
|
||||
id: inviteId,
|
||||
creatorId: userId,
|
||||
expirationDate: new Date(2022, 5, 1),
|
||||
token: "token",
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await caller.getAll();
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0]?.id).toBe(inviteId);
|
||||
expect(result[0]?.expirationDate).toEqual(new Date(2022, 5, 1));
|
||||
expect(result[0]?.creator.id).toBe(userId);
|
||||
expect(result[0]?.creator.name).toBe("someone");
|
||||
expect("token" in result[0]!).toBe(false);
|
||||
});
|
||||
|
||||
test("invites should be sorted ascending by expiration date", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = inviteRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const userId = createId();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: "someone",
|
||||
});
|
||||
|
||||
const inviteId = createId();
|
||||
await db.insert(invites).values({
|
||||
id: inviteId,
|
||||
creatorId: userId,
|
||||
expirationDate: new Date(2022, 5, 1),
|
||||
token: "token",
|
||||
});
|
||||
await db.insert(invites).values({
|
||||
id: createId(),
|
||||
creatorId: userId,
|
||||
expirationDate: new Date(2022, 5, 2),
|
||||
token: "token2",
|
||||
});
|
||||
|
||||
// Act
|
||||
const result = await caller.getAll();
|
||||
|
||||
// Assert
|
||||
expect(result.length).toBe(2);
|
||||
expect(result[0]?.expirationDate.getDate()).toBe(1);
|
||||
expect(result[1]?.expirationDate.getDate()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create should create a new invite expiring on the specified date with a token and id returned to generate url", () => {
|
||||
test("creation should work with a date in the future, but less than 6 months.", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = inviteRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
await db.insert(users).values({
|
||||
id: defaultSession.user.id,
|
||||
});
|
||||
const expirationDate = new Date(2024, 5, 1); // TODO: add mock date
|
||||
|
||||
// Act
|
||||
const result = await caller.createInvite({
|
||||
expirationDate,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(result.id.length).toBeGreaterThan(10);
|
||||
expect(result.token.length).toBeGreaterThan(20);
|
||||
|
||||
const createdInvite = await db.query.invites.findFirst();
|
||||
expect(createdInvite).toBeDefined();
|
||||
expect(createdInvite?.id).toBe(result.id);
|
||||
expect(createdInvite?.token).toBe(result.token);
|
||||
expect(createdInvite?.expirationDate).toEqual(expirationDate);
|
||||
expect(createdInvite?.creatorId).toBe(defaultSession.user.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete should remove invite by id", () => {
|
||||
test("deletion should remove present invite", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = inviteRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const userId = createId();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
});
|
||||
const inviteId = createId();
|
||||
await db.insert(invites).values([
|
||||
{
|
||||
id: createId(),
|
||||
creatorId: userId,
|
||||
expirationDate: new Date(2023, 1, 1),
|
||||
token: "first-token",
|
||||
},
|
||||
{
|
||||
id: inviteId,
|
||||
creatorId: userId,
|
||||
expirationDate: new Date(2023, 1, 1),
|
||||
token: "second-token",
|
||||
},
|
||||
]);
|
||||
|
||||
// Act
|
||||
await caller.deleteInvite({ id: inviteId });
|
||||
|
||||
// Assert
|
||||
const dbInvites = await db.query.invites.findMany();
|
||||
expect(dbInvites.length).toBe(1);
|
||||
expect(dbInvites[0]?.id).not.toBe(inviteId);
|
||||
});
|
||||
|
||||
test("deletion should throw with NOT_FOUND code when specified invite not present", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = inviteRouter.createCaller({
|
||||
db,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const userId = createId();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
});
|
||||
await db.insert(invites).values({
|
||||
id: createId(),
|
||||
creatorId: userId,
|
||||
expirationDate: new Date(2023, 1, 1),
|
||||
token: "first-token",
|
||||
});
|
||||
|
||||
// Act
|
||||
const act = async () => await caller.deleteInvite({ id: createId() });
|
||||
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrow("not found");
|
||||
});
|
||||
});
|
||||
9
packages/db/migrations/0001_sparkling_zaran.sql
Normal file
9
packages/db/migrations/0001_sparkling_zaran.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
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`);
|
||||
846
packages/db/migrations/meta/0001_snapshot.json
Normal file
846
packages/db/migrations/meta/0001_snapshot.json
Normal file
@@ -0,0 +1,846 @@
|
||||
{
|
||||
"version": "5",
|
||||
"dialect": "sqlite",
|
||||
"id": "c0a91279-dffa-4567-8cd2-d9d2d1a2e77c",
|
||||
"prevId": "7c2291ee-febd-4b90-994c-85e6ef27102d",
|
||||
"tables": {
|
||||
"account": {
|
||||
"name": "account",
|
||||
"columns": {
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider": {
|
||||
"name": "provider",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"providerAccountId": {
|
||||
"name": "providerAccountId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token_type": {
|
||||
"name": "token_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_state": {
|
||||
"name": "session_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"userId_idx": {
|
||||
"name": "userId_idx",
|
||||
"columns": ["userId"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"account_userId_user_id_fk": {
|
||||
"name": "account_userId_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["userId"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"account_provider_providerAccountId_pk": {
|
||||
"columns": ["provider", "providerAccountId"],
|
||||
"name": "account_provider_providerAccountId_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"app": {
|
||||
"name": "app",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"icon_url": {
|
||||
"name": "icon_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"href": {
|
||||
"name": "href",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"boardPermission": {
|
||||
"name": "boardPermission",
|
||||
"columns": {
|
||||
"board_id": {
|
||||
"name": "board_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"permission": {
|
||||
"name": "permission",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"boardPermission_board_id_board_id_fk": {
|
||||
"name": "boardPermission_board_id_board_id_fk",
|
||||
"tableFrom": "boardPermission",
|
||||
"tableTo": "board",
|
||||
"columnsFrom": ["board_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"boardPermission_user_id_user_id_fk": {
|
||||
"name": "boardPermission_user_id_user_id_fk",
|
||||
"tableFrom": "boardPermission",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"boardPermission_board_id_user_id_permission_pk": {
|
||||
"columns": ["board_id", "permission", "user_id"],
|
||||
"name": "boardPermission_board_id_user_id_permission_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"board": {
|
||||
"name": "board",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_public": {
|
||||
"name": "is_public",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"page_title": {
|
||||
"name": "page_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"meta_title": {
|
||||
"name": "meta_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"logo_image_url": {
|
||||
"name": "logo_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"favicon_image_url": {
|
||||
"name": "favicon_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"background_image_url": {
|
||||
"name": "background_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"background_image_attachment": {
|
||||
"name": "background_image_attachment",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'fixed'"
|
||||
},
|
||||
"background_image_repeat": {
|
||||
"name": "background_image_repeat",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'no-repeat'"
|
||||
},
|
||||
"background_image_size": {
|
||||
"name": "background_image_size",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'cover'"
|
||||
},
|
||||
"primary_color": {
|
||||
"name": "primary_color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'#fa5252'"
|
||||
},
|
||||
"secondary_color": {
|
||||
"name": "secondary_color",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'#fd7e14'"
|
||||
},
|
||||
"opacity": {
|
||||
"name": "opacity",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 100
|
||||
},
|
||||
"custom_css": {
|
||||
"name": "custom_css",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"column_count": {
|
||||
"name": "column_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"board_name_unique": {
|
||||
"name": "board_name_unique",
|
||||
"columns": ["name"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"board_creator_id_user_id_fk": {
|
||||
"name": "board_creator_id_user_id_fk",
|
||||
"tableFrom": "board",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["creator_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"integration_item": {
|
||||
"name": "integration_item",
|
||||
"columns": {
|
||||
"item_id": {
|
||||
"name": "item_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"integration_id": {
|
||||
"name": "integration_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"integration_item_item_id_item_id_fk": {
|
||||
"name": "integration_item_item_id_item_id_fk",
|
||||
"tableFrom": "integration_item",
|
||||
"tableTo": "item",
|
||||
"columnsFrom": ["item_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
"integration_item_integration_id_integration_id_fk": {
|
||||
"name": "integration_item_integration_id_integration_id_fk",
|
||||
"tableFrom": "integration_item",
|
||||
"tableTo": "integration",
|
||||
"columnsFrom": ["integration_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integration_item_item_id_integration_id_pk": {
|
||||
"columns": ["integration_id", "item_id"],
|
||||
"name": "integration_item_item_id_integration_id_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"integrationSecret": {
|
||||
"name": "integrationSecret",
|
||||
"columns": {
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"integration_id": {
|
||||
"name": "integration_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"integration_secret__kind_idx": {
|
||||
"name": "integration_secret__kind_idx",
|
||||
"columns": ["kind"],
|
||||
"isUnique": false
|
||||
},
|
||||
"integration_secret__updated_at_idx": {
|
||||
"name": "integration_secret__updated_at_idx",
|
||||
"columns": ["updated_at"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"integrationSecret_integration_id_integration_id_fk": {
|
||||
"name": "integrationSecret_integration_id_integration_id_fk",
|
||||
"tableFrom": "integrationSecret",
|
||||
"tableTo": "integration",
|
||||
"columnsFrom": ["integration_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {
|
||||
"integrationSecret_integration_id_kind_pk": {
|
||||
"columns": ["integration_id", "kind"],
|
||||
"name": "integrationSecret_integration_id_kind_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"integration": {
|
||||
"name": "integration",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"url": {
|
||||
"name": "url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"integration__kind_idx": {
|
||||
"name": "integration__kind_idx",
|
||||
"columns": ["kind"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"invite": {
|
||||
"name": "invite",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expiration_date": {
|
||||
"name": "expiration_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"creator_id": {
|
||||
"name": "creator_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"invite_token_unique": {
|
||||
"name": "invite_token_unique",
|
||||
"columns": ["token"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"item": {
|
||||
"name": "item",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"section_id": {
|
||||
"name": "section_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"x_offset": {
|
||||
"name": "x_offset",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"y_offset": {
|
||||
"name": "y_offset",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"width": {
|
||||
"name": "width",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"height": {
|
||||
"name": "height",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"options": {
|
||||
"name": "options",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'{\"json\": {}}'"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"item_section_id_section_id_fk": {
|
||||
"name": "item_section_id_section_id_fk",
|
||||
"tableFrom": "item",
|
||||
"tableTo": "section",
|
||||
"columnsFrom": ["section_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"section": {
|
||||
"name": "section",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"board_id": {
|
||||
"name": "board_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"position": {
|
||||
"name": "position",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"section_board_id_board_id_fk": {
|
||||
"name": "section_board_id_board_id_fk",
|
||||
"tableFrom": "section",
|
||||
"tableTo": "board",
|
||||
"columnsFrom": ["board_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"session": {
|
||||
"name": "session",
|
||||
"columns": {
|
||||
"sessionToken": {
|
||||
"name": "sessionToken",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"userId": {
|
||||
"name": "userId",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"user_id_idx": {
|
||||
"name": "user_id_idx",
|
||||
"columns": ["userId"],
|
||||
"isUnique": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"session_userId_user_id_fk": {
|
||||
"name": "session_userId_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": ["userId"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"user": {
|
||||
"name": "user",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"emailVerified": {
|
||||
"name": "emailVerified",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"salt": {
|
||||
"name": "salt",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {}
|
||||
},
|
||||
"verificationToken": {
|
||||
"name": "verificationToken",
|
||||
"columns": {
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expires": {
|
||||
"name": "expires",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {
|
||||
"verificationToken_identifier_token_pk": {
|
||||
"columns": ["identifier", "token"],
|
||||
"name": "verificationToken_identifier_token_pk"
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {}
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,13 @@
|
||||
"when": 1710878250235,
|
||||
"tag": "0000_productive_changeling",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "5",
|
||||
"when": 1712777046680,
|
||||
"tag": "0001_sparkling_zaran",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -92,6 +92,15 @@ export const verificationTokens = mysqlTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const invites = mysqlTable("invite", {
|
||||
id: varchar("id", { length: 256 }).notNull().primaryKey(),
|
||||
token: varchar("token", { length: 512 }).notNull().unique(),
|
||||
expirationDate: timestamp("expiration_date").notNull(),
|
||||
creatorId: varchar("creator_id", { length: 256 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const integrations = mysqlTable(
|
||||
"integration",
|
||||
{
|
||||
@@ -236,6 +245,14 @@ export const userRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts),
|
||||
boards: many(boards),
|
||||
boardPermissions: many(boardPermissions),
|
||||
invites: many(invites),
|
||||
}));
|
||||
|
||||
export const inviteRelations = relations(invites, ({ one }) => ({
|
||||
creator: one(users, {
|
||||
fields: [invites.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(sessions, ({ one }) => ({
|
||||
|
||||
@@ -89,6 +89,17 @@ export const verificationTokens = sqliteTable(
|
||||
}),
|
||||
);
|
||||
|
||||
export const invites = sqliteTable("invite", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
token: text("token").notNull().unique(),
|
||||
expirationDate: int("expiration_date", {
|
||||
mode: "timestamp",
|
||||
}).notNull(),
|
||||
creatorId: text("creator_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
});
|
||||
|
||||
export const integrations = sqliteTable(
|
||||
"integration",
|
||||
{
|
||||
@@ -231,6 +242,14 @@ export const userRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts),
|
||||
boards: many(boards),
|
||||
boardPermissions: many(boardPermissions),
|
||||
invites: many(invites),
|
||||
}));
|
||||
|
||||
export const inviteRelations = relations(invites, ({ one }) => ({
|
||||
creator: one(users, {
|
||||
fields: [invites.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const sessionRelations = relations(sessions, ({ one }) => ({
|
||||
|
||||
@@ -13,6 +13,9 @@ export default {
|
||||
},
|
||||
},
|
||||
field: {
|
||||
email: {
|
||||
label: "E-Mail",
|
||||
},
|
||||
username: {
|
||||
label: "Username",
|
||||
},
|
||||
@@ -866,14 +869,6 @@ export default {
|
||||
section: {
|
||||
profile: {
|
||||
title: "Profile",
|
||||
form: {
|
||||
username: {
|
||||
label: "Username",
|
||||
},
|
||||
email: {
|
||||
label: "E-Mail",
|
||||
},
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
title: "Preferences",
|
||||
@@ -882,11 +877,6 @@ export default {
|
||||
title: "Security",
|
||||
changePassword: {
|
||||
title: "Change password",
|
||||
form: {
|
||||
password: {
|
||||
label: "Password",
|
||||
},
|
||||
},
|
||||
message: {
|
||||
passwordUpdated: "Updated password",
|
||||
},
|
||||
@@ -911,25 +901,9 @@ export default {
|
||||
step: {
|
||||
personalInformation: {
|
||||
label: "Personal information",
|
||||
field: {
|
||||
username: {
|
||||
label: "Username",
|
||||
},
|
||||
email: {
|
||||
label: "E-Mail",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: {
|
||||
label: "Security",
|
||||
field: {
|
||||
password: {
|
||||
label: "Password",
|
||||
},
|
||||
confirmPassword: {
|
||||
label: "Confirm password",
|
||||
},
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
label: "Permissions",
|
||||
@@ -942,9 +916,45 @@ export default {
|
||||
title: "User created",
|
||||
},
|
||||
},
|
||||
buttons: {
|
||||
action: {
|
||||
createAnother: "Create another user",
|
||||
return: "Return to the user list",
|
||||
back: "Return to the user list",
|
||||
},
|
||||
},
|
||||
invite: {
|
||||
title: "Manager user invites",
|
||||
action: {
|
||||
new: {
|
||||
title: "New invite",
|
||||
description:
|
||||
"After the expiration, an invite will no longer be valid and the recipient of the invite won't be able to create an account.",
|
||||
},
|
||||
copy: {
|
||||
title: "Copy invite",
|
||||
description:
|
||||
"Your invitation has been generated. After this modal closes, <b>you'll not be able to copy this link anymore</b>. If you do no longer wish to invite said person, you can delete this invitation any time.",
|
||||
link: "Invitation link",
|
||||
button: "Copy & close",
|
||||
},
|
||||
delete: {
|
||||
title: "Delete invite",
|
||||
description:
|
||||
"Are you sure, that you want to delete this invitation? Users with this link will no longer be able to create an account using that link.",
|
||||
},
|
||||
},
|
||||
field: {
|
||||
id: {
|
||||
label: "ID",
|
||||
},
|
||||
creator: {
|
||||
label: "Creator",
|
||||
},
|
||||
expirationDate: {
|
||||
label: "Expiration date",
|
||||
},
|
||||
token: {
|
||||
label: "Token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user