feat: add user invite registration (#477)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user