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:
@@ -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,
|
||||
|
||||
70
packages/api/src/router/invite.ts
Normal file
70
packages/api/src/router/invite.ts
Normal 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));
|
||||
}),
|
||||
});
|
||||
190
packages/api/src/router/test/invite.spec.ts
Normal file
190
packages/api/src/router/test/invite.spec.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user