feat: invite members
This commit is contained in:
@@ -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
42
src/email/sendEmail.tsx
Normal 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;
|
||||
};
|
||||
100
src/email/templates/join-workspace.tsx
Normal file
100
src/email/templates/join-workspace.tsx
Normal 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'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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
128
src/server/api/routers/member.ts
Normal file
128
src/server/api/routers/member.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
22
src/server/db/migrations/0003_naive_secret_warriors.sql
Normal file
22
src/server/db/migrations/0003_naive_secret_warriors.sql
Normal 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 $$;
|
||||
1018
src/server/db/migrations/meta/0003_snapshot.json
Normal file
1018
src/server/db/migrations/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
30
src/server/db/repository/member.repo.ts
Normal file
30
src/server/db/repository/member.repo.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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("");
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
69
src/views/members/components/InviteMemberForm.tsx
Normal file
69
src/views/members/components/InviteMemberForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user