feat: accept workspace member invite

This commit is contained in:
Henry
2024-10-25 21:18:59 +01:00
parent 508d35a0cb
commit f9997a0645
6 changed files with 134 additions and 77 deletions

View File

@@ -1,37 +1,46 @@
import { type EmailOtpType } from "@supabase/supabase-js"; 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"; import { createNextClient } from "~/utils/supabase/api";
function stringOrFirstString(item: string | string[] | undefined) { import * as userRepo from "~/server/db/repository/user.repo";
return Array.isArray(item) ? item[0] : item; import * as memberRepo from "~/server/db/repository/member.repo";
}
export default async function handler( export default async function handler(req: NextRequest) {
req: NextApiRequest,
res: NextApiResponse,
) {
if (req.method !== "GET") { if (req.method !== "GET") {
res.status(405).appendHeader("Allow", "GET").end(); return new NextResponse(null, {
return; status: 405,
headers: { Allow: "GET" },
});
} }
const queryParams = req.query; if (!req.url) {
const token_hash = stringOrFirstString(queryParams.token_hash); return new NextResponse(null, {
const type = stringOrFirstString(queryParams.type); status: 400,
const code = stringOrFirstString(queryParams.code); });
}
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 next = "/error";
let authRes; let authRes;
if ((token_hash && type) ?? code) { const response = NextResponse.next();
const db = createNextClient(req, res);
if (token_hash && type) { if ((tokenHash && type) ?? code) {
const db = createNextClient(req, response);
if (tokenHash && type) {
authRes = await db.auth.verifyOtp({ authRes = await db.auth.verifyOtp({
type: type as EmailOtpType, type: type as EmailOtpType,
token_hash, token_hash: tokenHash,
}); });
} }
@@ -41,29 +50,41 @@ export default async function handler(
const user = authRes?.data.user; const user = authRes?.data.user;
if (user?.id) { if (user?.id && user.email) {
const existingUser = await db const existingUser = await userRepo.getById(db, user.id);
.from("user")
.select()
.eq("id", user.id)
.limit(1)
.maybeSingle();
if (!existingUser.data) { if (!existingUser) {
await db.from("user").insert({ id: user.id, email: user.email ?? "" }); 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) { if (authRes?.error) {
console.error(authRes.error); console.error(authRes.error);
} else { } 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 runtime = "edge";
// export const preferredRegion = "lhr1"; export const preferredRegion = "lhr1";
// export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";

View File

@@ -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); 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({ const magicLink = await ctx.adminDb.auth.admin.generateLink({
type: "magiclink", type: "magiclink",
email: input.email, email: input.email,
options: {
redirectTo: process.env.WEBSITE_URL,
},
}); });
const magicLinkUrl = magicLink.data.properties?.action_link; hashedToken = magicLink.data.properties?.hashed_token;
verificationType = magicLink.data.properties?.verification_type;
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,
},
);
} else { } else {
const invite = await ctx.adminDb.auth.admin.generateLink({ const invite = await ctx.adminDb.auth.admin.generateLink({
type: "invite", type: "invite",
email: input.email, 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) const invitedUserAuthId = invite.data.user?.id;
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 invitedUserEmail = invite.data.user?.email; const invitedUserEmail = invite.data.user?.email;
if (invitedUserId && invitedUserEmail) if (invitedUserAuthId && invitedUserEmail) {
await userRepo.create(ctx.adminDb, { const newUser = await userRepo.create(ctx.adminDb, {
email: invitedUserEmail, email: invitedUserEmail,
id: invitedUserId, id: invitedUserAuthId,
}); });
invitedUserId = newUser?.id;
}
} }
if (!invitedUserId) if (!invitedUserId)
@@ -115,6 +98,12 @@ export const memberRouter = createTRPCRouter({
code: "INTERNAL_SERVER_ERROR", 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, { const invite = await memberRepo.create(ctx.db, {
workspaceId: workspace.id, workspaceId: workspace.id,
userId: invitedUserId, userId: invitedUserId,
@@ -123,6 +112,23 @@ export const memberRouter = createTRPCRouter({
status: "invited", 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; return invite;
}), }),
}); });

View File

@@ -28,3 +28,29 @@ export const create = async (
return data; return data;
}; };
export const getByPublicId = async (
db: SupabaseClient<Database>,
publicId: string,
) => {
const { data } = await db
.from("workspace_members")
.select()
.eq("publicId", publicId)
.limit(1)
.single();
return data;
};
export const acceptInvite = async (
db: SupabaseClient<Database>,
id: number,
) => {
const { data } = await db
.from("workspace_members")
.update({ status: "active" })
.eq("id", id);
return data;
};

View File

@@ -80,6 +80,7 @@ export const getByPublicIdWithMembers = async (
members: workspace_members ( members: workspace_members (
publicId, publicId,
role, role,
status,
user ( user (
id, id,
name, name,

View File

@@ -6,24 +6,22 @@ import {
import { RequestCookies } from "@edge-runtime/cookies"; import { RequestCookies } from "@edge-runtime/cookies";
import { type Database } from "~/types/database.types"; 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<Database>( const supabase = createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, process.env.SUPABASE_SERVICE_API_KEY!,
{ {
cookies: { cookies: {
get(name: string) { get(name: string) {
return req.cookies[name]; return req.cookies.get(name)?.value;
}, },
set(name: string, value: string, options: CookieOptions) { set(name: string, value: string, options: CookieOptions) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call res.headers.append("Set-Cookie", serialize(name, value, options));
res.appendHeader("Set-Cookie", serialize(name, value, options));
}, },
remove(name: string, options: CookieOptions) { remove(name: string, options: CookieOptions) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call res.headers.append("Set-Cookie", serialize(name, "", options));
res.appendHeader("Set-Cookie", serialize(name, "", options));
}, },
}, },
}, },

View File

@@ -98,6 +98,11 @@ export default function MembersPage() {
{member.role.charAt(0).toUpperCase() + {member.role.charAt(0).toUpperCase() +
member.role.slice(1)} member.role.slice(1)}
</span> </span>
{member.status === "invited" && (
<span className="ml-2 inline-flex items-center rounded-md bg-gray-500/10 px-1.5 py-0.5 text-[11px] font-medium text-gray-400 ring-1 ring-inset ring-gray-500/20">
Pending
</span>
)}
</div> </div>
</td> </td>
</tr> </tr>