
* Add log in with Google * Fix merge conflicts * Merge branch 'main' into feature/copy-add-identity-provider # Conflicts: # pages/api/auth/[...nextauth].tsx # pages/api/auth/forgot-password.ts # pages/settings/security.tsx # prisma/schema.prisma # public/static/locales/en/common.json * WIP: SAML login * fixed login * fixed verified_email check for Google * tweaks to padding * added BoxyHQ SAML service to local docker-compose * identityProvider is missing from the select clause * user may be undefined * fix for yarn build * Added SAML configuration to Settings -> Security page * UI tweaks * get saml login flag from the server * UI tweaks * moved SAMLConfiguration to a component in ee * updated saml migration date * fixed merge conflict * fixed merge conflict * lint fixes * check-types fixes * check-types fixes * fixed type errors * updated docker image for SAML Jackson * added api keys config * added default values for SAML_TENANT_ID and SAML_PRODUCT_ID * - move all env vars related to saml into a separate file for easy access - added SAML_ADMINS comma separated list of emails that will be able to configure the SAML metadata * cleanup after merging main * revert mistake during merge * revert mistake during merge * set info text to indicate SAML has been configured. * tweaks to text * tweaks to text * i18n text * i18n text * tweak * use a separate db for saml to avoid Prisma schema being out of sync * use separate docker-compose file for saml * padding tweak * Prepare for implementing SAML login for the hosted solution * WIP: Support for SAML in the hosted solution * teams view has changed, adjusting saml changes accordingly * enabled SAML only for PRO plan * if user was invited and signs in via saml/google then update the user record * WIP: embed saml lib * 302 instead of 307 * no separate docker-compose file for saml * - ogs cleanup - type fixes * fixed types for jackson * cleaned up cors, not needed by the oauth flow * updated jackson to support encryption at rest * updated saml-jackson lib * allow only the required http methods * fixed issue with latest merge with main * - Added instructions for deploying SAML support - Tweaked SAML audience identifier * fixed check for hosted Cal instance * Added a new route to initiate Google and SAML login flows * updated saml-jackson lib (node engine version is now 14.x or above) * moved SAML instructions from Google Docs to a docs file * moved randomString to lib * comment SAML_DATABASE_URL and SAML_ADMINS in .env.example so that default is SAML off. * fixed path to randomString * updated @boxyhq/saml-jackson to v0.3.0 * fixed TS errors * tweaked SAML config UI * fixed types * added e2e test for Google login * setup secrets for Google login test * test for OAuth login buttons (Google and SAML) * enabled saml for the test * added test for SAML config UI * fixed nextauth import * use pkce flow * tweaked NextAuth config for saml * updated saml-jackson * added ability to delete SAML configuration * SAML variables explainers and refactoring * Prevents constant collision * Var name changes * Env explainers * better validation for email Co-authored-by: Omar López <zomars@me.com> * enabled GOOGLE_API_CREDENTIALS in e2e tests (Github Actions secret) * cleanup (will create an issue to handle forgot password for Google and SAML identities) Co-authored-by: Chris <76668588+bytesbuffer@users.noreply.github.com> Co-authored-by: Omar López <zomars@me.com>
384 lines
12 KiB
TypeScript
384 lines
12 KiB
TypeScript
import { MembershipRole } from "@prisma/client";
|
|
import { Prisma } from "@prisma/client";
|
|
import { randomBytes } from "crypto";
|
|
import { z } from "zod";
|
|
|
|
import { BASE_URL } from "@lib/config/constants";
|
|
import { sendTeamInviteEmail } from "@lib/emails/email-manager";
|
|
import { TeamInvite } from "@lib/emails/templates/team-invite-email";
|
|
import { getUserAvailability } from "@lib/queries/availability";
|
|
import { getTeamWithMembers, isTeamAdmin, isTeamOwner } from "@lib/queries/teams";
|
|
import slugify from "@lib/slugify";
|
|
|
|
import { createProtectedRouter } from "@server/createRouter";
|
|
import { getTranslation } from "@server/lib/i18n";
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
export const viewerTeamsRouter = createProtectedRouter()
|
|
// Retrieves team by id
|
|
.query("get", {
|
|
input: z.object({
|
|
teamId: z.number(),
|
|
}),
|
|
async resolve({ ctx, input }) {
|
|
const team = await getTeamWithMembers(input.teamId);
|
|
if (!team?.members.find((m) => m.id === ctx.user.id)) {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a member of this team." });
|
|
}
|
|
const membership = team?.members.find((membership) => membership.id === ctx.user.id);
|
|
return { ...team, membership: { role: membership?.role as MembershipRole } };
|
|
},
|
|
})
|
|
// Returns teams I a member of
|
|
.query("list", {
|
|
async resolve({ ctx }) {
|
|
const memberships = await ctx.prisma.membership.findMany({
|
|
where: {
|
|
userId: ctx.user.id,
|
|
},
|
|
orderBy: { role: "desc" },
|
|
});
|
|
const teams = await ctx.prisma.team.findMany({
|
|
where: {
|
|
id: {
|
|
in: memberships.map((membership) => membership.teamId),
|
|
},
|
|
},
|
|
});
|
|
|
|
return memberships.map((membership) => ({
|
|
role: membership.role,
|
|
accepted: membership.role === "OWNER" ? true : membership.accepted,
|
|
...teams.find((team) => team.id === membership.teamId),
|
|
}));
|
|
},
|
|
})
|
|
.mutation("create", {
|
|
input: z.object({
|
|
name: z.string(),
|
|
}),
|
|
async resolve({ ctx, input }) {
|
|
if (ctx.user.plan === "FREE") {
|
|
throw new TRPCError({ code: "UNAUTHORIZED", message: "You are not a pro user." });
|
|
}
|
|
|
|
const slug = slugify(input.name);
|
|
|
|
const nameCollisions = await ctx.prisma.team.count({
|
|
where: {
|
|
OR: [{ name: input.name }, { slug: slug }],
|
|
},
|
|
});
|
|
|
|
if (nameCollisions > 0)
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Team name already taken." });
|
|
|
|
const createTeam = await ctx.prisma.team.create({
|
|
data: {
|
|
name: input.name,
|
|
slug: slug,
|
|
},
|
|
});
|
|
|
|
await ctx.prisma.membership.create({
|
|
data: {
|
|
teamId: createTeam.id,
|
|
userId: ctx.user.id,
|
|
role: "OWNER",
|
|
accepted: true,
|
|
},
|
|
});
|
|
},
|
|
})
|
|
// Allows team owner to update team metadata
|
|
.mutation("update", {
|
|
input: z.object({
|
|
id: z.number(),
|
|
bio: z.string().optional(),
|
|
name: z.string().optional(),
|
|
logo: z.string().optional(),
|
|
slug: z.string().optional(),
|
|
hideBranding: z.boolean().optional(),
|
|
}),
|
|
async resolve({ ctx, input }) {
|
|
if (!(await isTeamAdmin(ctx.user?.id, input.id))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
|
|
if (input.slug) {
|
|
const userConflict = await ctx.prisma.team.findMany({
|
|
where: {
|
|
slug: input.slug,
|
|
},
|
|
});
|
|
if (userConflict.some((t) => t.id !== input.id)) return;
|
|
}
|
|
await ctx.prisma.team.update({
|
|
where: {
|
|
id: input.id,
|
|
},
|
|
data: {
|
|
name: input.name,
|
|
slug: input.slug,
|
|
logo: input.logo,
|
|
bio: input.bio,
|
|
hideBranding: input.hideBranding,
|
|
},
|
|
});
|
|
},
|
|
})
|
|
.mutation("delete", {
|
|
input: z.object({
|
|
teamId: z.number(),
|
|
}),
|
|
async resolve({ ctx, input }) {
|
|
if (!(await isTeamOwner(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
|
|
// delete all memberships
|
|
await ctx.prisma.membership.deleteMany({
|
|
where: {
|
|
teamId: input.teamId,
|
|
},
|
|
});
|
|
|
|
await ctx.prisma.team.delete({
|
|
where: {
|
|
id: input.teamId,
|
|
},
|
|
});
|
|
},
|
|
})
|
|
// Allows owner to remove member from team
|
|
.mutation("removeMember", {
|
|
input: z.object({
|
|
teamId: z.number(),
|
|
memberId: z.number(),
|
|
}),
|
|
async resolve({ ctx, input }) {
|
|
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
|
|
if (ctx.user?.id === input.memberId)
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You can not remove yourself from a team you own.",
|
|
});
|
|
|
|
await ctx.prisma.membership.delete({
|
|
where: {
|
|
userId_teamId: { userId: input.memberId, teamId: input.teamId },
|
|
},
|
|
});
|
|
},
|
|
})
|
|
.mutation("inviteMember", {
|
|
input: z.object({
|
|
teamId: z.number(),
|
|
usernameOrEmail: z.string(),
|
|
role: z.nativeEnum(MembershipRole),
|
|
language: z.string(),
|
|
sendEmailInvitation: z.boolean(),
|
|
}),
|
|
async resolve({ ctx, input }) {
|
|
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
|
|
const translation = await getTranslation(input.language ?? "en", "common");
|
|
|
|
const team = await ctx.prisma.team.findFirst({
|
|
where: {
|
|
id: input.teamId,
|
|
},
|
|
});
|
|
|
|
if (!team) throw new TRPCError({ code: "NOT_FOUND", message: "Team not found" });
|
|
|
|
const invitee = await ctx.prisma.user.findFirst({
|
|
where: {
|
|
OR: [{ username: input.usernameOrEmail }, { email: input.usernameOrEmail }],
|
|
},
|
|
});
|
|
|
|
if (!invitee) {
|
|
// liberal email match
|
|
const isEmail = (str: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(str);
|
|
|
|
if (!isEmail(input.usernameOrEmail))
|
|
throw new TRPCError({
|
|
code: "NOT_FOUND",
|
|
message: `Invite failed because there is no corresponding user for ${input.usernameOrEmail}`,
|
|
});
|
|
|
|
// valid email given, create User and add to team
|
|
await ctx.prisma.user.create({
|
|
data: {
|
|
email: input.usernameOrEmail,
|
|
invitedTo: input.teamId,
|
|
teams: {
|
|
create: {
|
|
teamId: input.teamId,
|
|
role: input.role as MembershipRole,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const token: string = randomBytes(32).toString("hex");
|
|
|
|
await ctx.prisma.verificationRequest.create({
|
|
data: {
|
|
identifier: input.usernameOrEmail,
|
|
token,
|
|
expires: new Date(new Date().setHours(168)), // +1 week
|
|
},
|
|
});
|
|
|
|
if (ctx?.user?.name && team?.name) {
|
|
const teamInviteEvent: TeamInvite = {
|
|
language: translation,
|
|
from: ctx.user.name,
|
|
to: input.usernameOrEmail,
|
|
teamName: team.name,
|
|
joinLink: `${BASE_URL}/auth/signup?token=${token}&callbackUrl=${BASE_URL + "/settings/teams"}`,
|
|
};
|
|
await sendTeamInviteEmail(teamInviteEvent);
|
|
}
|
|
} else {
|
|
// create provisional membership
|
|
try {
|
|
await ctx.prisma.membership.create({
|
|
data: {
|
|
teamId: input.teamId,
|
|
userId: invitee.id,
|
|
role: input.role as MembershipRole,
|
|
},
|
|
});
|
|
} catch (e) {
|
|
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
|
if (e.code === "P2002") {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "This user is a member of this team / has a pending invitation.",
|
|
});
|
|
}
|
|
} else throw e;
|
|
}
|
|
|
|
// inform user of membership by email
|
|
if (input.sendEmailInvitation && ctx?.user?.name && team?.name) {
|
|
const teamInviteEvent: TeamInvite = {
|
|
language: translation,
|
|
from: ctx.user.name,
|
|
to: input.usernameOrEmail,
|
|
teamName: team.name,
|
|
joinLink: BASE_URL + "/settings/teams",
|
|
};
|
|
|
|
await sendTeamInviteEmail(teamInviteEvent);
|
|
}
|
|
}
|
|
},
|
|
})
|
|
.mutation("acceptOrLeave", {
|
|
input: z.object({
|
|
teamId: z.number(),
|
|
accept: z.boolean(),
|
|
}),
|
|
async resolve({ ctx, input }) {
|
|
if (input.accept) {
|
|
await ctx.prisma.membership.update({
|
|
where: {
|
|
userId_teamId: { userId: ctx.user.id, teamId: input.teamId },
|
|
},
|
|
data: {
|
|
accepted: true,
|
|
},
|
|
});
|
|
} else {
|
|
await ctx.prisma.membership.delete({
|
|
where: {
|
|
userId_teamId: { userId: ctx.user.id, teamId: input.teamId },
|
|
},
|
|
});
|
|
}
|
|
},
|
|
})
|
|
.mutation("changeMemberRole", {
|
|
input: z.object({
|
|
teamId: z.number(),
|
|
memberId: z.number(),
|
|
role: z.nativeEnum(MembershipRole),
|
|
}),
|
|
async resolve({ ctx, input }) {
|
|
if (!(await isTeamAdmin(ctx.user?.id, input.teamId))) throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
|
|
const memberships = await ctx.prisma.membership.findMany({
|
|
where: {
|
|
teamId: input.teamId,
|
|
},
|
|
});
|
|
|
|
const targetMembership = memberships.find((m) => m.userId === input.memberId);
|
|
const myMembership = memberships.find((m) => m.userId === ctx.user.id);
|
|
const teamHasMoreThanOneOwner = memberships.some((m) => m.role === MembershipRole.OWNER);
|
|
|
|
if (myMembership?.role === MembershipRole.ADMIN && targetMembership?.role === MembershipRole.OWNER) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You can not change the role of an owner if you are an admin.",
|
|
});
|
|
}
|
|
|
|
if (!teamHasMoreThanOneOwner) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You can not change the role of the only owner of a team.",
|
|
});
|
|
}
|
|
|
|
if (myMembership?.role === MembershipRole.ADMIN && input.memberId === ctx.user.id) {
|
|
throw new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: "You can not change yourself to a higher role.",
|
|
});
|
|
}
|
|
|
|
await ctx.prisma.membership.update({
|
|
where: {
|
|
userId_teamId: { userId: input.memberId, teamId: input.teamId },
|
|
},
|
|
data: {
|
|
role: input.role,
|
|
},
|
|
});
|
|
},
|
|
})
|
|
.query("getMemberAvailability", {
|
|
input: z.object({
|
|
teamId: z.number(),
|
|
memberId: z.number(),
|
|
timezone: z.string(),
|
|
dateFrom: z.string(),
|
|
dateTo: z.string(),
|
|
}),
|
|
async resolve({ ctx, input }) {
|
|
const team = await isTeamAdmin(ctx.user?.id, input.teamId);
|
|
if (!team) throw new TRPCError({ code: "UNAUTHORIZED" });
|
|
|
|
// verify member is in team
|
|
const members = await ctx.prisma.membership.findMany({
|
|
where: { teamId: input.teamId },
|
|
include: { user: true },
|
|
});
|
|
const member = members?.find((m) => m.userId === input.memberId);
|
|
if (!member) throw new TRPCError({ code: "NOT_FOUND", message: "Member not found" });
|
|
if (!member.user.username)
|
|
throw new TRPCError({ code: "BAD_REQUEST", message: "Member doesn't have a username" });
|
|
|
|
// get availability for this member
|
|
const availability = await getUserAvailability({
|
|
username: member.user.username,
|
|
timezone: input.timezone,
|
|
dateFrom: input.dateFrom,
|
|
dateTo: input.dateTo,
|
|
});
|
|
|
|
return availability;
|
|
},
|
|
});
|