dashboard: restrict access to organization users

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-26 21:13:11 +01:00
parent 45e9b1d702
commit d0079c8dc3
33 changed files with 856 additions and 225 deletions

View File

@@ -1,7 +1,9 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { notFound } from 'next/navigation';
import {
getCurrentOrganizations,
getCurrentProjects,
getDashboardsByProjectId,
getProjectsByOrganizationSlug,
} from '@openpanel/db';
@@ -22,16 +24,30 @@ export default async function AppLayout({
}: AppLayoutProps) {
const [organizations, projects, dashboards] = await Promise.all([
getCurrentOrganizations(),
getProjectsByOrganizationSlug(organizationId),
getCurrentProjects(organizationId),
getDashboardsByProjectId(projectId),
]);
if (!organizations.find((item) => item.slug === organizationId)) {
return notFound();
return (
<FullPageEmptyState
title="Could not find organization"
className="min-h-screen"
>
The organization you are looking for could not be found.
</FullPageEmptyState>
);
}
if (!projects.find((item) => item.id === projectId)) {
return notFound();
return (
<FullPageEmptyState
title="Could not find project"
className="min-h-screen"
>
The project you are looking for could not be found.
</FullPageEmptyState>
);
}
return (

View File

@@ -1,4 +1,7 @@
import { getProjectsByOrganizationSlug } from '@openpanel/db';
import {
getCurrentProjects,
getProjectsByOrganizationSlug,
} from '@openpanel/db';
import LayoutProjectSelector from './layout-project-selector';
@@ -13,7 +16,7 @@ export default async function PageLayout({
title,
organizationSlug,
}: PageLayoutProps) {
const projects = await getProjectsByOrganizationSlug(organizationSlug);
const projects = await getCurrentProjects(organizationSlug);
return (
<>

View File

@@ -1,60 +0,0 @@
import { api } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { zodResolver } from '@hookform/resolvers/zod';
import { SendIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import { zInviteUser } from '@openpanel/validation';
type IForm = z.infer<typeof zInviteUser>;
export function InviteUser() {
const router = useRouter();
const { organizationId: organizationSlug } = useAppParams();
const { register, handleSubmit, formState, reset } = useForm<IForm>({
resolver: zodResolver(zInviteUser),
defaultValues: {
organizationSlug,
email: '',
role: 'org:member',
},
});
const mutation = api.organization.inviteUser.useMutation({
onSuccess() {
toast('User invited!', {
description: 'The user has been invited to the organization.',
});
reset();
router.refresh();
},
});
return (
<form
onSubmit={handleSubmit((values) => mutation.mutate(values))}
className="flex items-end gap-4"
>
<InputWithLabel
className="w-full max-w-sm"
label="Email"
placeholder="Who do you want to invite?"
{...register('email')}
/>
<Button
icon={SendIcon}
type="submit"
disabled={!formState.isDirty}
loading={mutation.isLoading}
>
Invite user
</Button>
</form>
);
}

View File

@@ -1,65 +0,0 @@
'use client';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import type { IServiceInvites } from '@openpanel/db';
import { InviteUser } from './invite-user';
interface InvitedUsersProps {
invites: IServiceInvites;
}
export default function InvitedUsers({ invites }: InvitedUsersProps) {
return (
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Invites</span>
</WidgetHead>
<WidgetBody>
<InviteUser />
<div className="font-medium mt-8 mb-2">Invited users</div>
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((item) => {
return (
<TableRow key={item.id}>
<TableCell className="font-medium">{item.email}</TableCell>
<TableCell>{item.role}</TableCell>
<TableCell>{item.status}</TableCell>
<TableCell>
{new Date(item.createdAt).toLocaleString()}
</TableCell>
</TableRow>
);
})}
{invites.length === 0 && (
<TableRow>
<TableCell colSpan={2} className="italic">
No invites
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</WidgetBody>
</Widget>
);
}

View File

@@ -0,0 +1,141 @@
import { api } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import {
closeSheet,
Sheet,
SheetContent,
SheetDescription,
SheetFooter,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/useAppParams';
import { zodResolver } from '@hookform/resolvers/zod';
import { PlusIcon, SendIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
import type { IServiceProject } from '@openpanel/db';
import { zInviteUser } from '@openpanel/validation';
type IForm = z.infer<typeof zInviteUser>;
interface Props {
projects: IServiceProject[];
}
export default function CreateInvite({ projects }: Props) {
const router = useRouter();
const { organizationId: organizationSlug } = useAppParams();
const { register, handleSubmit, formState, reset, control } = useForm<IForm>({
resolver: zodResolver(zInviteUser),
defaultValues: {
organizationSlug,
access: [],
role: 'org:member',
},
});
const mutation = api.organization.inviteUser.useMutation({
onSuccess() {
toast('User invited!', {
description: 'The user has been invited to the organization.',
});
reset();
closeSheet();
router.refresh();
},
});
return (
<Sheet>
<SheetTrigger asChild>
<Button icon={PlusIcon}>Invite user</Button>
</SheetTrigger>
<SheetContent>
<SheetHeader>
<SheetTitle>Invite a user</SheetTitle>
<SheetDescription>
Invite users to your organization. They will recieve an email will
instructions.
</SheetDescription>
</SheetHeader>
<form
onSubmit={handleSubmit((values) => mutation.mutate(values))}
className="flex flex-col gap-8"
>
<InputWithLabel
className="w-full max-w-sm"
label="Email"
error={formState.errors.email?.message}
placeholder="Who do you want to invite?"
{...register('email')}
/>
<div>
<Label>What role?</Label>
<Controller
name="role"
control={control}
render={({ field }) => (
<RadioGroup
defaultValue={field.value}
onChange={field.onChange}
ref={field.ref}
onBlur={field.onBlur}
className="flex gap-4"
>
<div className="flex items-center gap-2">
<RadioGroupItem value="org:member" id="member" />
<Label className="mb-0" htmlFor="member">
Member
</Label>
</div>
<div className="flex items-center gap-2">
<RadioGroupItem value="org:admin" id="admin" />
<Label className="mb-0" htmlFor="admin">
Admin
</Label>
</div>
</RadioGroup>
)}
/>
</div>
<Controller
name="access"
control={control}
render={({ field }) => (
<div>
<Label>Restrict access</Label>
<ComboboxAdvanced
placeholder="Restrict access to projects"
value={field.value}
onChange={field.onChange}
items={projects.map((item) => ({
label: item.name,
value: item.id,
}))}
/>
<p className="text-xs text-muted-foreground mt-1">
Leave empty to give access to all projects
</p>
</div>
)}
/>
<SheetFooter>
<Button icon={SendIcon} type="submit" loading={mutation.isLoading}>
Invite user
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}

View File

@@ -0,0 +1,18 @@
import { getInvites, getProjectsByOrganizationSlug } from '@openpanel/db';
import Invites from './invites';
interface Props {
organizationSlug: string;
}
const InvitesServer = async ({ organizationSlug }: Props) => {
const [invites, projects] = await Promise.all([
getInvites(organizationSlug),
getProjectsByOrganizationSlug(organizationSlug),
]);
return <Invites invites={invites} projects={projects} />;
};
export default InvitesServer;

View File

@@ -0,0 +1,111 @@
'use client';
import { Dot } from '@/components/dot';
import { TooltipComplete } from '@/components/tooltip-complete';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Widget, WidgetHead } from '@/components/widget';
import { cn } from '@/utils/cn';
import type { IServiceInvite, IServiceProject } from '@openpanel/db';
import CreateInvite from './create-invite';
interface Props {
invites: IServiceInvite[];
projects: IServiceProject[];
}
const Invites = ({ invites, projects }: Props) => {
return (
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Invites</span>
<CreateInvite projects={projects} />
</WidgetHead>
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
<TableHead>Access</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invites.map((item) => {
return <Item {...item} projects={projects} key={item.id} />;
})}
</TableBody>
</Table>
</Widget>
);
};
interface ItemProps extends IServiceInvite {
projects: IServiceProject[];
}
function Item({
id,
email,
role,
createdAt,
projects,
publicMetadata,
status,
}: ItemProps) {
const access = (publicMetadata?.access ?? []) as string[];
return (
<TableRow key={id}>
<TableCell className="font-medium">{email}</TableCell>
<TableCell>{role}</TableCell>
<TableCell>
<TooltipComplete content={new Date(createdAt).toLocaleString()}>
{new Date(createdAt).toLocaleDateString()}
</TooltipComplete>
</TableCell>
<TableCell className="capitalize flex items-center gap-2">
<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);
if (!project) {
return (
<Badge key={id} className="mr-1">
Unknown
</Badge>
);
}
return (
<Badge key={id} color="blue" className="mr-1">
{project.name}
</Badge>
);
})}
{access.length === 0 && (
<Badge variant={'secondary'}>All projects</Badge>
)}
</TableCell>
</TableRow>
);
}
export default Invites;

View File

@@ -0,0 +1,18 @@
import { getMembers, getProjectsByOrganizationSlug } from '@openpanel/db';
import Members from './members';
interface Props {
organizationSlug: string;
}
const MembersServer = async ({ organizationSlug }: Props) => {
const [members, projects] = await Promise.all([
getMembers(organizationSlug),
getProjectsByOrganizationSlug(organizationSlug),
]);
return <Members members={members} projects={projects} />;
};
export default MembersServer;

View File

@@ -0,0 +1,93 @@
'use client';
import { useState } from 'react';
import { api } from '@/app/_trpc/client';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Widget, WidgetHead } from '@/components/widget';
import type { IServiceMember, IServiceProject } from '@openpanel/db';
interface Props {
members: IServiceMember[];
projects: IServiceProject[];
}
const Members = ({ members, projects }: Props) => {
return (
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Members</span>
</WidgetHead>
<Table className="mini">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
<TableHead>Access</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((item) => {
return <Item {...item} projects={projects} key={item.id} />;
})}
</TableBody>
</Table>
</Widget>
);
};
interface ItemProps extends IServiceMember {
projects: IServiceProject[];
}
function Item({
id,
name,
role,
createdAt,
organization,
projects,
access: prevAccess,
}: ItemProps) {
const mutation = api.organization.updateMemberAccess.useMutation();
const [access, setAccess] = useState<string[]>(
prevAccess.map((item) => item.projectId)
);
return (
<TableRow key={id}>
<TableCell className="font-medium">{name}</TableCell>
<TableCell>{role}</TableCell>
<TableCell>{new Date(createdAt).toLocaleString()}</TableCell>
<TableCell>
<ComboboxAdvanced
placeholder="Restrict access to projects"
value={access}
onChange={(newAccess) => {
setAccess(newAccess);
mutation.mutate({
userId: id!,
organizationSlug: organization.slug,
access: newAccess as string[],
});
}}
items={projects.map((item) => ({
label: item.name,
value: item.id,
}))}
/>
</TableCell>
</TableRow>
);
}
export default Members;

View File

@@ -1,11 +1,11 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { clerkClient } from '@clerk/nextjs';
import { notFound } from 'next/navigation';
import { getInvites, getOrganizationBySlug } from '@openpanel/db';
import EditOrganization from './edit-organization';
import InvitedUsers from './invited-users';
import InvitesServer from './invites';
import MembersServer from './members';
interface PageProps {
params: {
@@ -13,8 +13,10 @@ interface PageProps {
};
}
export default async function Page({ params: { organizationId } }: PageProps) {
const organization = await getOrganizationBySlug(organizationId);
export default async function Page({
params: { organizationId: organizationSlug },
}: PageProps) {
const organization = await getOrganizationBySlug(organizationSlug);
if (!organization) {
return notFound();
@@ -23,10 +25,11 @@ export default async function Page({ params: { organizationId } }: PageProps) {
const invites = await getInvites(organization.id);
return (
<PageLayout title={organization.name} organizationSlug={organizationId}>
<div className="p-4 grid grid-cols-1 gap-4">
<PageLayout title={organization.name} organizationSlug={organizationSlug}>
<div className="p-4 grid grid-cols-1 gap-8">
<EditOrganization organization={organization} />
<InvitedUsers invites={invites} />
<MembersServer organizationSlug={organizationSlug} />
<InvitesServer organizationSlug={organizationSlug} />
</div>
</PageLayout>
);

View File

@@ -3,6 +3,7 @@ import { ProjectCard } from '@/components/projects/project-card';
import { notFound, redirect } from 'next/navigation';
import {
getCurrentProjects,
getOrganizationBySlug,
getProjectsByOrganizationSlug,
isWaitlistUserAccepted,
@@ -19,7 +20,7 @@ interface PageProps {
export default async function Page({ params: { organizationId } }: PageProps) {
const [organization, projects] = await Promise.all([
getOrganizationBySlug(organizationId),
getProjectsByOrganizationSlug(organizationId),
getCurrentProjects(organizationId),
]);
if (!organization) {

View File

@@ -0,0 +1,37 @@
import type { WebhookEvent } from '@clerk/nextjs/server';
import { AccessLevel, db } from '@openpanel/db';
export async function POST(request: Request) {
const payload: WebhookEvent = await request.json();
if (payload.type === 'organizationMembership.created') {
const access = payload.data.public_metadata.access;
if (Array.isArray(access)) {
await db.projectAccess.createMany({
data: access
.filter((a): a is string => typeof a === 'string')
.map((projectId) => ({
organization_slug: payload.data.organization.slug!,
project_id: projectId,
user_id: payload.data.public_user_data.user_id,
level: AccessLevel.read,
})),
});
}
}
if (payload.type === 'organizationMembership.deleted') {
await db.projectAccess.deleteMany({
where: {
organization_slug: payload.data.organization.slug!,
user_id: payload.data.public_user_data.user_id,
},
});
}
return Response.json({ message: 'Webhook received!' });
}
export async function GET() {
return Response.json({ message: 'Hello World!' });
}