feat: add user invite registration (#477)
This commit is contained in:
@@ -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<FormType>({
|
||||||
|
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 (
|
||||||
|
<Stack gap="xl">
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<TextInput
|
||||||
|
label={t("field.username.label")}
|
||||||
|
autoComplete="off"
|
||||||
|
{...form.getInputProps("username")}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label={t("field.password.label")}
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PasswordInput
|
||||||
|
label={t("field.passwordConfirm.label")}
|
||||||
|
autoComplete="new-password"
|
||||||
|
{...form.getInputProps("confirmPassword")}
|
||||||
|
/>
|
||||||
|
<Button type="submit" fullWidth loading={isPending}>
|
||||||
|
{t("action.register.label")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormType = z.infer<typeof validation.user.registration>;
|
||||||
72
apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx
Normal file
72
apps/nextjs/src/app/[locale]/auth/invite/[id]/page.tsx
Normal file
@@ -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 (
|
||||||
|
<Center>
|
||||||
|
<Stack align="center" mt="xl">
|
||||||
|
<HomarrLogoWithTitle size="lg" />
|
||||||
|
<Stack gap={6} align="center">
|
||||||
|
<Title order={3} fw={400} ta="center">
|
||||||
|
{t("title")}
|
||||||
|
</Title>
|
||||||
|
<Text size="sm" c="gray.5" ta="center">
|
||||||
|
{t("subtitle")}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
<Card bg="dark.8" w={64 * 6} maw="90vw">
|
||||||
|
<RegistrationForm invite={invite} />
|
||||||
|
</Card>
|
||||||
|
<Text size="xs" c="gray.5" ta="center">
|
||||||
|
{t("description", { username: invite.creator.name })}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,10 @@ import { IconAlertTriangle } from "@tabler/icons-react";
|
|||||||
|
|
||||||
import { signIn } from "@homarr/auth/client";
|
import { signIn } from "@homarr/auth/client";
|
||||||
import { useForm, zodResolver } from "@homarr/form";
|
import { useForm, zodResolver } from "@homarr/form";
|
||||||
|
import {
|
||||||
|
showErrorNotification,
|
||||||
|
showSuccessNotification,
|
||||||
|
} from "@homarr/notifications";
|
||||||
import { useScopedI18n } from "@homarr/translation/client";
|
import { useScopedI18n } from "@homarr/translation/client";
|
||||||
import type { z } from "@homarr/validation";
|
import type { z } from "@homarr/validation";
|
||||||
import { validation } from "@homarr/validation";
|
import { validation } from "@homarr/validation";
|
||||||
@@ -44,11 +48,19 @@ export const LoginForm = () => {
|
|||||||
throw response?.error;
|
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) => {
|
.catch((error: Error | string) => {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(error.toString());
|
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")}
|
{...form.getInputProps("password")}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" fullWidth loading={isLoading}>
|
<Button type="submit" fullWidth loading={isLoading}>
|
||||||
{t("action.login")}
|
{t("action.login.label")}
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it, test, vi } from "vitest";
|
|||||||
|
|
||||||
import type { Session } from "@homarr/auth";
|
import type { Session } from "@homarr/auth";
|
||||||
import { createId, eq, schema } from "@homarr/db";
|
import { createId, eq, schema } from "@homarr/db";
|
||||||
|
import { users } from "@homarr/db/schema/sqlite";
|
||||||
import { createDb } from "@homarr/db/test";
|
import { createDb } from "@homarr/db/test";
|
||||||
|
|
||||||
import { userRouter } from "../user";
|
import { userRouter } from "../user";
|
||||||
@@ -91,7 +92,106 @@ describe("initUser should initialize the first user", () => {
|
|||||||
|
|
||||||
await expect(act()).rejects.toThrow("too_small");
|
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 () => {
|
test("editProfile should update users and not update emailVerified when email not dirty", async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
@@ -180,7 +280,9 @@ describe("initUser should initialize the first user", () => {
|
|||||||
image: null,
|
image: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("delete should delete user", () => {
|
||||||
test("delete should delete user", async () => {
|
test("delete should delete user", async () => {
|
||||||
const db = createDb();
|
const db = createDb();
|
||||||
const caller = userRouter.createCaller({
|
const caller = userRouter.createCaller({
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { observable } from "@trpc/server/observable";
|
|||||||
|
|
||||||
import { createSalt, hashPassword } from "@homarr/auth";
|
import { createSalt, hashPassword } from "@homarr/auth";
|
||||||
import type { Database } from "@homarr/db";
|
import type { Database } from "@homarr/db";
|
||||||
import { createId, eq, schema } from "@homarr/db";
|
import { and, createId, eq, schema } from "@homarr/db";
|
||||||
import { users } from "@homarr/db/schema/sqlite";
|
import { invites, users } from "@homarr/db/schema/sqlite";
|
||||||
import { exampleChannel } from "@homarr/redis";
|
import { exampleChannel } from "@homarr/redis";
|
||||||
import { validation, z } from "@homarr/validation";
|
import { validation, z } from "@homarr/validation";
|
||||||
|
|
||||||
@@ -29,6 +29,32 @@ export const userRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await createUser(ctx.db, input);
|
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
|
create: publicProcedure
|
||||||
.input(validation.user.create)
|
.input(validation.user.create)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export default {
|
|||||||
title: "Log in to your account",
|
title: "Log in to your account",
|
||||||
subtitle: "Welcome back! Please enter your credentials",
|
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: {
|
init: {
|
||||||
title: "New Homarr installation",
|
title: "New Homarr installation",
|
||||||
subtitle: "Please create the initial administator user",
|
subtitle: "Please create the initial administator user",
|
||||||
@@ -27,7 +32,32 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
action: {
|
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",
|
create: "Create user",
|
||||||
select: {
|
select: {
|
||||||
label: "Select user",
|
label: "Select user",
|
||||||
|
|||||||
@@ -22,6 +22,24 @@ const signInSchema = z.object({
|
|||||||
password: z.string(),
|
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({
|
const editProfileSchema = z.object({
|
||||||
name: usernameSchema,
|
name: usernameSchema,
|
||||||
email: z
|
email: z
|
||||||
@@ -40,6 +58,8 @@ const changePasswordSchema = z.object({
|
|||||||
|
|
||||||
export const userSchemas = {
|
export const userSchemas = {
|
||||||
signIn: signInSchema,
|
signIn: signInSchema,
|
||||||
|
registration: registrationSchema,
|
||||||
|
registrationApi: registrationSchemaApi,
|
||||||
init: initUserSchema,
|
init: initUserSchema,
|
||||||
create: createUserSchema,
|
create: createUserSchema,
|
||||||
password: passwordSchema,
|
password: passwordSchema,
|
||||||
|
|||||||
Reference in New Issue
Block a user