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 { 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";

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);
@@ -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;
}),
});

View File

@@ -28,3 +28,29 @@ export const create = async (
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 (
publicId,
role,
status,
user (
id,
name,

View File

@@ -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<Database>(
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));
},
},
},

View File

@@ -98,6 +98,11 @@ export default function MembersPage() {
{member.role.charAt(0).toUpperCase() +
member.role.slice(1)}
</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>
</td>
</tr>