feat: accept workspace member invite
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -80,6 +80,7 @@ export const getByPublicIdWithMembers = async (
|
||||
members: workspace_members (
|
||||
publicId,
|
||||
role,
|
||||
status,
|
||||
user (
|
||||
id,
|
||||
name,
|
||||
|
||||
@@ -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));
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user