From 94af21abbfb36b1804d86d89155af720055de588 Mon Sep 17 00:00:00 2001 From: Meier Lukas Date: Sun, 12 May 2024 10:04:20 +0200 Subject: [PATCH] feat: add user invite registration (#477) --- .../auth/invite/[id]/_registration-form.tsx | 92 ++++++++++++++++ .../app/[locale]/auth/invite/[id]/page.tsx | 72 +++++++++++++ .../app/[locale]/auth/login/_login-form.tsx | 16 ++- packages/api/src/router/test/user.spec.ts | 102 ++++++++++++++++++ packages/api/src/router/user.ts | 30 +++++- packages/translation/src/lang/en.ts | 32 +++++- packages/validation/src/user.ts | 20 ++++ 7 files changed, 359 insertions(+), 5 deletions(-) create mode 100644 apps/nextjs/src/app/[locale]/auth/invite/[id]/_registration-form.tsx create mode 100644 apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx diff --git a/apps/nextjs/src/app/[locale]/auth/invite/[id]/_registration-form.tsx b/apps/nextjs/src/app/[locale]/auth/invite/[id]/_registration-form.tsx new file mode 100644 index 000000000..f7ea9ace5 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/auth/invite/[id]/_registration-form.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { Button, PasswordInput, Stack, TextInput } from "@mantine/core"; + +import { clientApi } from "@homarr/api/client"; +import { useForm, zodResolver } from "@homarr/form"; +import { + showErrorNotification, + showSuccessNotification, +} from "@homarr/notifications"; +import { useScopedI18n } from "@homarr/translation/client"; +import type { z } from "@homarr/validation"; +import { validation } from "@homarr/validation"; + +interface RegistrationFormProps { + invite: { + id: string; + token: string; + }; +} + +export const RegistrationForm = ({ invite }: RegistrationFormProps) => { + const t = useScopedI18n("user"); + const router = useRouter(); + const { mutate, isPending } = clientApi.user.register.useMutation(); + const form = useForm({ + validate: zodResolver(validation.user.registration), + initialValues: { + username: "", + password: "", + confirmPassword: "", + }, + validateInputOnBlur: true, + validateInputOnChange: true, + }); + + const handleSubmit = (values: FormType) => { + mutate( + { + ...values, + inviteId: invite.id, + token: invite.token, + }, + { + onSuccess() { + showSuccessNotification({ + title: t("action.register.notification.success.title"), + message: t("action.register.notification.success.message"), + }); + router.push("/auth/login"); + }, + onError() { + showErrorNotification({ + title: t("action.register.notification.error.title"), + message: t("action.register.notification.error.message"), + }); + }, + }, + ); + }; + + return ( + +
+ + + + + + + +
+
+ ); +}; + +type FormType = z.infer; diff --git a/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx b/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx new file mode 100644 index 000000000..a7441022d --- /dev/null +++ b/apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx @@ -0,0 +1,72 @@ +import { notFound } from "next/navigation"; +import { Card, Center, Stack, Text, Title } from "@mantine/core"; + +import { auth } from "@homarr/auth/next"; +import { and, db, eq } from "@homarr/db"; +import { invites } from "@homarr/db/schema/sqlite"; +import { getScopedI18n } from "@homarr/translation/server"; + +import { HomarrLogoWithTitle } from "~/components/layout/logo/homarr-logo"; +import { RegistrationForm } from "./_registration-form"; + +interface InviteUsagePageProps { + params: { + id: string; + }; + searchParams: { + token: string; + }; +} + +export default async function InviteUsagePage({ + params, + searchParams, +}: InviteUsagePageProps) { + const session = await auth(); + if (session) notFound(); + + const invite = await db.query.invites.findFirst({ + where: and( + eq(invites.id, params.id), + eq(invites.token, searchParams.token), + ), + columns: { + id: true, + token: true, + expirationDate: true, + }, + with: { + creator: { + columns: { + name: true, + }, + }, + }, + }); + + if (!invite || invite.expirationDate < new Date()) notFound(); + + const t = await getScopedI18n("user.page.invite"); + + return ( +
+ + + + + {t("title")} + + + {t("subtitle")} + + + + + + + {t("description", { username: invite.creator.name })} + + +
+ ); +} diff --git a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx index 308bd7d4a..dac9fa9a4 100644 --- a/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx +++ b/apps/nextjs/src/app/[locale]/auth/login/_login-form.tsx @@ -14,6 +14,10 @@ import { IconAlertTriangle } from "@tabler/icons-react"; import { signIn } from "@homarr/auth/client"; import { useForm, zodResolver } from "@homarr/form"; +import { + showErrorNotification, + showSuccessNotification, +} from "@homarr/notifications"; import { useScopedI18n } from "@homarr/translation/client"; import type { z } from "@homarr/validation"; import { validation } from "@homarr/validation"; @@ -44,11 +48,19 @@ export const LoginForm = () => { throw response?.error; } - void router.push("/"); + showSuccessNotification({ + title: t("action.login.notification.success.title"), + message: t("action.login.notification.success.message"), + }); + router.push("/"); }) .catch((error: Error | string) => { setIsLoading(false); setError(error.toString()); + showErrorNotification({ + title: t("action.login.notification.error.title"), + message: t("action.login.notification.error.message"), + }); }); }; @@ -65,7 +77,7 @@ export const LoginForm = () => { {...form.getInputProps("password")} /> diff --git a/packages/api/src/router/test/user.spec.ts b/packages/api/src/router/test/user.spec.ts index 936eb74f7..b41faf9c6 100644 --- a/packages/api/src/router/test/user.spec.ts +++ b/packages/api/src/router/test/user.spec.ts @@ -2,6 +2,7 @@ import { describe, expect, it, test, vi } from "vitest"; import type { Session } from "@homarr/auth"; import { createId, eq, schema } from "@homarr/db"; +import { users } from "@homarr/db/schema/sqlite"; import { createDb } from "@homarr/db/test"; import { userRouter } from "../user"; @@ -91,7 +92,106 @@ describe("initUser should initialize the first user", () => { await expect(act()).rejects.toThrow("too_small"); }); +}); +describe("register should create a user with valid invitation", () => { + test("register should create a user with valid invitation", async () => { + // Arrange + const db = createDb(); + const caller = userRouter.createCaller({ + db, + session: null, + }); + + const userId = createId(); + const inviteId = createId(); + const inviteToken = "123"; + vi.useFakeTimers(); + vi.setSystemTime(new Date(2024, 0, 3)); + + await db.insert(users).values({ + id: userId, + }); + await db.insert(schema.invites).values({ + id: inviteId, + token: inviteToken, + creatorId: userId, + expirationDate: new Date(2024, 0, 5), + }); + + // Act + await caller.register({ + inviteId, + token: inviteToken, + username: "test", + password: "12345678", + confirmPassword: "12345678", + }); + + // Assert + const user = await db.query.users.findMany({ + columns: { + name: true, + }, + }); + const invite = await db.query.invites.findMany({ + columns: { + id: true, + }, + }); + + expect(user).toHaveLength(2); + expect(invite).toHaveLength(0); + }); + + test.each([ + [{ token: "fakeToken" }, new Date(2024, 0, 3)], + [{ inviteId: "fakeInviteId" }, new Date(2024, 0, 3)], + [{}, new Date(2024, 0, 5, 0, 0, 1)], + ])( + "register should throw an error with input %s and date %s if the invitation is invalid", + async (partialInput, systemTime) => { + // Arrange + const db = createDb(); + const caller = userRouter.createCaller({ + db, + session: null, + }); + + const userId = createId(); + const inviteId = createId(); + const inviteToken = "123"; + vi.useFakeTimers(); + vi.setSystemTime(systemTime); + + await db.insert(users).values({ + id: userId, + }); + await db.insert(schema.invites).values({ + id: inviteId, + token: inviteToken, + creatorId: userId, + expirationDate: new Date(2024, 0, 5), + }); + + // Act + const act = async () => + await caller.register({ + inviteId, + token: inviteToken, + username: "test", + password: "12345678", + confirmPassword: "12345678", + ...partialInput, + }); + + // Assert + await expect(act()).rejects.toThrow("Invalid invite"); + }, + ); +}); + +describe("editProfile shoud update user", () => { test("editProfile should update users and not update emailVerified when email not dirty", async () => { // arrange const db = createDb(); @@ -180,7 +280,9 @@ describe("initUser should initialize the first user", () => { image: null, }); }); +}); +describe("delete should delete user", () => { test("delete should delete user", async () => { const db = createDb(); const caller = userRouter.createCaller({ diff --git a/packages/api/src/router/user.ts b/packages/api/src/router/user.ts index 364a3ff82..06b538a90 100644 --- a/packages/api/src/router/user.ts +++ b/packages/api/src/router/user.ts @@ -3,8 +3,8 @@ import { observable } from "@trpc/server/observable"; import { createSalt, hashPassword } from "@homarr/auth"; import type { Database } from "@homarr/db"; -import { createId, eq, schema } from "@homarr/db"; -import { users } from "@homarr/db/schema/sqlite"; +import { and, createId, eq, schema } from "@homarr/db"; +import { invites, users } from "@homarr/db/schema/sqlite"; import { exampleChannel } from "@homarr/redis"; import { validation, z } from "@homarr/validation"; @@ -29,6 +29,32 @@ export const userRouter = createTRPCRouter({ await createUser(ctx.db, input); }), + register: publicProcedure + .input(validation.user.registrationApi) + .mutation(async ({ ctx, input }) => { + const inviteWhere = and( + eq(invites.id, input.inviteId), + eq(invites.token, input.token), + ); + const dbInvite = await ctx.db.query.invites.findFirst({ + columns: { + id: true, + expirationDate: true, + }, + where: inviteWhere, + }); + + if (!dbInvite || dbInvite.expirationDate < new Date()) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Invalid invite", + }); + } + + await createUser(ctx.db, input); + // Delete invite as it's used + await ctx.db.delete(invites).where(inviteWhere); + }), create: publicProcedure .input(validation.user.create) .mutation(async ({ ctx, input }) => { diff --git a/packages/translation/src/lang/en.ts b/packages/translation/src/lang/en.ts index 836792926..5f7cb09e4 100644 --- a/packages/translation/src/lang/en.ts +++ b/packages/translation/src/lang/en.ts @@ -7,6 +7,11 @@ export default { title: "Log in to your account", subtitle: "Welcome back! Please enter your credentials", }, + invite: { + title: "Join Homarr", + subtitle: "Welcome to Homarr! Please create your account", + description: "You were invited by {username}", + }, init: { title: "New Homarr installation", subtitle: "Please create the initial administator user", @@ -27,7 +32,32 @@ export default { }, }, action: { - login: "Login", + login: { + label: "Login", + notification: { + success: { + title: "Login successful", + message: "You are now logged in", + }, + error: { + title: "Login failed", + message: "Your login failed", + }, + }, + }, + register: { + label: "Create account", + notification: { + success: { + title: "Account created", + message: "Please log in to continue", + }, + error: { + title: "Account creation failed", + message: "Your account could not be created", + }, + }, + }, create: "Create user", select: { label: "Select user", diff --git a/packages/validation/src/user.ts b/packages/validation/src/user.ts index 008905a87..fbce8b80e 100644 --- a/packages/validation/src/user.ts +++ b/packages/validation/src/user.ts @@ -22,6 +22,24 @@ const signInSchema = z.object({ password: z.string(), }); +const registrationSchema = z + .object({ + username: usernameSchema, + password: passwordSchema, + confirmPassword: z.string(), + }) + .refine((data) => data.password === data.confirmPassword, { + path: ["confirmPassword"], + message: "Passwords do not match", + }); + +const registrationSchemaApi = registrationSchema.and( + z.object({ + inviteId: z.string(), + token: z.string(), + }), +); + const editProfileSchema = z.object({ name: usernameSchema, email: z @@ -40,6 +58,8 @@ const changePasswordSchema = z.object({ export const userSchemas = { signIn: signInSchema, + registration: registrationSchema, + registrationApi: registrationSchemaApi, init: initUserSchema, create: createUserSchema, password: passwordSchema,