 1a20b0a0c6
			
		
	
	
		1a20b0a0c6
		
			
		
	
	
	
	
		
			
			* 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;
 | |
|     },
 | |
|   });
 |