From f9997a0645dce80df6d3bd8d348fcb467ad6c9ef Mon Sep 17 00:00:00 2001 From: Henry Date: Fri, 25 Oct 2024 21:18:59 +0100 Subject: [PATCH] feat: accept workspace member invite --- src/pages/api/auth/confirm.ts | 85 ++++++++++++++-------- src/server/api/routers/member.ts | 80 ++++++++++---------- src/server/db/repository/member.repo.ts | 26 +++++++ src/server/db/repository/workspace.repo.ts | 1 + src/utils/supabase/api.ts | 14 ++-- src/views/members/index.tsx | 5 ++ 6 files changed, 134 insertions(+), 77 deletions(-) diff --git a/src/pages/api/auth/confirm.ts b/src/pages/api/auth/confirm.ts index fa24d231..9263ac04 100644 --- a/src/pages/api/auth/confirm.ts +++ b/src/pages/api/auth/confirm.ts @@ -1,37 +1,46 @@ import { type EmailOtpType } from "@supabase/supabase-js"; -import type { NextApiRequest, NextApiResponse } from "next"; +import { type NextRequest, NextResponse } from "next/server"; import { createNextClient } from "~/utils/supabase/api"; -function stringOrFirstString(item: string | string[] | undefined) { - return Array.isArray(item) ? item[0] : item; -} +import * as userRepo from "~/server/db/repository/user.repo"; +import * as memberRepo from "~/server/db/repository/member.repo"; -export default async function handler( - req: NextApiRequest, - res: NextApiResponse, -) { +export default async function handler(req: NextRequest) { if (req.method !== "GET") { - res.status(405).appendHeader("Allow", "GET").end(); - return; + return new NextResponse(null, { + status: 405, + headers: { Allow: "GET" }, + }); } - const queryParams = req.query; - const token_hash = stringOrFirstString(queryParams.token_hash); - const type = stringOrFirstString(queryParams.type); - const code = stringOrFirstString(queryParams.code); + if (!req.url) { + return new NextResponse(null, { + status: 400, + }); + } + + const url = new URL(req.url); + const queryParams = Object.fromEntries(url.searchParams.entries()); + + const tokenHash = queryParams.token_hash; + const type = queryParams.type; + const code = queryParams.code; + const memberPublicId = queryParams.memberPublicId; let next = "/error"; let authRes; - if ((token_hash && type) ?? code) { - const db = createNextClient(req, res); + const response = NextResponse.next(); - if (token_hash && type) { + if ((tokenHash && type) ?? code) { + const db = createNextClient(req, response); + + if (tokenHash && type) { authRes = await db.auth.verifyOtp({ type: type as EmailOtpType, - token_hash, + token_hash: tokenHash, }); } @@ -41,29 +50,41 @@ export default async function handler( const user = authRes?.data.user; - if (user?.id) { - const existingUser = await db - .from("user") - .select() - .eq("id", user.id) - .limit(1) - .maybeSingle(); + if (user?.id && user.email) { + const existingUser = await userRepo.getById(db, user.id); - if (!existingUser.data) { - await db.from("user").insert({ id: user.id, email: user.email ?? "" }); + if (!existingUser) { + await userRepo.create(db, { + id: user.id, + email: user.email, + }); + } + } + + if (memberPublicId) { + const member = await memberRepo.getByPublicId(db, memberPublicId); + + if (member?.id) { + await memberRepo.acceptInvite(db, member.id); } } if (authRes?.error) { console.error(authRes.error); } else { - next = stringOrFirstString(queryParams.next) ?? "/"; + next = queryParams.next ?? "/"; } } - res.redirect(next); + const redirectResponse = NextResponse.redirect(new URL(next, req.url)); + + response.headers.getSetCookie().forEach((cookie) => { + redirectResponse.headers.append("Set-Cookie", cookie); + }); + + return redirectResponse; } -// export const runtime = "edge"; -// export const preferredRegion = "lhr1"; -// export const dynamic = "force-dynamic"; +export const runtime = "edge"; +export const preferredRegion = "lhr1"; +export const dynamic = "force-dynamic"; diff --git a/src/server/api/routers/member.ts b/src/server/api/routers/member.ts index 47925768..8e4e49fc 100644 --- a/src/server/api/routers/member.ts +++ b/src/server/api/routers/member.ts @@ -48,7 +48,9 @@ export const memberRouter = createTRPCRouter({ }); } - let invitedUserId: string | null = null; + let invitedUserId: string | undefined; + let hashedToken: string | undefined; + let verificationType: string | undefined; const existingUser = await userRepo.getByEmail(ctx.adminDb, input.email); @@ -58,55 +60,36 @@ export const memberRouter = createTRPCRouter({ const magicLink = await ctx.adminDb.auth.admin.generateLink({ type: "magiclink", email: input.email, + options: { + redirectTo: process.env.WEBSITE_URL, + }, }); - const magicLinkUrl = magicLink.data.properties?.action_link; - - if (!magicLinkUrl) - throw new TRPCError({ - message: `Unable to generate magic link for user with email ${input.email}`, - code: "INTERNAL_SERVER_ERROR", - }); - - await sendEmail( - input.email, - "Invitation to join workspace", - "JOIN_WORKSPACE", - { - magicLinkUrl, - }, - ); + hashedToken = magicLink.data.properties?.hashed_token; + verificationType = magicLink.data.properties?.verification_type; } else { const invite = await ctx.adminDb.auth.admin.generateLink({ type: "invite", email: input.email, + options: { + redirectTo: process.env.WEBSITE_URL, + }, }); - const magicLinkUrl = invite.data.properties?.action_link; + hashedToken = invite.data.properties?.hashed_token; + verificationType = invite.data.properties?.verification_type; - if (!magicLinkUrl) - throw new TRPCError({ - message: `Unable to generate invite link for user with email ${input.email}`, - code: "INTERNAL_SERVER_ERROR", - }); - - await sendEmail( - input.email, - "Invitation to join workspace", - "JOIN_WORKSPACE", - { - magicLinkUrl, - }, - ); - - invitedUserId = invite.data.user?.id ?? null; + const invitedUserAuthId = invite.data.user?.id; const invitedUserEmail = invite.data.user?.email; - if (invitedUserId && invitedUserEmail) - await userRepo.create(ctx.adminDb, { + if (invitedUserAuthId && invitedUserEmail) { + const newUser = await userRepo.create(ctx.adminDb, { email: invitedUserEmail, - id: invitedUserId, + id: invitedUserAuthId, }); + + invitedUserId = newUser?.id; + } } if (!invitedUserId) @@ -115,6 +98,12 @@ export const memberRouter = createTRPCRouter({ code: "INTERNAL_SERVER_ERROR", }); + if (!hashedToken || !verificationType) + throw new TRPCError({ + message: `Unable to generate magic link for user with email ${input.email}`, + code: "INTERNAL_SERVER_ERROR", + }); + const invite = await memberRepo.create(ctx.db, { workspaceId: workspace.id, userId: invitedUserId, @@ -123,6 +112,23 @@ export const memberRouter = createTRPCRouter({ status: "invited", }); + if (!invite) + throw new TRPCError({ + message: `Unable to invite user with email ${input.email}`, + code: "INTERNAL_SERVER_ERROR", + }); + + const magicLoginUrl = `${process.env.WEBSITE_URL}/api/auth/confirm?token_hash=${hashedToken}&type=${verificationType}&memberPublicId=${invite.publicId}`; + + await sendEmail( + input.email, + "Invitation to join workspace", + "JOIN_WORKSPACE", + { + magicLoginUrl, + }, + ); + return invite; }), }); diff --git a/src/server/db/repository/member.repo.ts b/src/server/db/repository/member.repo.ts index a87c8684..999d5c76 100644 --- a/src/server/db/repository/member.repo.ts +++ b/src/server/db/repository/member.repo.ts @@ -28,3 +28,29 @@ export const create = async ( return data; }; + +export const getByPublicId = async ( + db: SupabaseClient, + publicId: string, +) => { + const { data } = await db + .from("workspace_members") + .select() + .eq("publicId", publicId) + .limit(1) + .single(); + + return data; +}; + +export const acceptInvite = async ( + db: SupabaseClient, + id: number, +) => { + const { data } = await db + .from("workspace_members") + .update({ status: "active" }) + .eq("id", id); + + return data; +}; diff --git a/src/server/db/repository/workspace.repo.ts b/src/server/db/repository/workspace.repo.ts index 924f9b14..8f083b07 100644 --- a/src/server/db/repository/workspace.repo.ts +++ b/src/server/db/repository/workspace.repo.ts @@ -80,6 +80,7 @@ export const getByPublicIdWithMembers = async ( members: workspace_members ( publicId, role, + status, user ( id, name, diff --git a/src/utils/supabase/api.ts b/src/utils/supabase/api.ts index 8c45a0d4..5b784103 100644 --- a/src/utils/supabase/api.ts +++ b/src/utils/supabase/api.ts @@ -6,24 +6,22 @@ import { import { RequestCookies } from "@edge-runtime/cookies"; import { type Database } from "~/types/database.types"; -import { type NextApiRequest, type NextApiResponse } from "next"; +import { type NextRequest, NextResponse } from "next/server"; -export function createNextClient(req: NextApiRequest, res: NextApiResponse) { +export function createNextClient(req: NextRequest, res: NextResponse) { const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + process.env.SUPABASE_SERVICE_API_KEY!, { cookies: { get(name: string) { - return req.cookies[name]; + return req.cookies.get(name)?.value; }, set(name: string, value: string, options: CookieOptions) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call - res.appendHeader("Set-Cookie", serialize(name, value, options)); + res.headers.append("Set-Cookie", serialize(name, value, options)); }, remove(name: string, options: CookieOptions) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call - res.appendHeader("Set-Cookie", serialize(name, "", options)); + res.headers.append("Set-Cookie", serialize(name, "", options)); }, }, }, diff --git a/src/views/members/index.tsx b/src/views/members/index.tsx index 0f92b0c7..8704f877 100644 --- a/src/views/members/index.tsx +++ b/src/views/members/index.tsx @@ -98,6 +98,11 @@ export default function MembersPage() { {member.role.charAt(0).toUpperCase() + member.role.slice(1)} + {member.status === "invited" && ( + + Pending + + )}