feat: invite workspace members via link (#196)
* feat: add workspace invite links schema * feat: update workspace invite schema * feat: add repo funcs and share link toggle * feat: redirect to next param on authentication * feat: add invite page * feat: switch to workspace on invite success * refactor: tweak light mode styles * feat(cloud): update subscription for cloud * feat: only allow admin users to create links * chore: add translations
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
||||
import {
|
||||
generateUID,
|
||||
getSubscriptionByPlan,
|
||||
hasUnlimitedSeats,
|
||||
} from "@kan/shared/utils";
|
||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
|
||||
export const memberRouter = createTRPCRouter({
|
||||
@@ -241,4 +246,379 @@ export const memberRouter = createTRPCRouter({
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
getActiveInviteLink: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get active invite link for workspace",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/invite",
|
||||
description: "Gets the active invite link for a workspace",
|
||||
tags: ["Invites"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
inviteCode: z.string().optional(),
|
||||
inviteLink: z.string().optional(),
|
||||
isActive: z.boolean(),
|
||||
expiresAt: z.date().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Check if user is in workspace
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
|
||||
// Get active invite link for this workspace
|
||||
const activeInviteLink = await inviteLinkRepo.getActiveForWorkspace(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (
|
||||
activeInviteLink &&
|
||||
(!activeInviteLink.expiresAt || new Date() < activeInviteLink.expiresAt)
|
||||
) {
|
||||
return {
|
||||
id: activeInviteLink.id,
|
||||
inviteCode: activeInviteLink.code,
|
||||
inviteLink: `${process.env.NEXT_PUBLIC_BASE_URL}/invite/${activeInviteLink.code}`,
|
||||
isActive: true,
|
||||
expiresAt: activeInviteLink.expiresAt ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { isActive: false };
|
||||
}),
|
||||
createInviteLink: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create invite link for workspace",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/invites",
|
||||
description: "Create invite link for a workspace",
|
||||
tags: ["Invites"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
publicId: z.string().min(12),
|
||||
inviteCode: z.string(),
|
||||
inviteLink: z.string(),
|
||||
expiresAt: z.date().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Check if user is in workspace
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
||||
|
||||
// Deactivate any existing active invite links
|
||||
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
|
||||
workspaceId: workspace.id,
|
||||
updatedBy: userId,
|
||||
});
|
||||
|
||||
// Generate new invite code
|
||||
const inviteCode = generateUID();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||
|
||||
// Create new invite link
|
||||
const inviteLink = await inviteLinkRepo.createInviteLink(ctx.db, {
|
||||
workspaceId: workspace.id,
|
||||
code: inviteCode,
|
||||
expiresAt,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (!inviteLink) {
|
||||
throw new TRPCError({
|
||||
message: `Failed to create invite link`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
publicId: inviteLink.publicId,
|
||||
inviteCode: inviteLink.code,
|
||||
inviteLink: `${process.env.NEXT_PUBLIC_BASE_URL}/invite/${inviteLink.code}`,
|
||||
expiresAt: inviteLink.expiresAt,
|
||||
};
|
||||
}),
|
||||
deactivateInviteLink: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Deactivate invite link for workspace",
|
||||
method: "DELETE",
|
||||
path: "/workspaces/{workspacePublicId}/invites",
|
||||
description: "Deactivates the invite link for a workspace",
|
||||
tags: ["Invites"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
success: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Check if user is in workspace
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
||||
|
||||
// Deactivate all active invite links
|
||||
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
|
||||
workspaceId: workspace.id,
|
||||
updatedBy: userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
getInviteByCode: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get invite information by code",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/invites/{inviteCode}",
|
||||
description: "Get invite information by invite code",
|
||||
tags: ["Invites"],
|
||||
protect: false,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
inviteCode: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z
|
||||
.object({
|
||||
publicId: z.string().min(12),
|
||||
status: z.string(),
|
||||
expiresAt: z.date().nullable(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const invite = await inviteLinkRepo.getByCode(ctx.db, input.inviteCode);
|
||||
|
||||
if (
|
||||
!invite ||
|
||||
invite.status !== "active" ||
|
||||
(invite.expiresAt && new Date() > invite.expiresAt)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
message: `Invalid or expired invite link`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
publicId: invite.publicId,
|
||||
status: invite.status,
|
||||
expiresAt: invite.expiresAt ?? null,
|
||||
};
|
||||
}),
|
||||
acceptInviteLink: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Accept an invite link",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/invites/accept",
|
||||
description: "Accepts an invitation via invite link",
|
||||
tags: ["Invites"],
|
||||
protect: false,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
inviteCode: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
success: z.boolean(),
|
||||
workspacePublicId: z.string().optional(),
|
||||
workspaceSlug: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const invite = await inviteLinkRepo.getByCode(ctx.db, input.inviteCode);
|
||||
|
||||
if (
|
||||
!invite ||
|
||||
invite.status !== "active" ||
|
||||
(invite.expiresAt && new Date() > invite.expiresAt)
|
||||
)
|
||||
throw new TRPCError({
|
||||
message: `Invalid or expired invite link`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getById(ctx.db, invite.workspaceId);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const isMember = await workspaceRepo.isUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
invite.workspaceId,
|
||||
);
|
||||
|
||||
if (isMember) {
|
||||
throw new TRPCError({
|
||||
message: `User is already a member of this workspace`,
|
||||
code: "CONFLICT",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await userRepo.getById(ctx.db, userId);
|
||||
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: `User not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
||||
const subscriptions = await subscriptionRepo.getByReferenceId(
|
||||
ctx.db,
|
||||
workspace.publicId,
|
||||
);
|
||||
|
||||
// get the active subscriptions
|
||||
const activeTeamSubscription = getSubscriptionByPlan(
|
||||
subscriptions,
|
||||
"team",
|
||||
);
|
||||
const activeProSubscription = getSubscriptionByPlan(
|
||||
subscriptions,
|
||||
"pro",
|
||||
);
|
||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
||||
|
||||
if (!activeTeamSubscription && !activeProSubscription) {
|
||||
throw new TRPCError({
|
||||
message: `Workspace with public ID ${workspace.publicId} does not have an active subscription`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// Update the Stripe subscription
|
||||
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
||||
try {
|
||||
await updateSubscriptionSeats(
|
||||
activeTeamSubscription.stripeSubscriptionId,
|
||||
1,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to update Stripe subscription seats:", error);
|
||||
throw new TRPCError({
|
||||
message: `Failed to update subscription for the new member.`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await memberRepo.create(ctx.db, {
|
||||
workspaceId: invite.workspaceId,
|
||||
email: user.email,
|
||||
userId: user.id,
|
||||
createdBy: user.id,
|
||||
role: "member",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workspacePublicId: workspace.publicId,
|
||||
workspaceSlug: workspace.slug,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TYPE "public"."invite_link_status" AS ENUM('active', 'inactive');--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "workspace_invite_links" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"publicId" varchar(12) NOT NULL,
|
||||
"workspaceId" bigint NOT NULL,
|
||||
"code" varchar(12) NOT NULL,
|
||||
"status" "invite_link_status" DEFAULT 'active' NOT NULL,
|
||||
"expiresAt" timestamp,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"createdBy" uuid,
|
||||
"updatedAt" timestamp,
|
||||
"updatedBy" uuid,
|
||||
CONSTRAINT "workspace_invite_links_publicId_unique" UNIQUE("publicId"),
|
||||
CONSTRAINT "workspace_invite_links_code_unique" UNIQUE("code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace_invite_links" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_invite_links" ADD CONSTRAINT "workspace_invite_links_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_invite_links" ADD CONSTRAINT "workspace_invite_links_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_invite_links" ADD CONSTRAINT "workspace_invite_links_updatedBy_user_id_fk" FOREIGN KEY ("updatedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
2766
packages/db/migrations/meta/20250923211958_snapshot.json
Normal file
2766
packages/db/migrations/meta/20250923211958_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,13 @@
|
||||
"when": 1758226671081,
|
||||
"tag": "20250918201751_AddPausedMemberStatus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1758662398166,
|
||||
"tag": "20250923211958_AddWorkspaceInviteLinks",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
71
packages/db/src/repository/inviteLink.repo.ts
Normal file
71
packages/db/src/repository/inviteLink.repo.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { workspaceInviteLinks } from "@kan/db/schema";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
export const createInviteLink = async (
|
||||
db: dbClient,
|
||||
args: {
|
||||
workspaceId: number;
|
||||
code: string;
|
||||
expiresAt: Date | null;
|
||||
createdBy: string;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
.insert(workspaceInviteLinks)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
workspaceId: args.workspaceId,
|
||||
code: args.code,
|
||||
expiresAt: args.expiresAt ?? null,
|
||||
status: "active",
|
||||
createdBy: args.createdBy,
|
||||
})
|
||||
.returning({
|
||||
publicId: workspaceInviteLinks.publicId,
|
||||
code: workspaceInviteLinks.code,
|
||||
status: workspaceInviteLinks.status,
|
||||
expiresAt: workspaceInviteLinks.expiresAt,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const deactivateAllActiveForWorkspace = async (
|
||||
db: dbClient,
|
||||
args: { workspaceId: number; updatedBy: string },
|
||||
) => {
|
||||
await db
|
||||
.update(workspaceInviteLinks)
|
||||
.set({
|
||||
status: "inactive",
|
||||
updatedBy: args.updatedBy,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInviteLinks.workspaceId, args.workspaceId),
|
||||
eq(workspaceInviteLinks.status, "active"),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const getActiveForWorkspace = async (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
) => {
|
||||
return db.query.workspaceInviteLinks.findFirst({
|
||||
where: and(
|
||||
eq(workspaceInviteLinks.workspaceId, workspaceId),
|
||||
eq(workspaceInviteLinks.status, "active"),
|
||||
),
|
||||
orderBy: (links, { desc }) => [desc(links.createdAt)],
|
||||
});
|
||||
};
|
||||
|
||||
export const getByCode = async (db: dbClient, code: string) => {
|
||||
return db.query.workspaceInviteLinks.findFirst({
|
||||
where: eq(workspaceInviteLinks.code, code),
|
||||
});
|
||||
};
|
||||
@@ -87,11 +87,25 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => {
|
||||
publicId: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
slug: true,
|
||||
},
|
||||
where: eq(workspaces.publicId, workspacePublicId),
|
||||
});
|
||||
};
|
||||
|
||||
export const getById = (db: dbClient, workspaceId: number) => {
|
||||
return db.query.workspaces.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
publicId: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
slug: true,
|
||||
},
|
||||
where: eq(workspaces.id, workspaceId),
|
||||
});
|
||||
};
|
||||
|
||||
export const getByPublicIdWithMembers = (
|
||||
db: dbClient,
|
||||
workspacePublicId: string,
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from "./users";
|
||||
export * from "./integrations";
|
||||
export * from "./workspaces";
|
||||
export * from "./subscriptions";
|
||||
export * from "./workspaceInviteLinks";
|
||||
|
||||
38
packages/db/src/schema/workspaceInviteLinks.ts
Normal file
38
packages/db/src/schema/workspaceInviteLinks.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
bigint,
|
||||
bigserial,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
timestamp,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { users } from "./users";
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
export const inviteLinkStatuses = ["active", "inactive"] as const;
|
||||
export type InviteLinkStatus = (typeof inviteLinkStatuses)[number];
|
||||
export const inviteLinkStatusEnum = pgEnum(
|
||||
"invite_link_status",
|
||||
inviteLinkStatuses,
|
||||
);
|
||||
|
||||
export const workspaceInviteLinks = pgTable("workspace_invite_links", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
workspaceId: bigint("workspaceId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
code: varchar("code", { length: 12 }).notNull().unique(),
|
||||
status: inviteLinkStatusEnum("status").notNull().default("active"),
|
||||
expiresAt: timestamp("expiresAt"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
updatedBy: uuid("updatedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
}).enableRLS();
|
||||
Reference in New Issue
Block a user