fix: permissions not restricted for certain management pages / actions (#1219)
* fix: restrict parts of manage navigation to admins * fix: restrict stats cards on manage home page * fix: restrict access to amount of certain stats for manage home * fix: restrict visibility of board create button * fix: restrict access to integration pages * fix: restrict access to tools pages for admins * fix: restrict access to user and group pages * test: adjust tests to match permission changes for routes * fix: remove certain pages from spotlight without admin * fix: app management not restricted
This commit is contained in:
@@ -4,7 +4,7 @@ import { asc, createId, eq, like } from "@homarr/db";
|
||||
import { apps } from "@homarr/db/schema/sqlite";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
all: publicProcedure
|
||||
@@ -102,7 +102,7 @@ export const appRouter = createTRPCRouter({
|
||||
|
||||
return app;
|
||||
}),
|
||||
create: publicProcedure
|
||||
create: protectedProcedure
|
||||
.input(validation.app.manage)
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "POST", path: "/api/apps", tags: ["apps"], protect: true } })
|
||||
@@ -115,7 +115,7 @@ export const appRouter = createTRPCRouter({
|
||||
href: input.href,
|
||||
});
|
||||
}),
|
||||
update: publicProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => {
|
||||
update: protectedProcedure.input(validation.app.edit).mutation(async ({ ctx, input }) => {
|
||||
const app = await ctx.db.query.apps.findFirst({
|
||||
where: eq(apps.id, input.id),
|
||||
});
|
||||
@@ -137,7 +137,7 @@ export const appRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(apps.id, input.id));
|
||||
}),
|
||||
delete: publicProcedure
|
||||
delete: protectedProcedure
|
||||
.output(z.void())
|
||||
.meta({ openapi: { method: "DELETE", path: "/api/apps/{id}", tags: ["apps"], protect: true } })
|
||||
.input(validation.common.byId)
|
||||
|
||||
@@ -6,20 +6,23 @@ import { createCronJobStatusChannel } from "@homarr/cron-job-status";
|
||||
import { jobGroup } from "@homarr/cron-jobs";
|
||||
import { logger } from "@homarr/log";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
export const cronJobsRouter = createTRPCRouter({
|
||||
triggerJob: publicProcedure.input(jobNameSchema).mutation(async ({ input }) => {
|
||||
await triggerCronJobAsync(input);
|
||||
}),
|
||||
getJobs: publicProcedure.query(() => {
|
||||
triggerJob: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(jobNameSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
await triggerCronJobAsync(input);
|
||||
}),
|
||||
getJobs: permissionRequiredProcedure.requiresPermission("admin").query(() => {
|
||||
const registry = jobGroup.getJobRegistry();
|
||||
return [...registry.values()].map((job) => ({
|
||||
name: job.name,
|
||||
expression: job.cronExpression,
|
||||
}));
|
||||
}),
|
||||
subscribeToStatusUpdates: publicProcedure.subscription(() => {
|
||||
subscribeToStatusUpdates: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
|
||||
return observable<TaskStatus>((emit) => {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
|
||||
|
||||
@@ -5,84 +5,92 @@ import { and, createId, eq, like, not, sql } from "@homarr/db";
|
||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema/sqlite";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
getPaginated: protectedProcedure.input(validation.common.paginated).query(async ({ input, ctx }) => {
|
||||
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
|
||||
const groupCount = await ctx.db
|
||||
.select({
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(groups)
|
||||
.where(whereQuery);
|
||||
getPaginated: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.common.paginated)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const whereQuery = input.search ? like(groups.name, `%${input.search.trim()}%`) : undefined;
|
||||
const groupCount = await ctx.db
|
||||
.select({
|
||||
count: sql<number>`count(*)`,
|
||||
})
|
||||
.from(groups)
|
||||
.where(whereQuery);
|
||||
|
||||
const dbGroups = await ctx.db.query.groups.findMany({
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
const dbGroups = await ctx.db.query.groups.findMany({
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
limit: input.pageSize,
|
||||
offset: (input.page - 1) * input.pageSize,
|
||||
where: whereQuery,
|
||||
});
|
||||
limit: input.pageSize,
|
||||
offset: (input.page - 1) * input.pageSize,
|
||||
where: whereQuery,
|
||||
});
|
||||
|
||||
return {
|
||||
items: dbGroups.map((group) => ({
|
||||
return {
|
||||
items: dbGroups.map((group) => ({
|
||||
...group,
|
||||
members: group.members.map((member) => member.user),
|
||||
})),
|
||||
totalCount: groupCount[0]?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
getById: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.common.byId)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const group = await ctx.db.query.groups.findFirst({
|
||||
where: eq(groups.id, input.id),
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
provider: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
columns: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Group not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
members: group.members.map((member) => member.user),
|
||||
})),
|
||||
totalCount: groupCount[0]?.count ?? 0,
|
||||
};
|
||||
}),
|
||||
getById: protectedProcedure.input(validation.common.byId).query(async ({ input, ctx }) => {
|
||||
const group = await ctx.db.query.groups.findFirst({
|
||||
where: eq(groups.id, input.id),
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
image: true,
|
||||
provider: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
permissions: {
|
||||
columns: {
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!group) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Group not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
members: group.members.map((member) => member.user),
|
||||
permissions: group.permissions.map((permission) => permission.permission),
|
||||
};
|
||||
}),
|
||||
permissions: group.permissions.map((permission) => permission.permission),
|
||||
};
|
||||
}),
|
||||
// Is protected because also used in board access / integration access forms
|
||||
selectable: protectedProcedure.query(async ({ ctx }) => {
|
||||
return await ctx.db.query.groups.findMany({
|
||||
columns: {
|
||||
@@ -91,7 +99,8 @@ export const groupRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
search: publicProcedure
|
||||
search: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
@@ -108,85 +117,108 @@ export const groupRouter = createTRPCRouter({
|
||||
limit: input.limit,
|
||||
});
|
||||
}),
|
||||
createGroup: protectedProcedure.input(validation.group.create).mutation(async ({ input, ctx }) => {
|
||||
const normalizedName = normalizeName(input.name);
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);
|
||||
createGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.create)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const normalizedName = normalizeName(input.name);
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName);
|
||||
|
||||
const id = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id,
|
||||
name: normalizedName,
|
||||
ownerId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
return id;
|
||||
}),
|
||||
updateGroup: protectedProcedure.input(validation.group.update).mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||
|
||||
const normalizedName = normalizeName(input.name);
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName, input.id);
|
||||
|
||||
await ctx.db
|
||||
.update(groups)
|
||||
.set({
|
||||
const id = createId();
|
||||
await ctx.db.insert(groups).values({
|
||||
id,
|
||||
name: normalizedName,
|
||||
})
|
||||
.where(eq(groups.id, input.id));
|
||||
}),
|
||||
savePermissions: protectedProcedure.input(validation.group.savePermissions).mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
|
||||
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
|
||||
|
||||
await ctx.db.insert(groupPermissions).values(
|
||||
input.permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
transferOwnership: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
|
||||
await ctx.db
|
||||
.update(groups)
|
||||
.set({
|
||||
ownerId: input.userId,
|
||||
})
|
||||
.where(eq(groups.id, input.groupId));
|
||||
}),
|
||||
deleteGroup: protectedProcedure.input(validation.common.byId).mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||
|
||||
await ctx.db.delete(groups).where(eq(groups.id, input.id));
|
||||
}),
|
||||
addMember: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(groups.id, input.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
ownerId: ctx.session.user.id,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId: input.groupId,
|
||||
userId: input.userId,
|
||||
});
|
||||
}),
|
||||
removeMember: protectedProcedure.input(validation.group.groupUser).mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
return id;
|
||||
}),
|
||||
updateGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.update)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||
|
||||
await ctx.db
|
||||
.delete(groupMembers)
|
||||
.where(and(eq(groupMembers.groupId, input.groupId), eq(groupMembers.userId, input.userId)));
|
||||
}),
|
||||
const normalizedName = normalizeName(input.name);
|
||||
await checkSimilarNameAndThrowAsync(ctx.db, normalizedName, input.id);
|
||||
|
||||
await ctx.db
|
||||
.update(groups)
|
||||
.set({
|
||||
name: normalizedName,
|
||||
})
|
||||
.where(eq(groups.id, input.id));
|
||||
}),
|
||||
savePermissions: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.savePermissions)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
|
||||
await ctx.db.delete(groupPermissions).where(eq(groupPermissions.groupId, input.groupId));
|
||||
|
||||
await ctx.db.insert(groupPermissions).values(
|
||||
input.permissions.map((permission) => ({
|
||||
groupId: input.groupId,
|
||||
permission,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
transferOwnership: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.groupUser)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
|
||||
await ctx.db
|
||||
.update(groups)
|
||||
.set({
|
||||
ownerId: input.userId,
|
||||
})
|
||||
.where(eq(groups.id, input.groupId));
|
||||
}),
|
||||
deleteGroup: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.common.byId)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.id);
|
||||
|
||||
await ctx.db.delete(groups).where(eq(groups.id, input.id));
|
||||
}),
|
||||
addMember: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.groupUser)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(groups.id, input.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId: input.groupId,
|
||||
userId: input.userId,
|
||||
});
|
||||
}),
|
||||
removeMember: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(validation.group.groupUser)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
await ctx.db
|
||||
.delete(groupMembers)
|
||||
.where(and(eq(groupMembers.groupId, input.groupId), eq(groupMembers.userId, input.userId)));
|
||||
}),
|
||||
});
|
||||
|
||||
const normalizeName = (name: string) => name.trim();
|
||||
|
||||
@@ -1,17 +1,32 @@
|
||||
import type { AnySQLiteTable } from "drizzle-orm/sqlite-core";
|
||||
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { count } from "@homarr/db";
|
||||
import { apps, boards, groups, integrations, invites, users } from "@homarr/db/schema/sqlite";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
|
||||
export const homeRouter = createTRPCRouter({
|
||||
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||
getStats: publicProcedure.query(async ({ ctx }) => {
|
||||
const isAdmin = ctx.session?.user.permissions.includes("admin") ?? false;
|
||||
const isCredentialsEnabled = isProviderEnabled("credentials");
|
||||
|
||||
return {
|
||||
countBoards: (await ctx.db.select({ count: count() }).from(boards))[0]?.count ?? 0,
|
||||
countUsers: (await ctx.db.select({ count: count() }).from(users))[0]?.count ?? 0,
|
||||
countGroups: (await ctx.db.select({ count: count() }).from(groups))[0]?.count ?? 0,
|
||||
countInvites: (await ctx.db.select({ count: count() }).from(invites))[0]?.count ?? 0,
|
||||
countIntegrations: (await ctx.db.select({ count: count() }).from(integrations))[0]?.count ?? 0,
|
||||
countApps: (await ctx.db.select({ count: count() }).from(apps))[0]?.count ?? 0,
|
||||
countBoards: await getCountForTableAsync(ctx.db, boards, true),
|
||||
countUsers: await getCountForTableAsync(ctx.db, users, isAdmin),
|
||||
countGroups: await getCountForTableAsync(ctx.db, groups, true),
|
||||
countInvites: await getCountForTableAsync(ctx.db, invites, isAdmin),
|
||||
countIntegrations: await getCountForTableAsync(ctx.db, integrations, isCredentialsEnabled && isAdmin),
|
||||
countApps: await getCountForTableAsync(ctx.db, apps, true),
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
const getCountForTableAsync = async (db: Database, table: AnySQLiteTable, canView: boolean) => {
|
||||
if (!canView) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (await db.select({ count: count() }).from(table))[0]?.count ?? 0;
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { decryptSecret, encryptSecret } from "@homarr/common/server";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, asc, createId, eq, inArray, like } from "@homarr/db";
|
||||
import {
|
||||
groupMembers,
|
||||
groupPermissions,
|
||||
integrationGroupPermissions,
|
||||
integrations,
|
||||
@@ -14,20 +15,48 @@ import type { IntegrationSecretKind } from "@homarr/definitions";
|
||||
import { getPermissionsWithParents, integrationKinds, integrationSecretKindObject } from "@homarr/definitions";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../../trpc";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "./integration-access";
|
||||
import { testConnectionAsync } from "./integration-test-connection";
|
||||
|
||||
export const integrationRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
const integrations = await ctx.db.query.integrations.findMany();
|
||||
all: publicProcedure.query(async ({ ctx }) => {
|
||||
const groupsOfCurrentUser = await ctx.db.query.groupMembers.findMany({
|
||||
where: eq(groupMembers.userId, ctx.session?.user.id ?? ""),
|
||||
});
|
||||
|
||||
const integrations = await ctx.db.query.integrations.findMany({
|
||||
with: {
|
||||
userPermissions: {
|
||||
where: eq(integrationUserPermissions.userId, ctx.session?.user.id ?? ""),
|
||||
},
|
||||
groupPermissions: {
|
||||
where: inArray(
|
||||
integrationGroupPermissions.groupId,
|
||||
groupsOfCurrentUser.map((group) => group.groupId),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
return integrations
|
||||
.map((integration) => ({
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
}))
|
||||
.map((integration) => {
|
||||
const permissions = integration.userPermissions
|
||||
.map(({ permission }) => permission)
|
||||
.concat(integration.groupPermissions.map(({ permission }) => permission));
|
||||
|
||||
return {
|
||||
id: integration.id,
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
permissions: {
|
||||
hasUseAccess:
|
||||
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
|
||||
hasInteractAccess: permissions.includes("interact") || permissions.includes("full"),
|
||||
hasFullAccess: permissions.includes("full"),
|
||||
},
|
||||
};
|
||||
})
|
||||
.sort(
|
||||
(integrationA, integrationB) =>
|
||||
integrationKinds.indexOf(integrationA.kind) - integrationKinds.indexOf(integrationB.kind),
|
||||
|
||||
@@ -4,10 +4,10 @@ import { logger } from "@homarr/log";
|
||||
import type { LoggerMessage } from "@homarr/redis";
|
||||
import { loggingChannel } from "@homarr/redis";
|
||||
|
||||
import { createTRPCRouter, publicProcedure } from "../trpc";
|
||||
import { createTRPCRouter, permissionRequiredProcedure } from "../trpc";
|
||||
|
||||
export const logRouter = createTRPCRouter({
|
||||
subscribe: publicProcedure.subscription(() => {
|
||||
subscribe: permissionRequiredProcedure.requiresPermission("admin").subscription(() => {
|
||||
return observable<LoggerMessage>((emit) => {
|
||||
const unsubscribe = loggingChannel.subscribe((data) => {
|
||||
emit.next(data);
|
||||
|
||||
@@ -11,6 +11,11 @@ import { appRouter } from "../app";
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", () => ({ auth: () => ({}) as Session }));
|
||||
|
||||
const defaultSession: Session = {
|
||||
user: { id: createId(), permissions: [], colorScheme: "light" },
|
||||
expires: new Date().toISOString(),
|
||||
};
|
||||
|
||||
describe("all should return all apps", () => {
|
||||
test("should return all apps", async () => {
|
||||
const db = createDb();
|
||||
@@ -89,7 +94,7 @@ describe("create should create a new app with all arguments", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
session: defaultSession,
|
||||
});
|
||||
const input = {
|
||||
name: "Mantine",
|
||||
@@ -112,7 +117,7 @@ describe("create should create a new app with all arguments", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
session: defaultSession,
|
||||
});
|
||||
const input = {
|
||||
name: "Mantine",
|
||||
@@ -137,7 +142,7 @@ describe("update should update an app", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const appId = createId();
|
||||
@@ -172,7 +177,7 @@ describe("update should update an app", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const actAsync = async () =>
|
||||
@@ -192,7 +197,7 @@ describe("delete should delete an app", () => {
|
||||
const db = createDb();
|
||||
const caller = appRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const appId = createId();
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { Session } from "@homarr/auth";
|
||||
import * as env from "@homarr/auth/env.mjs";
|
||||
import { createId, eq } from "@homarr/db";
|
||||
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema/sqlite";
|
||||
import { createDb } from "@homarr/db/test";
|
||||
import type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
import { groupRouter } from "../group";
|
||||
|
||||
const defaultOwnerId = createId();
|
||||
const defaultSession = {
|
||||
user: {
|
||||
id: defaultOwnerId,
|
||||
permissions: [],
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
} satisfies Session;
|
||||
const createSession = (permissions: GroupPermissionKey[]) =>
|
||||
({
|
||||
user: {
|
||||
id: defaultOwnerId,
|
||||
permissions,
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
}) satisfies Session;
|
||||
const defaultSession = createSession([]);
|
||||
const adminSession = createSession(["admin"]);
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", async () => {
|
||||
@@ -32,7 +37,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
async (page, expectedCount) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
await db.insert(groups).values(
|
||||
[1, 2, 3, 4, 5].map((number) => ({
|
||||
@@ -55,7 +60,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
test("with 5 groups in database and pagesize set to 3 it should return total count 5", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
await db.insert(groups).values(
|
||||
[1, 2, 3, 4, 5].map((number) => ({
|
||||
@@ -76,7 +81,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
test("groups should contain id, name, email and image of members", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const user = createDummyUser();
|
||||
await db.insert(users).values(user);
|
||||
@@ -112,7 +117,7 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
async (query, expectedCount, firstKey) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
await db.insert(groups).values(
|
||||
["first", "second", "third", "forth", "fifth"].map((key, index) => ({
|
||||
@@ -131,13 +136,25 @@ describe("paginated should return a list of groups with pagination", () => {
|
||||
expect(result.items.at(0)?.name).toBe(firstKey);
|
||||
},
|
||||
);
|
||||
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.getPaginated({});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("byId should return group by id including members and permissions", () => {
|
||||
test('should return group with id "1" with members and permissions', async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const user = createDummyUser();
|
||||
const groupId = "1";
|
||||
@@ -180,7 +197,7 @@ describe("byId should return group by id including members and permissions", ()
|
||||
test("with group id 1 and group 2 in database it should throw NOT_FOUND error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: "2",
|
||||
@@ -193,13 +210,25 @@ describe("byId should return group by id including members and permissions", ()
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Group not found");
|
||||
});
|
||||
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.getById({ id: "1" });
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("create should create group in database", () => {
|
||||
test("with valid input (64 character name) and non existing name it should be successful", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const name = "a".repeat(64);
|
||||
await db.insert(users).values(defaultSession.user);
|
||||
@@ -223,7 +252,7 @@ describe("create should create group in database", () => {
|
||||
test("with more than 64 characters name it should fail while validation", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
const longName = "a".repeat(65);
|
||||
|
||||
// Act
|
||||
@@ -244,7 +273,7 @@ describe("create should create group in database", () => {
|
||||
])("with similar name %s it should fail to create %s", async (similarName, nameToCreate) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
@@ -257,6 +286,18 @@ describe("create should create group in database", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("similar name");
|
||||
});
|
||||
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () => await caller.createGroup({ name: "test" });
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("update should update name with value that is no duplicate", () => {
|
||||
@@ -266,7 +307,7 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
])("update should update name from %s to %s normalized", async (initialValue, updateValue, expectedValue) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values([
|
||||
@@ -299,7 +340,7 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
])("with similar name %s it should fail to update %s", async (updateValue, initialDuplicate) => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values([
|
||||
@@ -327,7 +368,7 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
test("with non existing id it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
@@ -344,13 +385,29 @@ describe("update should update name with value that is no duplicate", () => {
|
||||
// Assert
|
||||
await expect(act()).rejects.toThrow("Group not found");
|
||||
});
|
||||
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.updateGroup({
|
||||
id: createId(),
|
||||
name: "test",
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("savePermissions should save permissions for group", () => {
|
||||
test("with existing group and permissions it should save permissions", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values({
|
||||
@@ -380,7 +437,7 @@ describe("savePermissions should save permissions for group", () => {
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
@@ -397,13 +454,29 @@ describe("savePermissions should save permissions for group", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Group not found");
|
||||
});
|
||||
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.savePermissions({
|
||||
groupId: createId(),
|
||||
permissions: ["integration-create", "board-full-all"],
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferOwnership should transfer ownership of group", () => {
|
||||
test("with existing group and user it should transfer ownership", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const newUserId = createId();
|
||||
@@ -440,7 +513,7 @@ describe("transferOwnership should transfer ownership of group", () => {
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
@@ -457,13 +530,29 @@ describe("transferOwnership should transfer ownership of group", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Group not found");
|
||||
});
|
||||
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.transferOwnership({
|
||||
groupId: createId(),
|
||||
userId: createId(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteGroup should delete group", () => {
|
||||
test("with existing group it should delete group", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
await db.insert(groups).values([
|
||||
@@ -492,7 +581,7 @@ describe("deleteGroup should delete group", () => {
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
await db.insert(groups).values({
|
||||
id: createId(),
|
||||
@@ -508,13 +597,30 @@ describe("deleteGroup should delete group", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Group not found");
|
||||
});
|
||||
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.deleteGroup({
|
||||
id: createId(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
});
|
||||
|
||||
describe("addMember should add member to group", () => {
|
||||
test("with existing group and user it should add member", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const spy = vi.spyOn(env, "env", "get");
|
||||
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const userId = createId();
|
||||
@@ -552,7 +658,7 @@ describe("addMember should add member to group", () => {
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
await db.insert(users).values({
|
||||
id: createId(),
|
||||
@@ -569,13 +675,67 @@ describe("addMember should add member to group", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Group not found");
|
||||
});
|
||||
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.addMember({
|
||||
groupId: createId(),
|
||||
userId: createId(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
test("without credentials provider it should throw FORBIDDEN error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(env, "env", "get");
|
||||
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const userId = createId();
|
||||
await db.insert(users).values([
|
||||
{
|
||||
id: userId,
|
||||
name: "User",
|
||||
},
|
||||
{
|
||||
id: defaultOwnerId,
|
||||
name: "Creator",
|
||||
},
|
||||
]);
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.addMember({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("removeMember should remove member from group", () => {
|
||||
test("with existing group and user it should remove member", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const spy = vi.spyOn(env, "env", "get");
|
||||
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials"] } as never);
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const userId = createId();
|
||||
@@ -616,7 +776,7 @@ describe("removeMember should remove member from group", () => {
|
||||
test("with non existing group it should throw not found error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
await db.insert(users).values({
|
||||
id: createId(),
|
||||
@@ -633,6 +793,62 @@ describe("removeMember should remove member from group", () => {
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Group not found");
|
||||
});
|
||||
|
||||
test("without admin permissions it should throw unauthorized error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const caller = groupRouter.createCaller({ db, session: defaultSession });
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.removeMember({
|
||||
groupId: createId(),
|
||||
userId: createId(),
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Permission denied");
|
||||
});
|
||||
|
||||
test("without credentials provider it should throw FORBIDDEN error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(env, "env", "get");
|
||||
spy.mockReturnValue({ AUTH_PROVIDERS: ["ldap"] } as never);
|
||||
const caller = groupRouter.createCaller({ db, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const userId = createId();
|
||||
await db.insert(users).values([
|
||||
{
|
||||
id: userId,
|
||||
name: "User",
|
||||
},
|
||||
{
|
||||
id: defaultOwnerId,
|
||||
name: "Creator",
|
||||
},
|
||||
]);
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
});
|
||||
await db.insert(groupMembers).values({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.removeMember({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
|
||||
});
|
||||
});
|
||||
|
||||
const createDummyUser = () => ({
|
||||
|
||||
@@ -4,9 +4,22 @@ 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 type { GroupPermissionKey } from "@homarr/definitions";
|
||||
|
||||
import { userRouter } from "../user";
|
||||
|
||||
const defaultOwnerId = createId();
|
||||
const createSession = (permissions: GroupPermissionKey[]) =>
|
||||
({
|
||||
user: {
|
||||
id: defaultOwnerId,
|
||||
permissions,
|
||||
colorScheme: "light",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
}) satisfies Session;
|
||||
const defaultSession = createSession([]);
|
||||
|
||||
// Mock the auth module to return an empty session
|
||||
vi.mock("@homarr/auth", async () => {
|
||||
const mod = await import("@homarr/auth/security");
|
||||
@@ -212,14 +225,13 @@ describe("editProfile shoud update user", () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const id = createId();
|
||||
const emailVerified = new Date(2024, 0, 5);
|
||||
|
||||
await db.insert(schema.users).values({
|
||||
id,
|
||||
id: defaultOwnerId,
|
||||
name: "TEST 1",
|
||||
email: "abc@gmail.com",
|
||||
emailVerified,
|
||||
@@ -227,17 +239,17 @@ describe("editProfile shoud update user", () => {
|
||||
|
||||
// act
|
||||
await caller.editProfile({
|
||||
id: id,
|
||||
id: defaultOwnerId,
|
||||
name: "ABC",
|
||||
email: "",
|
||||
});
|
||||
|
||||
// assert
|
||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, id));
|
||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
|
||||
|
||||
expect(user).toHaveLength(1);
|
||||
expect(user[0]).toStrictEqual({
|
||||
id,
|
||||
id: defaultOwnerId,
|
||||
name: "ABC",
|
||||
email: "abc@gmail.com",
|
||||
emailVerified,
|
||||
@@ -255,13 +267,11 @@ describe("editProfile shoud update user", () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const id = createId();
|
||||
|
||||
await db.insert(schema.users).values({
|
||||
id,
|
||||
id: defaultOwnerId,
|
||||
name: "TEST 1",
|
||||
email: "abc@gmail.com",
|
||||
emailVerified: new Date(2024, 0, 5),
|
||||
@@ -269,17 +279,17 @@ describe("editProfile shoud update user", () => {
|
||||
|
||||
// act
|
||||
await caller.editProfile({
|
||||
id,
|
||||
id: defaultOwnerId,
|
||||
name: "ABC",
|
||||
email: "myNewEmail@gmail.com",
|
||||
});
|
||||
|
||||
// assert
|
||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, id));
|
||||
const user = await db.select().from(schema.users).where(eq(schema.users.id, defaultOwnerId));
|
||||
|
||||
expect(user).toHaveLength(1);
|
||||
expect(user[0]).toStrictEqual({
|
||||
id,
|
||||
id: defaultOwnerId,
|
||||
name: "ABC",
|
||||
email: "myNewEmail@gmail.com",
|
||||
emailVerified: null,
|
||||
@@ -298,11 +308,9 @@ describe("delete should delete user", () => {
|
||||
const db = createDb();
|
||||
const caller = userRouter.createCaller({
|
||||
db,
|
||||
session: null,
|
||||
session: defaultSession,
|
||||
});
|
||||
|
||||
const userToDelete = createId();
|
||||
|
||||
const initialUsers = [
|
||||
{
|
||||
id: createId(),
|
||||
@@ -317,7 +325,7 @@ describe("delete should delete user", () => {
|
||||
colorScheme: "auto" as const,
|
||||
},
|
||||
{
|
||||
id: userToDelete,
|
||||
id: defaultOwnerId,
|
||||
name: "User 2",
|
||||
email: null,
|
||||
emailVerified: null,
|
||||
@@ -343,7 +351,7 @@ describe("delete should delete user", () => {
|
||||
|
||||
await db.insert(schema.users).values(initialUsers);
|
||||
|
||||
await caller.delete(userToDelete);
|
||||
await caller.delete(defaultOwnerId);
|
||||
|
||||
const usersInDb = await db.select().from(schema.users);
|
||||
expect(usersInDb).toStrictEqual([initialUsers[0], initialUsers[2]]);
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { logger } from "@homarr/log";
|
||||
import { validation, z } from "@homarr/validation";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
@@ -69,7 +69,8 @@ export const userRouter = createTRPCRouter({
|
||||
// Delete invite as it's used
|
||||
await ctx.db.delete(invites).where(inviteWhere);
|
||||
}),
|
||||
create: publicProcedure
|
||||
create: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.meta({ openapi: { method: "POST", path: "/api/users", tags: ["users"], protect: true } })
|
||||
.input(validation.user.create)
|
||||
.output(z.void())
|
||||
@@ -130,7 +131,8 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, input.userId));
|
||||
}),
|
||||
getAll: publicProcedure
|
||||
getAll: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(z.void())
|
||||
.output(
|
||||
z.array(
|
||||
@@ -155,7 +157,8 @@ export const userRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
selectable: publicProcedure.query(({ ctx }) => {
|
||||
// Is protected because also used in board access / integration access forms
|
||||
selectable: protectedProcedure.query(({ ctx }) => {
|
||||
return ctx.db.query.users.findMany({
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -164,7 +167,8 @@ export const userRouter = createTRPCRouter({
|
||||
},
|
||||
});
|
||||
}),
|
||||
search: publicProcedure
|
||||
search: permissionRequiredProcedure
|
||||
.requiresPermission("admin")
|
||||
.input(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
@@ -187,7 +191,14 @@ export const userRouter = createTRPCRouter({
|
||||
image: user.image,
|
||||
}));
|
||||
}),
|
||||
getById: publicProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => {
|
||||
getById: protectedProcedure.input(z.object({ userId: z.string() })).query(async ({ input, ctx }) => {
|
||||
// Only admins can view other users details
|
||||
if (ctx.session.user.id !== input.userId && !ctx.session.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to view other users details",
|
||||
});
|
||||
}
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
@@ -210,7 +221,15 @@ export const userRouter = createTRPCRouter({
|
||||
|
||||
return user;
|
||||
}),
|
||||
editProfile: publicProcedure.input(validation.user.editProfile).mutation(async ({ input, ctx }) => {
|
||||
editProfile: protectedProcedure.input(validation.user.editProfile).mutation(async ({ input, ctx }) => {
|
||||
// Only admins can view other users details
|
||||
if (ctx.session.user.id !== input.id && !ctx.session.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to edit other users details",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
columns: { email: true, provider: true },
|
||||
where: eq(users.id, input.id),
|
||||
@@ -242,7 +261,15 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
delete: publicProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
|
||||
delete: protectedProcedure.input(z.string()).mutation(async ({ input, ctx }) => {
|
||||
// Only admins and user itself can delete a user
|
||||
if (ctx.session.user.id !== input && !ctx.session.user.permissions.includes("admin")) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You are not allowed to delete other users",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.delete(users).where(eq(users.id, input));
|
||||
}),
|
||||
changePassword: protectedProcedure.input(validation.user.changePasswordApi).mutation(async ({ ctx, input }) => {
|
||||
@@ -311,7 +338,7 @@ export const userRouter = createTRPCRouter({
|
||||
.input(validation.user.changeHomeBoard.and(z.object({ userId: z.string() })))
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const user = ctx.session.user;
|
||||
// Only admins can change other users' passwords
|
||||
// Only admins can change other users passwords
|
||||
if (!user.permissions.includes("admin") && user.id !== input.userId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
|
||||
Reference in New Issue
Block a user