migrate organizations from clerk to in-house

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-06-16 19:32:53 +02:00
parent 84ac68fe63
commit c7dbc2f7c4
27 changed files with 523 additions and 294 deletions

View File

@@ -18,25 +18,21 @@ export default function LayoutOrganizationSelector({
const router = useRouter();
const organization = organizations.find(
(item) => item.slug === params.organizationSlug
(item) => item.id === params.organizationSlug
);
if (!organization) {
return null;
}
return (
<Combobox
className="w-full"
placeholder="Select organization"
icon={Building}
value={organization.slug}
value={organization?.id}
items={
organizations
.filter((item) => item.slug)
.filter((item) => item.id)
.map((item) => ({
label: item.name,
value: item.slug,
value: item.id,
})) ?? []
}
onChange={(value) => {

View File

@@ -27,7 +27,7 @@ export default async function AppLayout({
getDashboardsByProjectId(projectId),
]);
if (!organizations.find((item) => item.slug === organizationSlug)) {
if (!organizations.find((item) => item.id === organizationSlug)) {
return (
<FullPageEmptyState title="Not found" className="min-h-screen">
The organization you were looking for could not be found.

View File

@@ -53,6 +53,9 @@ export default function CreateInvite({ projects }: Props) {
closeSheet();
router.refresh();
},
onError() {
toast.error('Failed to invite user');
},
});
return (

View File

@@ -1,6 +1,5 @@
'use client';
import { Dot } from '@/components/dot';
import { TooltipComplete } from '@/components/tooltip-complete';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -20,9 +19,9 @@ import {
} from '@/components/ui/table';
import { Widget, WidgetHead } from '@/components/widget';
import { api } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { MoreHorizontalIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { pathOr } from 'ramda';
import { toast } from 'sonner';
import type { IServiceInvite, IServiceProject } from '@openpanel/db';
@@ -44,10 +43,9 @@ const Invites = ({ invites, projects }: Props) => {
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Mail</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
<TableHead>Access</TableHead>
<TableHead>More</TableHead>
</TableRow>
@@ -66,18 +64,9 @@ interface ItemProps extends IServiceInvite {
projects: IServiceProject[];
}
function Item({
id,
email,
role,
createdAt,
projects,
publicMetadata,
status,
organizationId,
}: ItemProps) {
function Item({ id, email, role, createdAt, projects, meta }: ItemProps) {
const router = useRouter();
const access = (publicMetadata?.access ?? []) as string[];
const access = pathOr<string[]>([], ['access'], meta);
const revoke = api.organization.revokeInvite.useMutation({
onSuccess() {
toast.success(`Invite for ${email} revoked`);
@@ -96,17 +85,6 @@ function Item({
{new Date(createdAt).toLocaleDateString()}
</TooltipComplete>
</TableCell>
<TableCell className="flex items-center gap-2 capitalize">
<Dot
className={cn(
status === 'accepted' && 'bg-emerald-600',
status === 'revoked' && 'bg-red-600',
status === 'pending' && 'bg-orange-600'
)}
animated={status === 'pending'}
/>
{status}
</TableCell>
<TableCell>
{access.map((id) => {
const project = projects.find((p) => p.id === id);
@@ -136,7 +114,7 @@ function Item({
<DropdownMenuItem
className="text-destructive"
onClick={() => {
revoke.mutate({ organizationId, invitationId: id });
revoke.mutate({ memberId: id });
}}
>
Revoke invite

View File

@@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { TooltipComplete } from '@/components/tooltip-complete';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import {
@@ -62,10 +63,10 @@ interface ItemProps extends IServiceMember {
function Item({
id,
name,
user,
role,
organizationId,
createdAt,
organization,
projects,
access: prevAccess,
}: ItemProps) {
@@ -73,22 +74,35 @@ function Item({
const mutation = api.organization.updateMemberAccess.useMutation();
const revoke = api.organization.removeMember.useMutation({
onSuccess() {
toast.success(`${name} has been removed from the organization`);
toast.success(
`${user?.firstName} has been removed from the organization`
);
router.refresh();
},
onError() {
toast.error(`Failed to remove ${name} from the organization`);
toast.error(`Failed to remove ${user?.firstName} from the organization`);
},
});
const [access, setAccess] = useState<string[]>(
prevAccess.map((item) => item.projectId)
);
if (!user) {
return null;
}
return (
<TableRow key={id}>
<TableCell className="font-medium">{name}</TableCell>
<TableCell className="font-medium">
<div>{[user?.firstName, user?.lastName].filter(Boolean).join(' ')}</div>
<div className="text-sm text-muted-foreground">{user?.email}</div>
</TableCell>
<TableCell>{role}</TableCell>
<TableCell>{new Date(createdAt).toLocaleString()}</TableCell>
<TableCell>
<TooltipComplete content={new Date(createdAt).toLocaleString()}>
{new Date(createdAt).toLocaleDateString()}
</TooltipComplete>
</TableCell>
<TableCell>
<ComboboxAdvanced
placeholder="Restrict access to projects"
@@ -96,8 +110,8 @@ function Item({
onChange={(newAccess) => {
setAccess(newAccess);
mutation.mutate({
userId: id!,
organizationSlug: organization.slug,
userId: user.id,
organizationSlug: organizationId,
access: newAccess as string[],
});
}}
@@ -116,7 +130,7 @@ function Item({
<DropdownMenuItem
className="text-destructive"
onClick={() => {
revoke.mutate({ organizationId: organization.id, userId: id! });
revoke.mutate({ organizationId: organizationId, userId: id });
}}
>
Remove member

View File

@@ -1,10 +1,10 @@
import PageLayout from '@/app/(app)/[organizationSlug]/[projectId]/page-layout';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { auth, clerkClient } from '@clerk/nextjs/server';
import { auth } from '@clerk/nextjs/server';
import { ShieldAlertIcon } from 'lucide-react';
import { notFound } from 'next/navigation';
import { getOrganizationBySlug } from '@openpanel/db';
import { db } from '@openpanel/db';
import EditOrganization from './edit-organization';
import InvitesServer from './invites';
@@ -19,25 +19,30 @@ interface PageProps {
export default async function Page({
params: { organizationSlug },
}: PageProps) {
const organization = await getOrganizationBySlug(organizationSlug);
const session = auth();
const memberships = await clerkClient.users.getOrganizationMembershipList({
userId: session.userId!,
const organization = await db.organization.findUnique({
where: {
id: organizationSlug,
members: {
some: {
userId: session.userId,
},
},
},
include: {
members: {
select: {
role: true,
},
},
},
});
if (!organization) {
return notFound();
}
const member = memberships.data.find(
(membership) => membership.organization.id === organization.id
);
if (!member) {
return notFound();
}
const hasAccess = member.role === 'org:admin';
const hasAccess = organization.members[0]?.role === 'org:admin';
return (
<>

View File

@@ -20,9 +20,7 @@ export default async function Page({
getCurrentProjects(organizationSlug),
]);
const organization = organizations.find(
(org) => org.slug === organizationSlug
);
const organization = organizations.find((org) => org.id === organizationSlug);
if (!organization) {
return (

View File

@@ -6,7 +6,7 @@ export default async function Page() {
const organizations = await getCurrentOrganizations();
if (organizations.length > 0) {
return redirect(`/${organizations[0]?.slug}`);
return redirect(`/${organizations[0]?.id}`);
}
return redirect('/onboarding');

View File

@@ -12,7 +12,7 @@ type Props = {
const Connect = async ({ params: { projectId } }: Props) => {
const orgs = await getCurrentOrganizations();
const organizationSlug = orgs[0]?.slug;
const organizationSlug = orgs[0]?.id;
if (!organizationSlug) {
throw new Error('No organization found');
}

View File

@@ -17,7 +17,7 @@ type Props = {
const Verify = async ({ params: { projectId } }: Props) => {
const orgs = await getCurrentOrganizations();
const organizationSlug = orgs[0]?.slug;
const organizationSlug = orgs[0]?.id;
if (!organizationSlug) {
throw new Error('No organization found');
}

View File

@@ -112,10 +112,10 @@ const Tracking = ({
value={field.value}
items={
organizations
.filter((item) => item.slug)
.filter((item) => item.id)
.map((item) => ({
label: item.name,
value: item.slug,
value: item.id,
})) ?? []
}
onChange={field.onChange}

View File

@@ -1,10 +1,64 @@
import type { WebhookEvent } from '@clerk/nextjs/server';
import { pathOr } from 'ramda';
import { AccessLevel, db } from '@openpanel/db';
export async function POST(request: Request) {
const payload: WebhookEvent = await request.json();
if (payload.type === 'user.created') {
const email = payload.data.email_addresses[0]?.email_address;
if (!email) {
return Response.json(
{ message: 'No email address found' },
{ status: 400 }
);
}
const user = await db.user.create({
data: {
id: payload.data.id,
email,
firstName: payload.data.first_name,
lastName: payload.data.last_name,
},
});
const memberships = await db.member.findMany({
where: {
email,
userId: null,
},
});
for (const membership of memberships) {
const access = pathOr<string[]>([], ['meta', 'access'], membership);
db.$transaction([
// Update the member to link it to the user
// This will remove the item from invitations
db.member.update({
where: {
id: membership.id,
},
data: {
userId: user.id,
},
}),
db.projectAccess.createMany({
data: access
.filter((a) => typeof a === 'string')
.map((projectId) => ({
organizationSlug: membership.organizationId,
projectId: projectId,
userId: user.id,
level: AccessLevel.read,
})),
}),
]);
}
}
if (payload.type === 'organizationMembership.created') {
const access = payload.data.public_metadata.access;
if (Array.isArray(access)) {
@@ -12,7 +66,7 @@ export async function POST(request: Request) {
data: access
.filter((a): a is string => typeof a === 'string')
.map((projectId) => ({
organizationSlug: payload.data.organization.slug!,
organizationSlug: payload.data.organization.slug,
projectId: projectId,
userId: payload.data.public_user_data.user_id,
level: AccessLevel.read,
@@ -20,10 +74,37 @@ export async function POST(request: Request) {
});
}
}
if (payload.type === 'user.deleted') {
db.$transaction([
db.user.update({
where: {
id: payload.data.id,
},
data: {
deletedAt: new Date(),
firstName: null,
lastName: null,
email: `deleted+${payload.data.id}@openpanel.dev`,
},
}),
db.projectAccess.deleteMany({
where: {
userId: payload.data.id,
},
}),
db.member.deleteMany({
where: {
userId: payload.data.id,
},
}),
]);
}
if (payload.type === 'organizationMembership.deleted') {
await db.projectAccess.deleteMany({
where: {
organizationSlug: payload.data.organization.slug!,
organizationSlug: payload.data.organization.slug,
userId: payload.data.public_user_data.user_id,
},
});