feat: invite members

This commit is contained in:
Henry
2024-10-17 21:37:51 +01:00
parent 9291ad183d
commit 936e554e93
22 changed files with 1588 additions and 48 deletions

BIN
bun.lockb

Binary file not shown.

View File

@@ -19,7 +19,7 @@
"@edge-runtime/cookies": "^4.1.1",
"@headlessui/react": "^1.7.17",
"@hookform/resolvers": "^3.3.4",
"@react-email/render": "^0.0.10",
"@react-email/render": "^1.0.1",
"@supabase/ssr": "^0.3.0",
"@supabase/supabase-js": "^2.42.0",
"@t3-oss/env-nextjs": "^0.9.2",

42
src/email/sendEmail.tsx Normal file
View File

@@ -0,0 +1,42 @@
import MagicLinkTemplate from "~/email/templates/magic-link";
import JoinWorkspaceTemplate from "~/email/templates/join-workspace";
import { render } from "@react-email/render";
type Templates = "MAGIC_LINK" | "JOIN_WORKSPACE";
const emailTemplates: Record<Templates, React.FC> = {
MAGIC_LINK: MagicLinkTemplate,
JOIN_WORKSPACE: JoinWorkspaceTemplate,
};
export const sendEmail = async (
to: string,
subject: string,
template: Templates,
data: Record<string, string>,
) => {
const EmailTemplate = emailTemplates[template];
const html = await render(<EmailTemplate {...data} />, { pretty: true });
const response = await fetch(process.env.EMAIL_URL!, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.EMAIL_TOKEN}`,
},
body: JSON.stringify({
from: process.env.EMAIL_FROM,
to,
subject,
html,
}),
});
if (!response.ok) {
throw new Error(`Failed to send email: ${response.statusText}`);
}
return response;
};

View File

@@ -0,0 +1,100 @@
import {
Body,
Button,
Container,
Head,
Heading,
Html,
Hr,
Link,
Preview,
Tailwind,
Text,
} from "@react-email/components";
import * as React from "react";
export const JoinWorkspaceTemplate = ({
magicLoginUrl,
}: {
magicLoginUrl?: string;
}) => (
<Tailwind
config={{
theme: {
extend: {
fontFamily: {
body: [
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Oxygen",
"Ubuntu",
"Cantarell",
"Fira Sans",
"Droid Sans",
"Helvetica Neue",
"sans-serif",
],
},
colors: {
"dark-50": "#161616",
"dark-100": "#1c1c1c",
"dark-200": "#232323",
"dark-300": "#282828",
"dark-400": "#2e2e2e",
"dark-500": "#343434",
"dark-600": "#3e3e3e",
"dark-700": "#505050",
"dark-800": "#707070",
"dark-900": "#7e7e7e",
"dark-950": "#bbb",
"dark-1000": "#ededed",
},
},
},
}}
>
<Html>
<Head />
<Preview>Log in with this magic link</Preview>
<Body className="bg-white">
<Container className="font-body m-auto px-3">
<Heading className="my-10 text-[24px] font-bold text-dark-200">
kan.bn
</Heading>
<Heading className="text-[24px] font-bold text-dark-200">
Login to your Kan account
</Heading>
<Text className="font-sm mb-8 text-dark-200">
Click the button below to instantly login to your account.
</Text>
<Button
target="_blank"
href={magicLoginUrl}
className="mb-8 rounded-md bg-dark-300 px-6 py-4 text-sm font-medium leading-4 text-white"
>
Login to your account
</Button>
<Text className="mb-4 text-sm text-dark-900">
If you didn&apos;t try to login, you can safely ignore this email.
</Text>
<Hr className="mb-8 mt-10 border" />
<Text className="text-dark-900">
<Link
href={process.env.WEBSITE_URL}
target="_blank"
className="text-dark-900 underline"
>
Kan
</Link>
, the open source Trello alternative.
</Text>
</Container>
</Body>
</Html>
</Tailwind>
);
export default JoinWorkspaceTemplate;

View File

@@ -18,7 +18,7 @@ interface MagicLinkEmailProps {
loginUrl?: string;
}
export const MagicLinkEmail = ({ loginUrl }: MagicLinkEmailProps) => (
export const MagicLinkTemplate = ({ loginUrl }: MagicLinkEmailProps) => (
<Tailwind
config={{
theme: {
@@ -97,4 +97,4 @@ export const MagicLinkEmail = ({ loginUrl }: MagicLinkEmailProps) => (
</Tailwind>
);
export default MagicLinkEmail;
export default MagicLinkTemplate;

View File

@@ -3,6 +3,7 @@ import { boardRouter } from "~/server/api/routers/board";
import { cardRouter } from "~/server/api/routers/card";
import { labelRouter } from "~/server/api/routers/label";
import { listRouter } from "~/server/api/routers/list";
import { memberRouter } from "~/server/api/routers/member";
import { importRouter } from "~/server/api/routers/import";
import { workspaceRouter } from "~/server/api/routers/workspace";
import { createTRPCRouter } from "~/server/api/trpc";
@@ -18,6 +19,7 @@ export const appRouter = createTRPCRouter({
card: cardRouter,
label: labelRouter,
list: listRouter,
member: memberRouter,
import: importRouter,
workspace: workspaceRouter,
});

View File

@@ -0,0 +1,128 @@
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import * as workspaceRepo from "~/server/db/repository/workspace.repo";
import * as memberRepo from "~/server/db/repository/member.repo";
import * as userRepo from "~/server/db/repository/user.repo";
import { sendEmail } from "~/email/sendEmail";
export const memberRouter = createTRPCRouter({
invite: protectedProcedure
.input(
z.object({
email: z.string().email(),
workspacePublicId: z.string().min(12),
}),
)
.mutation(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const workspace = await workspaceRepo.getByPublicIdWithMembers(
ctx.db,
input.workspacePublicId,
);
if (!workspace)
throw new TRPCError({
message: `Workspace with public ID ${input.workspacePublicId} not found`,
code: "NOT_FOUND",
});
const isInvitedEmailAlreadyMember = workspace?.members.some(
(member) => member.user?.email === input.email,
);
if (isInvitedEmailAlreadyMember) {
throw new TRPCError({
message: `User with email ${input.email} is already a member of this workspace`,
code: "BAD_REQUEST",
});
}
let invitedUserId: string | null = null;
const existingUser = await userRepo.getByEmail(ctx.adminDb, input.email);
if (existingUser) {
invitedUserId = existingUser.id;
const magicLink = await ctx.adminDb.auth.admin.generateLink({
type: "magiclink",
email: input.email,
});
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,
},
);
} else {
const invite = await ctx.adminDb.auth.admin.generateLink({
type: "invite",
email: input.email,
});
const magicLinkUrl = invite.data.properties?.action_link;
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 invitedUserEmail = invite.data.user?.email;
if (invitedUserId && invitedUserEmail)
userRepo.create(ctx.adminDb, {
email: invitedUserEmail,
id: invitedUserId,
});
}
if (!invitedUserId)
throw new TRPCError({
message: `Unable to invite user with email ${input.email}`,
code: "INTERNAL_SERVER_ERROR",
});
const invite = await memberRepo.create(ctx.db, {
workspaceId: workspace.id,
userId: invitedUserId,
createdBy: userId,
role: "member",
status: "invited",
});
return invite;
}),
});

View File

@@ -11,7 +11,7 @@ import { type FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import superjson from "superjson";
import { ZodError } from "zod";
import { createTRPCClient } from "~/utils/supabase/api";
import { createTRPCClient, createTRPCAdminClient } from "~/utils/supabase/api";
import { type Database } from "~/types/database.types";
import { type SupabaseClient } from "@supabase/supabase-js";
@@ -30,6 +30,7 @@ type User = {
interface CreateContextOptions {
user: User | null;
db: SupabaseClient<Database>;
adminDb: SupabaseClient<Database>;
}
/**
@@ -47,6 +48,7 @@ export const createInnerTRPCContext = (opts: CreateContextOptions) => {
return {
user: opts.user,
db: opts.db,
adminDb: opts.adminDb,
};
};
@@ -61,12 +63,13 @@ export const createTRPCContext = async ({
resHeaders,
}: FetchCreateContextFnOptions) => {
const db = createTRPCClient(req, resHeaders);
const adminDb = createTRPCAdminClient();
const {
data: { user },
} = await db.auth.getUser();
return createInnerTRPCContext({ db, user });
return createInnerTRPCContext({ db, adminDb, user });
};
/**

View File

@@ -0,0 +1,22 @@
DO $$ BEGIN
CREATE TYPE "member_status" AS ENUM('invited', 'active', 'removed');
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
ALTER TABLE "_card_workspace_members" DROP CONSTRAINT "_card_workspace_members_cardId_card_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_labels" DROP CONSTRAINT "_card_labels_cardId_card_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace_members" ADD COLUMN "status" "member_status" DEFAULT 'invited' NOT NULL;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_workspace_members" ADD CONSTRAINT "_card_workspace_members_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@
"when": 1724967733894,
"tag": "0002_clever_robin_chapel",
"breakpoints": true
},
{
"idx": 3,
"version": "5",
"when": 1728246215706,
"tag": "0003_naive_secret_warriors",
"breakpoints": true
}
]
}
}

View File

@@ -0,0 +1,30 @@
import { generateUID } from "~/utils/generateUID";
import { type Database } from "~/types/database.types";
import { type SupabaseClient } from "@supabase/supabase-js";
export const create = async (
db: SupabaseClient<Database>,
memberInput: {
userId: string;
workspaceId: number;
createdBy: string;
role: "admin" | "member" | "guest";
status: "invited" | "active" | "removed";
},
) => {
const { data } = await db
.from("workspace_members")
.insert({
publicId: generateUID(),
userId: memberInput.userId,
workspaceId: memberInput.workspaceId,
createdBy: memberInput.createdBy,
role: memberInput.role,
status: memberInput.status,
})
.select(`id, publicId`)
.limit(1)
.single();
return data;
};

View File

@@ -11,3 +11,31 @@ export const getById = async (db: SupabaseClient<Database>, userId: string) => {
return data;
};
export const getByEmail = async (
db: SupabaseClient<Database>,
email: string,
) => {
const { data } = await db
.from("user")
.select(`id, name, email`)
.eq("email", email)
.limit(1)
.single();
return data;
};
export const create = async (
db: SupabaseClient<Database>,
user: { id: string; email: string },
) => {
const { data } = await db
.from("user")
.insert({ id: user.id, email: user.email })
.select()
.limit(1)
.single();
return data;
};

View File

@@ -75,6 +75,7 @@ export const getByPublicIdWithMembers = async (
.from("workspace")
.select(
`
id,
publicId,
members: workspace_members (
publicId,
@@ -89,6 +90,7 @@ export const getByPublicIdWithMembers = async (
)
.eq("publicId", workspacePublicId)
.is("deletedAt", null)
.is("members.deletedAt", null)
.limit(1)
.single();
@@ -99,7 +101,7 @@ export const getAllByUserId = async (
db: SupabaseClient<Database>,
userId: string,
) => {
const { data } = await db
const { data, error } = await db
.from("workspace_members")
.select(
`
@@ -113,6 +115,8 @@ export const getAllByUserId = async (
.eq("userId", userId)
.is("deletedAt", null);
console.log({ error });
return data;
};

View File

@@ -19,6 +19,11 @@ export const importStatusEnum = pgEnum("status", [
"failed",
]);
export const memberRoleEnum = pgEnum("role", ["admin", "member", "guest"]);
export const memberStatusEnum = pgEnum("member_status", [
"invited",
"active",
"removed",
]);
export const boards = pgTable("board", {
id: bigserial("id", { mode: "number" }).primaryKey(),
@@ -294,6 +299,7 @@ export const workspaceMembers = pgTable("workspace_members", {
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
role: memberRoleEnum("role").notNull(),
status: memberStatusEnum("status").default("invited").notNull(),
});
export const usersToWorkspacesRelations = relations(

View File

@@ -458,6 +458,7 @@ export type Database = {
id: number
publicId: string
role: Database["public"]["Enums"]["role"]
status: Database["public"]["Enums"]["member_status"]
updatedAt: string | null
userId: string
workspaceId: number
@@ -469,6 +470,7 @@ export type Database = {
id?: number
publicId: string
role: Database["public"]["Enums"]["role"]
status?: Database["public"]["Enums"]["member_status"]
updatedAt?: string | null
userId: string
workspaceId: number
@@ -480,6 +482,7 @@ export type Database = {
id?: number
publicId?: string
role?: Database["public"]["Enums"]["role"]
status?: Database["public"]["Enums"]["member_status"]
updatedAt?: string | null
userId?: string
workspaceId?: number
@@ -548,9 +551,11 @@ export type Database = {
}
}
Enums: {
member_status: "invited" | "active" | "removed"
role: "admin" | "member" | "guest"
source: "trello"
status: "started" | "success" | "failed"
workspace_invite_status: "pending" | "accepted" | "cancelled"
}
CompositeTypes: {
[_ in never]: never

View File

@@ -8,3 +8,4 @@ export type NewLabelInput = RouterInputs["label"]["create"];
export type NewListInput = RouterInputs["list"]["create"];
export type NewCardInput = RouterInputs["card"]["create"];
export type NewBoardInput = RouterInputs["board"]["create"];
export type InviteMemberInput = RouterInputs["member"]["invite"];

View File

@@ -6,3 +6,25 @@ export const formatToArray = (
}
return value ? [value] : [];
};
export const inferInitialsFromEmail = (email: string) => {
const localPart = email.split("@")[0];
if (!localPart) return "";
const separators = /[._-]/;
const parts = localPart.split(separators);
if (parts.length > 1) {
return (
(parts[0]?.[0] ?? "") + (parts[parts.length - 1]?.[0] ?? "")
).toUpperCase();
} else {
return localPart.slice(0, 2).toUpperCase();
}
};
export const getInitialsFromName = (name: string) => {
return name
.split(" ")
.map((namePart) => namePart.charAt(0).toUpperCase())
.join("");
};

View File

@@ -56,3 +56,13 @@ export function createTRPCClient(req: Request, resHeaders: Headers) {
return supabase;
}
export function createTRPCAdminClient() {
const supabase = createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_API_KEY!,
{ cookies: {} },
);
return supabase;
}

View File

@@ -0,0 +1,69 @@
import { api } from "~/utils/api";
import { HiXMark } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { useForm } from "react-hook-form";
import { type InviteMemberInput } from "~/types/router.types";
export function InviteMemberForm() {
const utils = api.useUtils();
const { closeModal } = useModal();
const { workspace } = useWorkspace();
const { register, handleSubmit } = useForm<InviteMemberInput>({
defaultValues: {
email: "",
workspacePublicId: workspace?.publicId || "",
},
});
const refetchBoards = () => utils.board.all.refetch();
const createBoard = api.member.invite.useMutation({
onSuccess: async () => {
closeModal();
await utils.workspace.byId.refetch();
await refetchBoards();
},
});
const onSubmit = (data: InviteMemberInput) => {
createBoard.mutate(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div className="px-5 pt-5">
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
<h2 className="text-sm font-bold">Add member</h2>
<button
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
e.preventDefault();
closeModal();
}}
>
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
</button>
</div>
<input
id="email"
placeholder="Email"
{...register("email", { required: true })}
className="block w-full rounded-md border-0 bg-white/5 py-1.5 text-neutral-900 placeholder-dark-800 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:bg-dark-300 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<button
type="submit"
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
>
Invite member
</button>
</div>
</div>
</form>
);
}

View File

@@ -4,11 +4,12 @@ import { useWorkspace } from "~/providers/workspace";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { InviteMemberForm } from "./components/InviteMemberForm";
import { api } from "~/utils/api";
import { getInitialsFromName, inferInitialsFromEmail } from "~/utils/helpers";
export default function MembersPage() {
const { modalContentType } = useModal();
const { modalContentType, openModal } = useModal();
const { workspace } = useWorkspace();
const { data } = api.workspace.byId.useQuery(
@@ -26,7 +27,7 @@ export default function MembersPage() {
<button
type="button"
className="flex items-center gap-x-1.5 rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 dark:bg-dark-1000 dark:text-dark-50"
// onClick={() => openModal("")}
onClick={() => openModal("INVITE_MEMBER")}
>
<div className="h-5 w-5 items-center">
<HiOutlinePlusSmall
@@ -61,46 +62,47 @@ export default function MembersPage() {
</tr>
</thead>
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
{data?.members.map((member) => (
<tr key={member.publicId}>
<td>
<div className="flex items-center p-4">
<div className="flex-shrink-0">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400">
<span className="text-sm font-medium leading-none text-white">
{member.user?.name
?.split(" ")
.map((namePart) =>
namePart.charAt(0).toUpperCase(),
)
.join("")}
{data?.members.map((member) => {
const initials = member.user?.name
? getInitialsFromName(member.user.name)
: inferInitialsFromEmail(member.user?.email ?? "");
return (
<tr key={member.publicId}>
<td>
<div className="flex items-center p-4">
<div className="flex-shrink-0">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-light-1000 dark:bg-dark-400">
<span className="text-sm font-medium leading-none text-white">
{initials}
</span>
</span>
</span>
</div>
<div className="ml-2 min-w-0 flex-1">
<div>
<div className="flex items-center">
<p className="mr-2 text-sm font-medium text-neutral-900 dark:text-dark-1000">
{member.user?.name}
</div>
<div className="ml-2 min-w-0 flex-1">
<div>
<div className="flex items-center">
<p className="mr-2 text-sm font-medium text-neutral-900 dark:text-dark-1000">
{member.user?.name}
</p>
</div>
<p className="truncate text-sm text-dark-900">
{member.user?.email}
</p>
</div>
<p className="truncate text-sm text-dark-900">
{member.user?.email}
</p>
</div>
</div>
</div>
</td>
<td>
<div className="px-3">
<span className="inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20">
{member.role.charAt(0).toUpperCase() +
member.role.slice(1)}
</span>
</div>
</td>
</tr>
))}
</td>
<td>
<div className="px-3">
<span className="inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20">
{member.role.charAt(0).toUpperCase() +
member.role.slice(1)}
</span>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
@@ -110,6 +112,7 @@ export default function MembersPage() {
<Modal>
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
{modalContentType === "INVITE_MEMBER" && <InviteMemberForm />}
</Modal>
</div>
);

View File

@@ -75,6 +75,19 @@ AS $$
WHERE "listId" = list_id AND index >= card_index AND "deletedAt" IS NULL;
$$;
CREATE OR REPLACE FUNCTION is_workspace_admin(user_id UUID, workspace_id BIGINT)
RETURNS BOOLEAN
LANGUAGE SQL
AS $$
SELECT EXISTS (
SELECT 1
FROM workspace_members
WHERE "workspaceId" = workspace_id
AND "userId" = user_id
AND "role" = 'admin'
);
$$;
alter table "_card_labels" enable row level security;
alter table "_card_workspace_members" enable row level security;
alter table "board" enable row level security;
@@ -255,15 +268,42 @@ FOR INSERT
TO authenticated
USING (true);
CREATE POLICY "Allow access to user's own workspace membership"
CREATE POLICY "Allow members to view workspace membership"
ON public.workspace_members
AS PERMISSIVE
FOR ALL
FOR SELECT
TO authenticated
USING (
"userId" = auth.uid()
"userId" = auth.uid() OR
is_workspace_admin(auth.uid(), "workspaceId")
);
CREATE POLICY "Allow admins to add workspace members"
ON public.workspace_members
AS PERMISSIVE
FOR INSERT
TO authenticated
WITH CHECK (
is_workspace_admin(auth.uid(), "workspaceId")
);
CREATE POLICY "Allow admins to update workspace members"
ON public.workspace_members
AS PERMISSIVE
FOR UPDATE
TO authenticated
USING (
is_workspace_admin(auth.uid(), "workspaceId")
);
CREATE POLICY "Allow admins to remove workspace members"
ON public.workspace_members
AS PERMISSIVE
FOR DELETE
TO authenticated
USING (
is_workspace_admin(auth.uid(), "workspaceId")
);
CREATE POLICY "Allow access to user's own imports"
ON public.import
AS PERMISSIVE