diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx index b87161268..08c936336 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_background.tsx @@ -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"; diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/profile.accordion.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/profile.accordion.tsx index 831a64a40..00a65d0ea 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/profile.accordion.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/profile.accordion.tsx @@ -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) => {
diff --git a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/security.accordion.tsx b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/security.accordion.tsx index aaeecaae5..6bbae085a 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/security.accordion.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/[userId]/_components/security.accordion.tsx @@ -66,9 +66,7 @@ const ChangePasswordForm = ({ )} diff --git a/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.component.tsx b/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.component.tsx index ce7d771a6..4bda7a355 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.component.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/_components/user-list.component.tsx @@ -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 }) => ( @@ -42,7 +43,7 @@ export const UserListComponent = ({ }, { accessorKey: "email", - header: "Email", + header: t("user.field.email.label"), Cell: ({ renderedCellValue, row }) => ( {row.original.email ? renderedCellValue : -} @@ -55,7 +56,7 @@ export const UserListComponent = ({ ), }, ], - [], + [t], ); const table = useMantineReactTable({ @@ -75,13 +76,13 @@ export const UserListComponent = ({ ), state: { - isLoading: isLoading, + isLoading, }, }); return ( <> - {t("title")} + {tUserList("title")} ); diff --git a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx index 654443af8..c1d903110 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/create/_components/create-user-stepper.tsx @@ -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 = () => { @@ -146,13 +147,13 @@ export const UserCreateStepperComponent = () => { } onClick={reset} > - {t("management.page.user.create.buttons.createAnother")} + {t("management.page.user.create.action.createAnother")} )} diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-copy-modal.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-copy-modal.tsx new file mode 100644 index 000000000..359402cf4 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-copy-modal.tsx @@ -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 ( + + {t("action.copy.description")} + {/* 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 */} + {t("action.copy.link")} + + {t("field.id.label")}: + + {innerProps.id} + + + {t("field.token.label")}: + + {innerProps.token} + + + + {({ copy }) => ( + + )} + + + ); +}).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 })); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-modal.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-modal.tsx new file mode 100644 index 000000000..b211745ca --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-create-modal.tsx @@ -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(({ 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({ + 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 ( +
+ + {tInvite("action.new.description")} + + + + + + + + +
+ ); +}).withOptions({ + defaultTitle(t) { + return t("management.page.user.invite.action.new.title"); + }, +}); diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx new file mode 100644 index 000000000..8a512818e --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/_components/invite-list.tsx @@ -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[] + >( + () => [ + { + 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 ( + <> + {t("title")} + + + ); +}; + +const RenderTopToolbarCustomActions = () => { + const t = useScopedI18n("management.page.user.invite"); + const { openModal } = useModalAction(InviteCreateModal); + const handleNewInvite = useCallback(() => { + openModal(); + }, [openModal]); + + return ( + + ); +}; + +const RenderRowActions = ({ + row, +}: { + row: MRT_Row; +}) => { + 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 ( + + + + ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx new file mode 100644 index 000000000..12daa10c4 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/manage/users/invites/page.tsx @@ -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 ; +} diff --git a/apps/nextjs/src/components/manage/boards/add-board-modal.tsx b/apps/nextjs/src/components/manage/boards/add-board-modal.tsx index 9c9662866..2cbe4e588 100644 --- a/apps/nextjs/src/components/manage/boards/add-board-modal.tsx +++ b/apps/nextjs/src/components/manage/boards/add-board-modal.tsx @@ -38,9 +38,7 @@ export const AddBoardModal = createModal( > diff --git a/packages/api/src/root.ts b/packages/api/src/root.ts index a2ac124d0..05a37b963 100644 --- a/packages/api/src/root.ts +++ b/packages/api/src/root.ts @@ -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, diff --git a/packages/api/src/router/invite.ts b/packages/api/src/router/invite.ts new file mode 100644 index 000000000..cc6894115 --- /dev/null +++ b/packages/api/src/router/invite.ts @@ -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)); + }), +}); diff --git a/packages/api/src/router/test/invite.spec.ts b/packages/api/src/router/test/invite.spec.ts new file mode 100644 index 000000000..6a4e5668e --- /dev/null +++ b/packages/api/src/router/test/invite.spec.ts @@ -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"); + }); +}); diff --git a/packages/db/migrations/0001_sparkling_zaran.sql b/packages/db/migrations/0001_sparkling_zaran.sql new file mode 100644 index 000000000..5fb299785 --- /dev/null +++ b/packages/db/migrations/0001_sparkling_zaran.sql @@ -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`); \ No newline at end of file diff --git a/packages/db/migrations/meta/0001_snapshot.json b/packages/db/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..fc6d8fa86 --- /dev/null +++ b/packages/db/migrations/meta/0001_snapshot.json @@ -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": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 0ab745f96..c45a1ff34 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1710878250235, "tag": "0000_productive_changeling", "breakpoints": true + }, + { + "idx": 1, + "version": "5", + "when": 1712777046680, + "tag": "0001_sparkling_zaran", + "breakpoints": true } ] } diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts index 7ef925b15..5bc28d659 100644 --- a/packages/db/schema/mysql.ts +++ b/packages/db/schema/mysql.ts @@ -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 }) => ({ diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts index 89439e1e1..400aaf7fa 100644 --- a/packages/db/schema/sqlite.ts +++ b/packages/db/schema/sqlite.ts @@ -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 }) => ({ diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 81cc936f6..06205c227 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -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, you'll not be able to copy this link anymore. 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", + }, }, }, },