mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +00:00
Membership settings rework (#198)
* Add refined members list * futher progress on members settings polish * Remove old components * feedback
This commit is contained in:
parent
e09b21f6b9
commit
f652ca526e
21 changed files with 1282 additions and 689 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
const getAuthorizationPrecendence = (role: OrgRole): number => {
|
||||
switch (role) {
|
||||
case OrgRole.MEMBER:
|
||||
return 0;
|
||||
case OrgRole.OWNER:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
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 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);
|
||||
|
||||
if (email === session.user.email) {
|
||||
console.error("User tried to invite themselves");
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.SELF_INVITE,
|
||||
message: "❌ You can't invite yourself to an org",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.invite.create({
|
||||
data: {
|
||||
recipientEmail: email,
|
||||
hostUserId: userId,
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
// Check for existing invites
|
||||
const existingInvites = await prisma.invite.findMany({
|
||||
where: {
|
||||
recipientEmail: {
|
||||
in: emails
|
||||
},
|
||||
orgId,
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to create invite:", error);
|
||||
return unexpectedError("Failed to create invite");
|
||||
|
||||
if (existingInvites.length > 0) {
|
||||
return {
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
errorCode: ErrorCode.INVALID_INVITE,
|
||||
message: `A pending invite already exists for one or more of the provided emails.`,
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
// 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,
|
||||
}));
|
||||
})
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
useEffect(() => {
|
||||
getSecrets(domain).then((keys) => {
|
||||
if ('keys' in keys) {
|
||||
setSecrets(keys);
|
||||
} else {
|
||||
console.error(keys);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchSecretKeys();
|
||||
}, [fetchSecretKeys]);
|
||||
})
|
||||
}, []);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
|
||||
export default async function GeneralSettingsPage() {
|
||||
return (
|
||||
<p>todo</p>
|
||||
)
|
||||
}
|
||||
|
||||
22
packages/web/src/app/[domain]/settings/components/header.tsx
Normal file
22
packages/web/src/app/[domain]/settings/components/header.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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="flex-1">{children}</div>
|
||||
<div className="w-full rounded-lg">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
95
packages/web/src/app/[domain]/settings/members/page.tsx
Normal file
95
packages/web/src/app/[domain]/settings/members/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
160
packages/web/src/components/ui/select.tsx
Normal file
160
packages/web/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
64
yarn.lock
64
yarn.lock
|
|
@ -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==
|
||||
|
|
|
|||
Loading…
Reference in a new issue