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:
Meier Lukas
2024-04-29 12:09:34 +02:00
committed by GitHub
parent 16e42d654d
commit 621f6c81ae
20 changed files with 1506 additions and 59 deletions

View File

@@ -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";

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />
</>
);

View File

@@ -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")}

View File

@@ -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>
)}

View File

@@ -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 }));
};

View File

@@ -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");
},
});

View File

@@ -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>
);
};

View File

@@ -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} />;
}

View File

@@ -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")}
/>

View File

@@ -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,

View 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));
}),
});

View 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");
});
});

View 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`);

View 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": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1710878250235,
"tag": "0000_productive_changeling",
"breakpoints": true
},
{
"idx": 1,
"version": "5",
"when": 1712777046680,
"tag": "0001_sparkling_zaran",
"breakpoints": true
}
]
}

View File

@@ -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 }) => ({

View File

@@ -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 }) => ({

View File

@@ -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",
},
},
},
},