Membership settings rework (#198)

* Add refined members list

* futher progress on members settings polish

* Remove old components

* feedback
This commit is contained in:
Brendan Kellam 2025-02-18 11:27:19 -08:00 committed by GitHub
parent e09b21f6b9
commit f652ca526e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1282 additions and 689 deletions

View file

@ -49,6 +49,7 @@
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.6",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tabs": "^1.1.2",

View file

@ -34,7 +34,7 @@ export const withAuth = async <T>(fn: (session: Session) => Promise<T>) => {
return fn(session);
}
export const withOrgMembership = async <T>(session: Session, domain: string, fn: (orgId: number) => Promise<T>) => {
export const withOrgMembership = async <T>(session: Session, domain: string, fn: (params: { orgId: number, userRole: OrgRole }) => Promise<T>, minRequiredRole: OrgRole = OrgRole.MEMBER) => {
const org = await prisma.org.findUnique({
where: {
domain,
@ -58,38 +58,28 @@ export const withOrgMembership = async <T>(session: Session, domain: string, fn:
return notFound();
}
return fn(org.id);
}
export const withOwner = async <T>(session: Session, domain: string, fn: (orgId: number) => Promise<T>) => {
const org = await prisma.org.findUnique({
where: {
domain,
},
});
if (!org) {
return notFound();
const getAuthorizationPrecendence = (role: OrgRole): number => {
switch (role) {
case OrgRole.MEMBER:
return 0;
case OrgRole.OWNER:
return 1;
}
}
const userRole = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId: org.id,
userId: session.user.id,
},
},
});
if (!userRole || userRole.role !== OrgRole.OWNER) {
if (getAuthorizationPrecendence(membership.role) < getAuthorizationPrecendence(minRequiredRole)) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.MEMBER_NOT_OWNER,
message: "Only org owners can perform this action",
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS,
message: "You do not have sufficient permissions to perform this action.",
} satisfies ServiceError;
}
return fn(org.id);
return fn({
orgId: org.id,
userRole: membership.role,
});
}
export const isAuthed = async () => {
@ -126,7 +116,7 @@ export const createOrg = (name: string, domain: string, stripeCustomerId?: strin
export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const secrets = await prisma.secret.findMany({
where: {
orgId,
@ -146,7 +136,7 @@ export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: stri
export const createSecret = async (key: string, value: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
try {
const encrypted = encrypt(value);
await prisma.secret.create({
@ -168,7 +158,7 @@ export const createSecret = async (key: string, value: string, domain: string):
export const deleteSecret = async (key: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
await prisma.secret.delete({
where: {
orgId_key: {
@ -195,7 +185,7 @@ export const getConnections = async (domain: string): Promise<
}[] | ServiceError
> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const connections = await prisma.connection.findMany({
where: {
orgId,
@ -216,7 +206,7 @@ export const getConnections = async (domain: string): Promise<
export const createConnection = async (name: string, type: string, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const parsedConfig = parseConnectionConfig(type, connectionConfig);
if (isServiceError(parsedConfig)) {
return parsedConfig;
@ -238,7 +228,7 @@ export const createConnection = async (name: string, type: string, connectionCon
export const getConnectionInfoAction = async (connectionId: number, domain: string): Promise<{ connection: Connection, linkedRepos: Repo[] } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
@ -255,7 +245,7 @@ export const getConnectionInfoAction = async (connectionId: number, domain: stri
export const getOrgFromDomainAction = async (domain: string): Promise<Org | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
@ -273,7 +263,7 @@ export const getOrgFromDomainAction = async (domain: string): Promise<Org | Serv
export const updateConnectionDisplayName = async (connectionId: number, name: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
@ -296,7 +286,7 @@ export const updateConnectionDisplayName = async (connectionId: number, name: st
export const updateConnectionConfigAndScheduleSync = async (connectionId: number, config: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
@ -335,7 +325,7 @@ export const updateConnectionConfigAndScheduleSync = async (connectionId: number
export const flagConnectionForSync = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const connection = await getConnection(connectionId, orgId);
if (!connection || connection.orgId !== orgId) {
return notFound();
@ -365,7 +355,7 @@ export const flagConnectionForSync = async (connectionId: number, domain: string
export const deleteConnection = async (connectionId: number, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const connection = await getConnection(connectionId, orgId);
if (!connection) {
return notFound();
@ -385,57 +375,98 @@ export const deleteConnection = async (connectionId: number, domain: string): Pr
export const getCurrentUserRole = async (domain: string): Promise<OrgRole | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const userRole = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId,
userId: session.user.id,
},
},
});
if (!userRole) {
return notFound();
}
return userRole.role;
withOrgMembership(session, domain, async ({ userRole }) => {
return userRole;
})
);
export const createInvite = async (email: string, userId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOwner(session, domain, async (orgId) => {
console.log("Creating invite for", email, userId, orgId);
withOrgMembership(session, domain, async ({ orgId }) => {
// Check for existing invites
const existingInvites = await prisma.invite.findMany({
where: {
recipientEmail: {
in: emails
},
orgId,
}
});
if (email === session.user.email) {
console.error("User tried to invite themselves");
if (existingInvites.length > 0) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.SELF_INVITE,
message: "❌ You can't invite yourself to an org",
errorCode: ErrorCode.INVALID_INVITE,
message: `A pending invite already exists for one or more of the provided emails.`,
} satisfies ServiceError;
}
try {
await prisma.invite.create({
data: {
recipientEmail: email,
hostUserId: userId,
orgId,
}
});
} catch (error) {
console.error("Failed to create invite:", error);
return unexpectedError("Failed to create invite");
// Check for members that are already in the org
const existingMembers = await prisma.userToOrg.findMany({
where: {
user: {
email: {
in: emails,
}
},
orgId,
},
});
if (existingMembers.length > 0) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.INVALID_INVITE,
message: `One or more of the provided emails are already members of this org.`,
} satisfies ServiceError;
}
await prisma.$transaction(async (tx) => {
for (const email of emails) {
await tx.invite.create({
data: {
recipientEmail: email,
hostUserId: session.user.id,
orgId,
}
});
}
});
return {
success: true,
}
})
}, /* minRequiredRole = */ OrgRole.OWNER)
);
export const cancelInvite = async (inviteId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const invite = await prisma.invite.findUnique({
where: {
id: inviteId,
orgId,
},
});
if (!invite) {
return notFound();
}
await prisma.invite.delete({
where: {
id: inviteId,
},
});
return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
);
export const redeemInvite = async (invite: Invite, userId: string): Promise<{ success: boolean } | ServiceError> =>
withAuth(async () => {
try {
@ -498,9 +529,9 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su
}
});
export const makeOwner = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
export const transferOwnership = async (newOwnerId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOwner(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const currentUserId = session.user.id;
if (newOwnerId === currentUserId) {
@ -556,7 +587,7 @@ export const makeOwner = async (newOwnerId: string, domain: string): Promise<{ s
return {
success: true,
}
})
}, /* minRequiredRole = */ OrgRole.OWNER)
);
const parseConnectionConfig = (connectionType: string, config: string) => {
@ -702,7 +733,7 @@ export const setupInitialStripeCustomer = async (name: string, domain: string) =
export const getSubscriptionCheckoutRedirect = async (domain: string) =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
@ -762,7 +793,7 @@ export async function fetchStripeSession(sessionId: string) {
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
withAuth((session) =>
withOwner(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
@ -781,7 +812,8 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise<stri
});
return portalSession.url;
}));
}, /* minRequiredRole = */ OrgRole.OWNER)
);
export const fetchSubscription = (domain: string): Promise<Stripe.Subscription | ServiceError> =>
withAuth(async () => {
@ -808,7 +840,7 @@ export const fetchSubscription = (domain: string): Promise<Stripe.Subscription |
export const getSubscriptionBillingEmail = async (domain: string): Promise<string | ServiceError> =>
withAuth(async (session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
@ -830,24 +862,7 @@ export const getSubscriptionBillingEmail = async (domain: string): Promise<strin
export const changeSubscriptionBillingEmail = async (domain: string, newEmail: string): Promise<{ success: boolean } | ServiceError> =>
withAuth((session) =>
withOrgMembership(session, domain, async (orgId) => {
const userRole = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
orgId,
userId: session.user.id,
}
}
});
if (!userRole || userRole.role !== "OWNER") {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.MEMBER_NOT_OWNER,
message: "Only org owners can change billing email",
} satisfies ServiceError;
}
withOrgMembership(session, domain, async ({ orgId }) => {
const org = await prisma.org.findUnique({
where: {
id: orgId,
@ -866,7 +881,7 @@ export const changeSubscriptionBillingEmail = async (domain: string, newEmail: s
return {
success: true,
}
})
}, /* minRequiredRole = */ OrgRole.OWNER)
);
export const checkIfUserHasOrg = async (userId: string): Promise<boolean | ServiceError> => {
@ -890,9 +905,9 @@ export const checkIfOrgDomainExists = async (domain: string): Promise<boolean |
return !!org;
});
export const removeMember = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
export const removeMemberFromOrg = async (memberId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth(async (session) =>
withOrgMembership(session, domain, async (orgId) => {
withOrgMembership(session, domain, async ({ orgId }) => {
const targetMember = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
@ -944,6 +959,61 @@ export const removeMember = async (memberId: string, domain: string): Promise<{
}
});
return {
success: true,
}
}, /* minRequiredRole = */ OrgRole.OWNER)
);
export const leaveOrg = async (domain: string): Promise<{ success: boolean } | ServiceError> =>
withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId, userRole }) => {
if (userRole === OrgRole.OWNER) {
return {
statusCode: StatusCodes.FORBIDDEN,
errorCode: ErrorCode.OWNER_CANNOT_LEAVE_ORG,
message: "Organization owners cannot leave their own organization",
} satisfies ServiceError;
}
const org = await prisma.org.findUnique({
where: {
id: orgId,
},
});
if (!org) {
return notFound();
}
if (org.stripeCustomerId) {
const subscription = await fetchSubscription(domain);
if (isServiceError(subscription)) {
return orgInvalidSubscription();
}
const existingSeatCount = subscription.items.data[0].quantity;
const newSeatCount = (existingSeatCount || 1) - 1;
const stripe = getStripe();
await stripe.subscriptionItems.update(
subscription.items.data[0].id,
{
quantity: newSeatCount,
proration_behavior: 'create_prorations',
}
)
}
await prisma.userToOrg.delete({
where: {
orgId_userId: {
orgId,
userId: session.user.id,
}
}
});
return {
success: true,
}
@ -967,3 +1037,44 @@ export const getSubscriptionData = async (domain: string) =>
}
})
);
export const getOrgMembers = async (domain: string) =>
withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const members = await prisma.userToOrg.findMany({
where: {
orgId,
},
include: {
user: true,
},
});
return members.map((member) => ({
id: member.userId,
email: member.user.email!,
name: member.user.name ?? undefined,
avatarUrl: member.user.image ?? undefined,
role: member.role,
joinedAt: member.joinedAt,
}));
})
);
export const getOrgInvites = async (domain: string) =>
withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => {
const invites = await prisma.invite.findMany({
where: {
orgId,
},
});
return invites.map((invite) => ({
id: invite.id,
email: invite.recipientEmail,
createdAt: invite.createdAt,
}));
})
);

View file

@ -17,7 +17,7 @@ import { useDomain } from "@/hooks/useDomain";
const formSchema = z.object({
key: z.string().min(2).max(40),
value: z.string().min(2).max(40),
value: z.string().min(2),
});
interface SecretsTableProps {
@ -30,18 +30,15 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
const { toast } = useToast();
const domain = useDomain();
const fetchSecretKeys = async () => {
const keys = await getSecrets(domain);
if ('keys' in keys) {
setSecrets(keys);
} else {
console.error(keys);
}
};
useEffect(() => {
fetchSecretKeys();
}, [fetchSecretKeys]);
getSecrets(domain).then((keys) => {
if ('keys' in keys) {
setSecrets(keys);
} else {
console.error(keys);
}
})
}, []);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),

View file

@ -0,0 +1,7 @@
export default async function GeneralSettingsPage() {
return (
<p>todo</p>
)
}

View file

@ -0,0 +1,22 @@
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import clsx from "clsx";
interface HeaderProps {
children: React.ReactNode;
withTopMargin?: boolean;
className?: string;
}
export const Header = ({
children,
withTopMargin = true,
className,
}: HeaderProps) => {
return (
<div className={cn("mb-16", className)}>
{children}
<Separator className={clsx("absolute left-0 right-0", { "mt-12": withTopMargin })} />
</div>
)
}

View file

@ -1,47 +0,0 @@
'use client';
import { useMemo } from "react";
import { DataTable } from "@/components/ui/data-table";
import { InviteColumnInfo, inviteTableColumns } from "./inviteTableColumns"
import { useToast } from "@/components/hooks/use-toast";
export interface InviteInfo {
id: string;
email: string;
createdAt: Date;
}
interface InviteTableProps {
initialInvites: InviteInfo[];
}
export const InviteTable = ({ initialInvites }: InviteTableProps) => {
const { toast } = useToast();
const displayToast = (message: string) => {
toast({
description: message,
});
}
const inviteRows: InviteColumnInfo[] = useMemo(() => {
return initialInvites.map(invite => {
return {
id: invite.id!,
email: invite.email!,
createdAt: invite.createdAt!,
}
})
}, [initialInvites]);
return (
<div className="space-y-2 overflow-x-auto">
<h4 className="text-lg font-normal">Invites</h4>
<DataTable
columns={inviteTableColumns(displayToast)}
data={inviteRows}
searchKey="email"
searchPlaceholder="Search invites..."
/>
</div>
)
}

View file

@ -1,71 +0,0 @@
'use client'
import { Button } from "@/components/ui/button";
import { ColumnDef } from "@tanstack/react-table"
import { resolveServerPath } from "@/app/api/(client)/client";
import { createPathWithQueryParams } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
export type InviteColumnInfo = {
id: string;
email: string;
createdAt: Date;
}
export const inviteTableColumns = (displayToast: (message: string) => void): ColumnDef<InviteColumnInfo>[] => {
return [
{
accessorKey: "email",
cell: ({ row }) => {
const invite = row.original;
return <div>{invite.email}</div>;
}
},
{
accessorKey: "createdAt",
cell: ({ row }) => {
const invite = row.original;
return invite.createdAt.toISOString();
}
},
{
id: "copy",
cell: ({ row }) => {
const invite = row.original;
return (
<Button
variant="ghost"
size="icon"
onClick={() => {
const basePath = `${window.location.origin}${resolveServerPath('/')}`;
const url = createPathWithQueryParams(`${basePath}redeem?invite_id=${invite.id}`);
navigator.clipboard.writeText(url)
.then(() => {
displayToast("✅ Copied invite link");
})
.catch(() => {
displayToast("❌ Failed to copy invite link");
})
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="hover:stroke-gray-600 transition-colors"
>
<rect width="14" height="14" x="8" y="8" rx="2" ry="2" />
<path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" />
</svg>
</Button>
)
}
}
]
}

View file

@ -1,76 +0,0 @@
'use client'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/components/hooks/use-toast";
import { createInvite } from "@/actions"
import { isServiceError } from "@/lib/utils";
import { useDomain } from "@/hooks/useDomain";
import { ErrorCode } from "@/lib/errorCodes";
import { useRouter } from "next/navigation";
import { OrgRole } from "@sourcebot/db";
const formSchema = z.object({
email: z.string().min(2).max(40),
});
export const MemberInviteForm = ({ userId, currentUserRole }: { userId: string, currentUserRole: OrgRole }) => {
const router = useRouter();
const { toast } = useToast();
const domain = useDomain();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
},
});
const handleCreateInvite = async (values: { email: string }) => {
const res = await createInvite(values.email, userId, domain);
if (isServiceError(res)) {
toast({
description: res.errorCode == ErrorCode.SELF_INVITE ? res.message :`❌ Failed to create invite`
});
return;
} else {
toast({
description: `✅ Invite created successfully!`
});
router.refresh();
}
}
const isOwner = currentUserRole === OrgRole.OWNER;
return (
<div className="space-y-2">
<h4 className="text-lg font-normal">Invite a member</h4>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleCreateInvite)}>
<div title={!isOwner ? "Only the owner of the org can invite new members" : undefined}>
<div className={!isOwner ? "opacity-50 pointer-events-none" : ""}>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button className="mt-5" type="submit">Submit</Button>
</div>
</div>
</form>
</Form>
</div>
);
}

View file

@ -1,42 +0,0 @@
'use client';
import { useMemo } from "react";
import { DataTable } from "@/components/ui/data-table";
import { MemberColumnInfo, MemberTableColumns } from "./memberTableColumns";
export interface MemberInfo {
id: string;
name: string;
email: string;
role: string;
}
interface MemberTableProps {
currentUserRole: string;
currentUserId: string;
initialMembers: MemberInfo[];
}
export const MemberTable = ({ currentUserRole, currentUserId, initialMembers }: MemberTableProps) => {
const memberRows: MemberColumnInfo[] = useMemo(() => {
return initialMembers.map(member => {
return {
id: member.id!,
name: member.name!,
email: member.email!,
role: member.role!,
}
})
}, [initialMembers]);
return (
<div className="space-y-2">
<h4 className="text-lg font-normal">Members</h4>
<DataTable
columns={MemberTableColumns(currentUserRole, currentUserId)}
data={memberRows}
searchKey="name"
searchPlaceholder="Search members..."
/>
</div>
)
}

View file

@ -1,199 +0,0 @@
'use client'
import { Button } from "@/components/ui/button"
import { ColumnDef } from "@tanstack/react-table"
import {
Dialog,
DialogContent,
DialogClose,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { removeMember, makeOwner } from "@/actions"
import { useToast } from "@/components/hooks/use-toast"
import { useDomain } from "@/hooks/useDomain";
import { isServiceError } from "@/lib/utils";
import { useRouter } from "next/navigation";
export type MemberColumnInfo = {
id: string;
name: string;
email: string;
role: string;
}
export const MemberTableColumns = (currentUserRole: string, currentUserId: string): ColumnDef<MemberColumnInfo>[] => {
const { toast } = useToast();
const domain = useDomain();
const router = useRouter();
const isOwner = currentUserRole === "OWNER";
return [
{
accessorKey: "name",
cell: ({ row }) => {
const member = row.original;
return <div className={member.id === currentUserId ? "text-blue-600 font-medium" : ""}>{member.name}</div>;
}
},
{
accessorKey: "email",
cell: ({ row }) => {
const member = row.original;
return <div className={member.id === currentUserId ? "text-blue-600 font-medium" : ""}>{member.email}</div>;
}
},
{
accessorKey: "role",
cell: ({ row }) => {
const member = row.original;
return <div className={member.id === currentUserId ? "text-blue-600 font-medium" : ""}>{member.role}</div>;
}
},
{
id: "makeOwner",
cell: ({ row }) => {
const member = row.original;
if (!isOwner || member.id === currentUserId) return null;
return (
<Dialog>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
Make Owner
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-lg font-semibold">Make Owner</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-4">
<p className="font-medium">Are you sure you want to make this member the owner?</p>
<div className="rounded-lg bg-muted p-4">
<p className="text-sm text-muted-foreground">
This action will make <span className="font-semibold text-foreground">{member.email}</span> the owner of your organization.
<br/>
<br/>
You will be demoted to a regular member.
</p>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
variant="default"
onClick={async () => {
const response = await makeOwner(member.id, domain);
if (isServiceError(response)) {
toast({
description: `❌ Failed to switch ownership. ${response.message}`
});
} else {
toast({
description: `✅ Switched ownership successfully.`
});
router.refresh();
}
}}
>
Confirm
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
},
{
id: "remove",
cell: ({ row }) => {
const member = row.original;
if (!isOwner || member.id === currentUserId) {
return null;
}
return (
<Dialog>
<DialogTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hover:bg-destructive/30 transition-colors"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-destructive hover:text-destructive transition-colors"
>
<path d="M3 6h18" />
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6" />
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
</svg>
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle className="text-lg font-semibold">Remove Member</DialogTitle>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-4">
<p className="font-medium">Are you sure you want to remove this member?</p>
<div className="rounded-lg bg-muted p-4">
<p className="text-sm text-muted-foreground">
This action will remove <span className="font-semibold text-foreground">{member.email}</span> from your organization.
<br/>
<br/>
Your subscription&apos;s seat count will be automatically adjusted.
</p>
</div>
</div>
</div>
<DialogFooter className="gap-2">
<DialogClose asChild>
<Button variant="outline">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
variant="destructive"
className="hover:bg-destructive/90"
onClick={async () => {
const response = await removeMember(member.id, domain);
if (isServiceError(response)) {
toast({
description: `❌ Failed to remove member. Reason: ${response.message}`
});
} else {
toast({
description: `✅ Member removed successfully.`
});
router.refresh();
}
}}
>
Remove Member
</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
}
]
}

View file

@ -2,7 +2,6 @@
import Link from "next/link"
import { usePathname } from "next/navigation"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
@ -19,7 +18,7 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
return (
<nav
className={cn(
"flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1",
"flex flex-col space-x-2 lg:space-x-0 lg:space-y-1",
className
)}
{...props}

View file

@ -1,7 +1,7 @@
import { Metadata } from "next"
import { Separator } from "@/components/ui/separator"
import { SidebarNav } from "./components/sidebar-nav"
import { NavigationMenu } from "../components/navigationMenu"
import { Header } from "./components/header";
export const metadata: Metadata = {
title: "Settings",
}
@ -15,31 +15,33 @@ export default function SettingsLayout({
}>) {
const sidebarNavItems = [
{
title: "Members",
title: "General",
href: `/${domain}/settings`,
},
{
title: "Billing",
href: `/${domain}/settings/billing`,
},
{
title: "Members",
href: `/${domain}/settings/members`,
}
]
return (
<div>
<div className="min-h-screen flex flex-col">
<NavigationMenu domain={domain} />
<div className="hidden space-y-6 p-10 pb-16 md:block">
<div className="space-y-0.5">
<h2 className="text-2xl font-bold tracking-tight">Settings</h2>
<p className="text-muted-foreground">
Manage your organization settings.
</p>
</div>
<Separator className="my-6" />
<div className="flex flex-col space-y-8 lg:flex-row lg:space-x-8 lg:space-y-0">
<aside className="-mx-4 lg:w-48">
<SidebarNav items={sidebarNavItems} />
</aside>
<div className="flex-1">{children}</div>
<div className="flex-grow flex justify-center p-4 bg-[#fafafa] dark:bg-background relative">
<div className="w-full max-w-6xl">
<Header className="w-full">
<h1 className="text-3xl">Settings</h1>
</Header>
<div className="flex flex-row gap-10 mt-20">
<aside className="lg:w-48">
<SidebarNav items={sidebarNavItems} />
</aside>
<div className="w-full rounded-lg">{children}</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,165 @@
'use client';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useCallback, useState } from "react";
import { z } from "zod";
import { PlusCircleIcon, Loader2 } from "lucide-react";
import { OrgRole } from "@prisma/client";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { createInvites } from "@/actions";
import { useDomain } from "@/hooks/useDomain";
import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation";
const formSchema = z.object({
emails: z.array(z.object({
email: z.string().email()
}))
.refine((emails) => {
const emailSet = new Set(emails.map(e => e.email.toLowerCase()));
return emailSet.size === emails.length;
}, "Duplicate email addresses are not allowed")
});
interface InviteMemberCardProps {
currentUserRole: OrgRole;
}
export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) => {
const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const domain = useDomain();
const { toast } = useToast();
const router = useRouter();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
emails: [{ email: "" }]
},
});
const addEmailField = useCallback(() => {
const emails = form.getValues().emails;
form.setValue('emails', [...emails, { email: "" }]);
}, [form]);
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
setIsLoading(true);
createInvites(data.emails.map(e => e.email), domain)
.then((res) => {
if (isServiceError(res)) {
toast({
description: `❌ Failed to invite members. Reason: ${res.message}`
});
} else {
form.reset();
router.push(`?tab=invites`);
router.refresh();
toast({
description: `✅ Successfully invited ${data.emails.length} members`
});
}
})
.finally(() => {
setIsLoading(false);
});
}, [domain, form, toast, router]);
return (
<>
<Card>
<CardHeader>
<CardTitle>Invite Member</CardTitle>
<CardDescription>Invite new members to your organization.</CardDescription>
</CardHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(() => setIsInviteDialogOpen(true))}>
<CardContent className="space-y-4">
<FormLabel>Email Address</FormLabel>
{form.watch('emails').map((_, index) => (
<FormField
key={index}
control={form.control}
name={`emails.${index}.email`}
render={({ field }) => (
<FormItem>
<FormControl>
<Input
{...field}
placeholder="melissa@example.com"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
))}
{form.formState.errors.emails?.root?.message && (
<FormMessage>{form.formState.errors.emails.root.message}</FormMessage>
)}
<Button
type="button"
variant="outline"
size="sm"
onClick={addEmailField}
>
<PlusCircleIcon className="w-4 h-4 mr-0.5" />
Add more
</Button>
</CardContent>
<CardFooter className="flex justify-end">
<Button
size="sm"
type="submit"
disabled={currentUserRole !== OrgRole.OWNER || isLoading}
>
{isLoading && <Loader2 className="w-4 h-4 mr-0.5 animate-spin" />}
Invite
</Button>
</CardFooter>
</form>
</Form>
</Card>
<AlertDialog
open={isInviteDialogOpen}
onOpenChange={setIsInviteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Invite Team Members</AlertDialogTitle>
<AlertDialogDescription>
{`Your team is growing! By confirming, you will be inviting ${form.getValues().emails.length} new members to your organization. Your subscription's seat count will be adjusted when a member accepts their invitation.`}
</AlertDialogDescription>
</AlertDialogHeader>
<div className="border rounded-lg overflow-hidden">
<div className="max-h-[400px] overflow-y-auto divide-y">
{form.getValues().emails.map(({ email }, index) => (
<p
key={index}
className="text-sm p-2"
>
{email}
</p>
))}
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onSubmit(form.getValues())}
>
Invite
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}

View file

@ -0,0 +1,212 @@
'use client';
import { OrgRole } from "@sourcebot/db";
import { resolveServerPath } from "@/app/api/(client)/client";
import { useToast } from "@/components/hooks/use-toast";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { createPathWithQueryParams, isServiceError } from "@/lib/utils";
import placeholderAvatar from "@/public/placeholder_avatar.png";
import { Copy, MoreVertical, Search } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { cancelInvite } from "@/actions";
import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
interface Invite {
id: string;
email: string;
createdAt: Date;
}
interface InviteListProps {
invites: Invite[]
currentUserRole: OrgRole
}
export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
const [searchQuery, setSearchQuery] = useState("")
const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest")
const [isCancelInviteDialogOpen, setIsCancelInviteDialogOpen] = useState(false)
const [inviteToCancel, setInviteToCancel] = useState<Invite | null>(null)
const { toast } = useToast();
const router = useRouter();
const domain = useDomain();
const filteredInvites = useMemo(() => {
return invites
.filter((invite) => {
const searchLower = searchQuery.toLowerCase();
const matchesSearch =
invite.email.toLowerCase().includes(searchLower);
return matchesSearch;
})
.sort((a, b) => {
return dateSort === "newest"
? b.createdAt.getTime() - a.createdAt.getTime()
: a.createdAt.getTime() - b.createdAt.getTime()
});
}, [invites, searchQuery, dateSort]);
const onCancelInvite = useCallback((inviteId: string) => {
cancelInvite(inviteId, domain)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to cancel invite. Reason: ${response.message}`
})
} else {
toast({
description: `✅ Invite cancelled successfully.`
})
router.refresh();
}
});
}, [domain, toast, router]);
return (
<div className="w-full mx-auto space-y-6">
<div className="flex gap-4 flex-col sm:flex-row">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter by name or email..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={dateSort} onValueChange={(value) => setDateSort(value as "newest" | "oldest")}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Date" />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="oldest">Oldest</SelectItem>
</SelectContent>
</Select>
</div>
<div className="border rounded-lg overflow-hidden">
<div className="max-h-[600px] overflow-y-auto divide-y">
{invites.length === 0 || (filteredInvites.length === 0 && searchQuery.length > 0) ? (
<div className="flex flex-col items-center justify-center h-96 p-4">
<p className="font-medium text-sm">No Pending Invitations Found</p>
<p className="text-sm text-muted-foreground mt-2">
{filteredInvites.length === 0 && searchQuery.length > 0 ? "No pending invitations found matching your filters." : "Use the form above to invite new members."}
</p>
</div>
) : (
filteredInvites.map((invite) => (
<div key={invite.id} className="p-4 flex items-center justify-between bg-background">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={placeholderAvatar.src} />
</Avatar>
<div>
<div className="text-sm text-muted-foreground">{invite.email}</div>
</div>
</div>
<div className="flex items-center gap-4">
<Button
variant="ghost"
size="sm"
className="gap-2"
title="Copy invite link"
onClick={() => {
const basePath = `${window.location.origin}${resolveServerPath('/')}`;
const url = createPathWithQueryParams(`${basePath}redeem?invite_id=${invite.id}`);
navigator.clipboard.writeText(url)
.then(() => {
toast({
description: `✅ Copied invite link for ${invite.email} to clipboard`
})
})
.catch(() => {
toast({
description: "❌ Failed to copy invite link"
})
})
}}
>
<Copy className="h-4 w-4" />
Copy invite link
</Button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(invite.email)
.then(() => {
toast({
description: `✅ Email copied to clipboard.`
})
})
.catch(() => {
toast({
description: `❌ Failed to copy email.`
})
})
}}
>
Copy email
</DropdownMenuItem>
{currentUserRole === OrgRole.OWNER && (
<DropdownMenuItem
className="cursor-pointer text-destructive"
onClick={() => {
setIsCancelInviteDialogOpen(true);
setInviteToCancel(invite);
}}
>
Cancel invite
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))
)}
</div>
</div>
<AlertDialog
open={isCancelInviteDialogOpen}
onOpenChange={setIsCancelInviteDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel Invite</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to cancel this invite for <strong>{inviteToCancel?.email}</strong>?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
Back
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
onCancelInvite(inviteToCancel?.id ?? "");
}}
>
Cancel
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}

View file

@ -0,0 +1,310 @@
'use client';
import { Input } from "@/components/ui/input";
import { Search, MoreVertical } from "lucide-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Avatar, AvatarImage } from "@/components/ui/avatar";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { useCallback, useMemo, useState } from "react";
import { OrgRole } from "@prisma/client";
import placeholderAvatar from "@/public/placeholder_avatar.png";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { useDomain } from "@/hooks/useDomain";
import { transferOwnership, removeMemberFromOrg, leaveOrg } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation";
type Member = {
id: string
email: string
name?: string
role: OrgRole
joinedAt: Date
avatarUrl?: string
}
export interface MembersListProps {
members: Member[],
currentUserId: string,
currentUserRole: OrgRole,
orgName: string,
}
export const MembersList = ({ members, currentUserId, currentUserRole, orgName }: MembersListProps) => {
const [searchQuery, setSearchQuery] = useState("")
const [roleFilter, setRoleFilter] = useState<"all" | OrgRole>("all")
const [dateSort, setDateSort] = useState<"newest" | "oldest">("newest")
const [memberToRemove, setMemberToRemove] = useState<Member | null>(null)
const [memberToTransfer, setMemberToTransfer] = useState<Member | null>(null)
const domain = useDomain()
const { toast } = useToast()
const [isRemoveDialogOpen, setIsRemoveDialogOpen] = useState(false)
const [isTransferOwnershipDialogOpen, setIsTransferOwnershipDialogOpen] = useState(false)
const [isLeaveOrgDialogOpen, setIsLeaveOrgDialogOpen] = useState(false)
const router = useRouter();
const filteredMembers = useMemo(() => {
return members
.filter((member) => {
const searchLower = searchQuery.toLowerCase();
const matchesSearch =
member.name?.toLowerCase().includes(searchLower) || member.email.toLowerCase().includes(searchLower);
const matchesRole = roleFilter === "all" || member.role === roleFilter;
return matchesSearch && matchesRole;
})
.sort((a, b) => {
return dateSort === "newest"
? b.joinedAt.getTime() - a.joinedAt.getTime()
: a.joinedAt.getTime() - b.joinedAt.getTime()
});
}, [members, searchQuery, roleFilter, dateSort]);
const onRemoveMember = useCallback((memberId: string) => {
removeMemberFromOrg(memberId, domain)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to remove member. Reason: ${response.message}`
})
} else {
toast({
description: `✅ Member removed successfully.`
})
router.refresh();
}
});
}, [domain, toast, router]);
const onTransferOwnership = useCallback((memberId: string) => {
transferOwnership(memberId, domain)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to transfer ownership. Reason: ${response.message}`
})
} else {
toast({
description: `✅ Ownership transferred successfully.`
})
router.refresh();
}
});
}, [domain, toast, router]);
const onLeaveOrg = useCallback(() => {
leaveOrg(domain)
.then((response) => {
if (isServiceError(response)) {
toast({
description: `❌ Failed to leave organization. Reason: ${response.message}`
})
} else {
toast({
description: `✅ You have left the organization.`
})
router.push("/");
}
});
}, [domain, toast, router]);
return (
<div>
<div className="w-full mx-auto space-y-6">
<div className="flex gap-4 flex-col sm:flex-row">
<div className="relative flex-1">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filter by name or email..."
className="pl-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={roleFilter} onValueChange={(value) => setRoleFilter(value as "all" | OrgRole)}>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="All Team Roles" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Team Roles</SelectItem>
<SelectItem value={OrgRole.OWNER}>Owner</SelectItem>
<SelectItem value={OrgRole.MEMBER}>Member</SelectItem>
</SelectContent>
</Select>
<Select value={dateSort} onValueChange={(value) => setDateSort(value as "newest" | "oldest")}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Date" />
</SelectTrigger>
<SelectContent>
<SelectItem value="newest">Newest</SelectItem>
<SelectItem value="oldest">Oldest</SelectItem>
</SelectContent>
</Select>
</div>
<div className="border rounded-lg overflow-hidden">
<div className="max-h-[600px] overflow-y-auto divide-y">
{filteredMembers.length === 0 ? (
<div className="flex flex-col items-center justify-center h-96 p-4">
<p className="font-medium text-sm">No Members Found</p>
<p className="text-sm text-muted-foreground mt-2">
No members found matching your filters.
</p>
</div>
) : (
filteredMembers.map((member) => (
<div key={member.id} className="p-4 flex items-center justify-between bg-background">
<div className="flex items-center gap-3">
<Avatar>
<AvatarImage src={member.avatarUrl ?? placeholderAvatar.src} />
</Avatar>
<div>
<div className="font-medium">{member.name}</div>
<div className="text-sm text-muted-foreground">{member.email}</div>
</div>
</div>
<div className="flex items-center gap-4">
<span className="text-sm text-muted-foreground capitalize">{member.role.toLowerCase()}</span>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
navigator.clipboard.writeText(member.email)
.then(() => {
toast({
description: `✅ Email copied to clipboard.`
})
})
.catch(() => {
toast({
description: `❌ Failed to copy email.`
})
})
}}
>
Copy email
</DropdownMenuItem>
{member.id !== currentUserId && currentUserRole === OrgRole.OWNER && (
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
setMemberToTransfer(member);
setIsTransferOwnershipDialogOpen(true);
}}
>
Transfer ownership
</DropdownMenuItem>
)}
{member.id !== currentUserId && currentUserRole === OrgRole.OWNER && (
<DropdownMenuItem
className="cursor-pointer text-destructive"
onClick={() => {
setMemberToRemove(member);
setIsRemoveDialogOpen(true);
}}
>
Remove
</DropdownMenuItem>
)}
{member.id === currentUserId && (
<DropdownMenuItem
className="cursor-pointer text-destructive"
disabled={currentUserRole === OrgRole.OWNER}
onClick={() => {
setIsLeaveOrgDialogOpen(true);
}}
>
Leave organization
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))
)}
</div>
</div>
<AlertDialog
open={isRemoveDialogOpen}
onOpenChange={setIsRemoveDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove Member</AlertDialogTitle>
<AlertDialogDescription>
{`Are you sure you want to remove ${memberToRemove?.name ?? memberToRemove?.email}? Your subscription's seat count will be automatically adjusted.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => {
onRemoveMember(memberToRemove?.id ?? "");
}}
>
Remove
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={isTransferOwnershipDialogOpen}
onOpenChange={setIsTransferOwnershipDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Transfer Ownership</AlertDialogTitle>
<AlertDialogDescription>
{`Are you sure you want to transfer ownership of ${orgName} to ${memberToTransfer?.name ?? memberToTransfer?.email}?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onTransferOwnership(memberToTransfer?.id ?? "");
}}
>
Transfer
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={isLeaveOrgDialogOpen}
onOpenChange={setIsLeaveOrgDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Leave Organization</AlertDialogTitle>
<AlertDialogDescription>
{`Are you sure you want to leave ${orgName}?`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={onLeaveOrg}
>
Leave
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)
}

View file

@ -0,0 +1,95 @@
import { MembersList } from "./components/membersList";
import { getOrgMembers } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { auth } from "@/auth";
import { getUser, getUserRoleInOrg } from "@/data/user";
import { getOrgFromDomain } from "@/data/org";
import { InviteMemberCard } from "./components/inviteMemberCard";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { TabSwitcher } from "@/components/ui/tab-switcher";
import { InvitesList } from "./components/invitesList";
import { getOrgInvites } from "@/actions";
interface MembersSettingsPageProps {
params: {
domain: string
},
searchParams: {
tab?: string
}
}
export default async function MembersSettingsPage({ params: { domain }, searchParams: { tab } }: MembersSettingsPageProps) {
const session = await auth();
if (!session) {
return null;
}
const members = await getOrgMembers(domain);
const org = await getOrgFromDomain(domain);
if (!org) {
return null;
}
const user = await getUser(session.user.id);
if (!user) {
return null;
}
const userRoleInOrg = await getUserRoleInOrg(user.id, org.id);
if (!userRoleInOrg) {
return null;
}
if (isServiceError(members)) {
return null;
}
const invites = await getOrgInvites(domain);
if (isServiceError(invites)) {
return null;
}
const currentTab = tab || "members";
return (
<div className="flex flex-col gap-6">
<div>
<h3 className="text-lg font-medium">Members</h3>
<p className="text-sm text-muted-foreground">Invite and manage members of your organization.</p>
</div>
<InviteMemberCard
currentUserRole={userRoleInOrg}
/>
<Tabs value={currentTab}>
<div className="border-b border-border w-full">
<TabSwitcher
className="h-auto p-0 bg-transparent"
tabs={[
{ label: "Team Members", value: "members" },
{ label: "Pending Invites", value: "invites" },
]}
currentTab={currentTab}
/>
</div>
<TabsContent value="members">
<MembersList
members={members}
currentUserId={session.user.id}
currentUserRole={userRoleInOrg}
orgName={org.name}
/>
</TabsContent>
<TabsContent value="invites">
<InvitesList
invites={invites}
currentUserRole={userRoleInOrg}
/>
</TabsContent>
</Tabs>
</div>
)
}

View file

@ -1,113 +0,0 @@
import { auth } from "@/auth"
import { getUser } from "@/data/user"
import { prisma } from "@/prisma"
import { MemberTable } from "./components/memberTable"
import { MemberInviteForm } from "./components/memberInviteForm"
import { InviteTable } from "./components/inviteTable"
import { Separator } from "@/components/ui/separator"
import { getCurrentUserRole } from "@/actions"
import { isServiceError } from "@/lib/utils"
import { OrgRole } from "@sourcebot/db"
interface SettingsPageProps {
params: {
domain: string
}
}
export default async function SettingsPage({ params: { domain } }: SettingsPageProps) {
const fetchData = async () => {
const session = await auth()
if (!session) {
return null
}
const user = await getUser(session.user.id)
if (!user) {
return null
}
const activeOrg = await prisma.org.findUnique({
where: {
domain,
},
})
if (!activeOrg) {
return null
}
const members = await prisma.user.findMany({
where: {
orgs: {
some: {
orgId: activeOrg.id,
},
},
},
include: {
orgs: {
where: {
orgId: activeOrg.id,
},
select: {
role: true,
},
},
},
})
const invites = await prisma.invite.findMany({
where: {
orgId: activeOrg.id,
},
})
const memberInfo = members.map((member) => ({
id: member.id,
name: member.name!,
email: member.email!,
role: member.orgs[0].role,
}))
const inviteInfo = invites.map((invite) => ({
id: invite.id,
email: invite.recipientEmail,
createdAt: invite.createdAt,
}))
const currentUserRole = await getCurrentUserRole(domain)
if (isServiceError(currentUserRole)) {
return null
}
return {
user,
memberInfo,
inviteInfo,
userRole: currentUserRole,
}
}
const data = await fetchData()
if (!data) {
return <div>Error: Unable to fetch data</div>
}
const { user, memberInfo, inviteInfo, userRole } = data
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium">Members</h3>
<p className="text-sm text-muted-foreground">Invite and manage members of your organization.</p>
</div>
<Separator />
<div className="space-y-6">
<MemberTable currentUserRole={userRole} currentUserId={user.id} initialMembers={memberInfo} />
<MemberInviteForm userId={user.id} currentUserRole={userRole} />
<InviteTable initialInvites={inviteInfo} />
</div>
</div>
)
}

View file

@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View file

@ -24,3 +24,16 @@ export const getUserOrgs = async (userId: string) => {
return orgs;
}
export const getUserRoleInOrg = async (userId: string, orgId: number) => {
const userToOrg = await prisma.userToOrg.findUnique({
where: {
orgId_userId: {
userId,
orgId,
}
},
});
return userToOrg?.role;
}

View file

@ -5,7 +5,6 @@ export enum ErrorCode {
REPOSITORY_NOT_FOUND = 'REPOSITORY_NOT_FOUND',
FILE_NOT_FOUND = 'FILE_NOT_FOUND',
INVALID_REQUEST_BODY = 'INVALID_REQUEST_BODY',
SELF_INVITE = 'SELF_INVITE',
NOT_AUTHENTICATED = 'NOT_AUTHENTICATED',
NOT_FOUND = 'NOT_FOUND',
CONNECTION_SYNC_ALREADY_SCHEDULED = 'CONNECTION_SYNC_ALREADY_SCHEDULED',
@ -13,6 +12,8 @@ export enum ErrorCode {
ORG_INVALID_SUBSCRIPTION = 'ORG_INVALID_SUBSCRIPTION',
MEMBER_NOT_FOUND = 'MEMBER_NOT_FOUND',
INVALID_CREDENTIALS = 'INVALID_CREDENTIALS',
MEMBER_NOT_OWNER = 'MEMBER_NOT_OWNER',
INSUFFICIENT_PERMISSIONS = 'INSUFFICIENT_PERMISSIONS',
CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED',
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
INVALID_INVITE = 'INVALID_INVITE',
}

View file

@ -1471,6 +1471,16 @@
"@radix-ui/react-primitive" "2.0.1"
"@radix-ui/react-slot" "1.1.1"
"@radix-ui/react-collection@1.1.2":
version "1.1.2"
resolved "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz#b45eccca1cb902fd078b237316bd9fa81e621e15"
integrity sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==
dependencies:
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-slot" "1.1.2"
"@radix-ui/react-compose-refs@1.0.1":
version "1.0.1"
resolved "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz"
@ -1680,6 +1690,15 @@
"@radix-ui/react-primitive" "2.0.1"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-focus-scope@1.1.2":
version "1.1.2"
resolved "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz#c0a4519cd95c772606a82fc5b96226cd7fdd2602"
integrity sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==
dependencies:
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-hover-card@^1.1.6":
version "1.1.6"
resolved "https://registry.yarnpkg.com/@radix-ui/react-hover-card/-/react-hover-card-1.1.6.tgz#94fb87c047e1bb3bfd70439cf7ee48165ea4efa5"
@ -1929,6 +1948,33 @@
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-select@^2.1.6":
version "2.1.6"
resolved "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.6.tgz#79c07cac4de0188e6f7afb2720a87a0405d88849"
integrity sha512-T6ajELxRvTuAMWH0YmRJ1qez+x4/7Nq7QIx7zJ0VK3qaEWdnWpNbEDnmWldG1zBDwqrLy5aLMUWcoGirVj5kMg==
dependencies:
"@radix-ui/number" "1.1.0"
"@radix-ui/primitive" "1.1.1"
"@radix-ui/react-collection" "1.1.2"
"@radix-ui/react-compose-refs" "1.1.1"
"@radix-ui/react-context" "1.1.1"
"@radix-ui/react-direction" "1.1.0"
"@radix-ui/react-dismissable-layer" "1.1.5"
"@radix-ui/react-focus-guards" "1.1.1"
"@radix-ui/react-focus-scope" "1.1.2"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-popper" "1.2.2"
"@radix-ui/react-portal" "1.1.4"
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/react-slot" "1.1.2"
"@radix-ui/react-use-callback-ref" "1.1.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-use-previous" "1.1.0"
"@radix-ui/react-visually-hidden" "1.1.2"
aria-hidden "^1.2.4"
react-remove-scroll "^2.6.3"
"@radix-ui/react-separator@^1.1.0":
version "1.1.0"
resolved "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz"
@ -2104,6 +2150,13 @@
dependencies:
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-visually-hidden@1.1.2":
version "1.1.2"
resolved "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz#8f6025507eb5d8b4b3215ebfd2c71a6632323a62"
integrity sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==
dependencies:
"@radix-ui/react-primitive" "2.0.2"
"@radix-ui/rect@1.1.0":
version "1.1.0"
resolved "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz"
@ -6530,7 +6583,7 @@ react-remove-scroll@^2.6.1:
use-callback-ref "^1.3.3"
use-sidecar "^1.1.2"
react-remove-scroll@^2.6.2:
react-remove-scroll@^2.6.2, react-remove-scroll@^2.6.3:
version "2.6.3"
resolved "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.3.tgz"
integrity sha512-pnAi91oOk8g8ABQKGF5/M9qxmmOPxaAnopyTHYfqYEwJhyFrbbBtHuSgtKEoH0jpcxx5o3hXqH1mNd9/Oi+8iQ==
@ -7238,14 +7291,7 @@ stringify-entities@^4.0.0:
character-entities-html4 "^2.0.0"
character-entities-legacy "^3.0.0"
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
dependencies:
ansi-regex "^5.0.1"
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
version "6.0.1"
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==