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

@@ -30,12 +30,16 @@ ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ARG CLERK_SECRET_KEY ARG CLERK_SECRET_KEY
ENV CLERK_SECRET_KEY=$CLERK_SECRET_KEY ENV CLERK_SECRET_KEY=$CLERK_SECRET_KEY
ARG CLERK_SIGNING_SECRET
ENV CLERK_SIGNING_SECRET=$CLERK_SIGNING_SECRET
ARG NEXT_PUBLIC_SENTRY_DSN ARG NEXT_PUBLIC_SENTRY_DSN
ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN ENV NEXT_PUBLIC_SENTRY_DSN=$NEXT_PUBLIC_SENTRY_DSN
ARG SENTRY_AUTH_TOKEN ARG SENTRY_AUTH_TOKEN
ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN ENV SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN
ENV PNPM_HOME="/pnpm" ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH" ENV PATH="$PNPM_HOME:$PATH"

View File

@@ -31,6 +31,7 @@
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-popover": "^1.0.7",
"@radix-ui/react-progress": "^1.0.3", "@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-scroll-area": "^1.0.5", "@radix-ui/react-scroll-area": "^1.0.5",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",

View File

@@ -1,7 +1,9 @@
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { import {
getCurrentOrganizations, getCurrentOrganizations,
getCurrentProjects,
getDashboardsByProjectId, getDashboardsByProjectId,
getProjectsByOrganizationSlug, getProjectsByOrganizationSlug,
} from '@openpanel/db'; } from '@openpanel/db';
@@ -22,16 +24,30 @@ export default async function AppLayout({
}: AppLayoutProps) { }: AppLayoutProps) {
const [organizations, projects, dashboards] = await Promise.all([ const [organizations, projects, dashboards] = await Promise.all([
getCurrentOrganizations(), getCurrentOrganizations(),
getProjectsByOrganizationSlug(organizationId), getCurrentProjects(organizationId),
getDashboardsByProjectId(projectId), getDashboardsByProjectId(projectId),
]); ]);
if (!organizations.find((item) => item.slug === organizationId)) { 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)) { 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 ( return (

View File

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

View File

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

View File

@@ -0,0 +1,46 @@
import { cn } from '@/utils/cn';
interface DotProps {
className?: string;
size?: number;
animated?: boolean;
}
function filterCn(filter: string[], className: string | undefined) {
const split: string[] = className?.split(' ') || [];
return split
.filter((item) => !filter.some((filterItem) => item.startsWith(filterItem)))
.join(' ');
}
export function Dot({ className, size = 8, animated }: DotProps) {
const style = {
width: size,
height: size,
};
return (
<div
className={cn(
'relative',
filterCn(['bg-', 'animate-', 'group-hover/row'], className)
)}
style={style}
>
<div
className={cn(
'absolute !m-0 rounded-full',
animated !== false && 'animate-ping',
className
)}
style={style}
/>
<div
className={cn(
'absolute !m-0 rounded-full',
filterCn(['animate-', 'group-hover/row'], className)
)}
style={style}
/>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import { cn } from '@/utils/cn';
import { BoxSelectIcon } from 'lucide-react'; import { BoxSelectIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
@@ -5,15 +6,17 @@ interface FullPageEmptyStateProps {
icon?: LucideIcon; icon?: LucideIcon;
title: string; title: string;
children: React.ReactNode; children: React.ReactNode;
className?: string;
} }
export function FullPageEmptyState({ export function FullPageEmptyState({
icon: Icon = BoxSelectIcon, icon: Icon = BoxSelectIcon,
title, title,
children, children,
className,
}: FullPageEmptyStateProps) { }: FullPageEmptyStateProps) {
return ( return (
<div className="p-4 flex items-center justify-center"> <div className={cn('p-4 flex items-center justify-center', className)}>
<div className="p-8 w-full max-w-xl flex flex-col items-center justify-center"> <div className="p-8 w-full max-w-xl flex flex-col items-center justify-center">
<div className="w-24 h-24 bg-white shadow-sm rounded-full flex justify-center items-center mb-6"> <div className="w-24 h-24 bg-white shadow-sm rounded-full flex justify-center items-center mb-6">
<Icon size={60} strokeWidth={1} /> <Icon size={60} strokeWidth={1} />

View File

@@ -120,11 +120,7 @@ export function FilterItem({ filter, event }: FilterProps) {
items={valuesCombobox} items={valuesCombobox}
value={filter.value} value={filter.value}
className="flex-1" className="flex-1"
onChange={(setFn) => { onChange={changeFilterValue}
changeFilterValue(
typeof setFn === 'function' ? setFn(filter.value) : setFn
);
}}
placeholder="Select..." placeholder="Select..."
/> />
</div> </div>

View File

@@ -0,0 +1,26 @@
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
interface TooltipCompleteProps {
children: React.ReactNode | string;
content: React.ReactNode | string;
disabled?: boolean;
side?: 'top' | 'right' | 'bottom' | 'left';
}
export function TooltipComplete({
children,
disabled,
content,
side,
}: TooltipCompleteProps) {
return (
<Tooltip>
<TooltipTrigger asChild={typeof children !== 'string'}>
{children}
</TooltipTrigger>
<TooltipContent side={side} disabled={disabled}>
{content}
</TooltipContent>
</Tooltip>
);
}

View File

@@ -19,7 +19,7 @@ type IItem = Record<'value' | 'label', IValue>;
interface ComboboxAdvancedProps { interface ComboboxAdvancedProps {
value: IValue[]; value: IValue[];
onChange: React.Dispatch<React.SetStateAction<IValue[]>>; onChange: (value: IValue[]) => void;
items: IItem[]; items: IItem[];
placeholder: string; placeholder: string;
className?: string; className?: string;
@@ -57,12 +57,11 @@ export function ComboboxAdvanced({
}} }}
onSelect={() => { onSelect={() => {
setInputValue(''); setInputValue('');
onChange((prev) => { onChange(
if (prev.includes(item.value)) { value.includes(item.value)
return prev.filter((s) => s !== item.value); ? value.filter((s) => s !== item.value)
} : [...value, item.value]
return [...prev, item.value]; );
});
}} }}
className={'cursor-pointer flex items-center gap-2'} className={'cursor-pointer flex items-center gap-2'}
> >

View File

@@ -1,47 +1,42 @@
'use client'; import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react"
import * as React from 'react'; import { cn } from "@/utils/cn"
import { cn } from '@/utils/cn';
export type RadioGroupProps = React.InputHTMLAttributes<HTMLDivElement>; const RadioGroup = React.forwardRef<
export type RadioGroupItemProps = React.ElementRef<typeof RadioGroupPrimitive.Root>,
React.InputHTMLAttributes<HTMLButtonElement> & { React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
active?: boolean; >(({ className, ...props }, ref) => {
}; return (
<RadioGroupPrimitive.Root
className={cn("grid gap-2", className)}
{...props}
ref={ref}
/>
)
})
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>( const RadioGroupItem = React.forwardRef<
({ className, type, ...props }, ref) => { React.ElementRef<typeof RadioGroupPrimitive.Item>,
return ( React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
<div >(({ className, ...props }, ref) => {
className={cn( return (
'flex h-10 divide-x rounded-md border border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50', <RadioGroupPrimitive.Item
className ref={ref}
)} className={cn(
ref={ref} "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
{...props} className
/> )}
); {...props}
} >
); <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
})
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
const RadioGroupItem = React.forwardRef<HTMLButtonElement, RadioGroupItemProps>( export { RadioGroup, RadioGroupItem }
({ className, active, ...props }, ref) => {
return (
<button
{...props}
className={cn(
'flex-1 px-3 whitespace-nowrap leading-none hover:bg-slate-100 transition-colors font-medium',
className,
active && 'bg-slate-100'
)}
type="button"
ref={ref}
/>
);
}
);
RadioGroup.displayName = 'RadioGroup';
RadioGroupItem.displayName = 'RadioGroupItem';
export { RadioGroup, RadioGroupItem };

View File

@@ -133,6 +133,14 @@ const SheetDescription = React.forwardRef<
)); ));
SheetDescription.displayName = SheetPrimitive.Description.displayName; SheetDescription.displayName = SheetPrimitive.Description.displayName;
export function closeSheet() {
if (typeof document === 'undefined') return;
const element = document.querySelector('#close-sheet');
if (element instanceof HTMLElement) {
element.click();
}
}
export { export {
Sheet, Sheet,
SheetPortal, SheetPortal,

View File

@@ -6,18 +6,14 @@ import { cn } from '@/utils/cn';
const Table = React.forwardRef< const Table = React.forwardRef<
HTMLTableElement, HTMLTableElement,
React.HTMLAttributes<HTMLTableElement> & { React.HTMLAttributes<HTMLTableElement> & {
wrapper?: boolean;
overflow?: boolean; overflow?: boolean;
} }
>(({ className, wrapper, overflow = true, ...props }, ref) => ( >(({ className, overflow = true, ...props }, ref) => (
<div className={cn('card', className)}> <div className={cn('card', className)}>
<div className={cn('relative w-full', overflow && 'overflow-auto')}> <div className={cn('relative w-full', overflow && 'overflow-auto')}>
<table <table
ref={ref} ref={ref}
className={cn( className={cn('w-full caption-bottom text-sm', className)}
'w-full caption-bottom text-sm [&.mini]:text-xs',
className
)}
{...props} {...props}
/> />
</div> </div>
@@ -79,7 +75,7 @@ const TableHead = React.forwardRef<
<th <th
ref={ref} ref={ref}
className={cn( className={cn(
'p-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 shadow-[0_0_0_0.5px] shadow-border [.mini_&]:p-2', 'px-4 h-10 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 shadow-[0_0_0_0.5px] shadow-border',
className className
)} )}
{...props} {...props}
@@ -94,7 +90,7 @@ const TableCell = React.forwardRef<
<td <td
ref={ref} ref={ref}
className={cn( className={cn(
'p-4 align-middle [&:has([role=checkbox])]:pr-0 shadow-[0_0_0_0.5px] shadow-border [.mini_&]:p-2 whitespace-nowrap', 'px-4 h-12 align-middle [&:has([role=checkbox])]:pr-0 shadow-[0_0_0_0.5px] shadow-border whitespace-nowrap',
className className
)} )}
{...props} {...props}

View File

@@ -12,18 +12,22 @@ const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef< const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>, React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
>(({ className, sideOffset = 4, ...props }, ref) => ( disabled?: boolean;
<TooltipPrimitive.Content }
ref={ref} >(({ className, sideOffset = 4, disabled, ...props }, ref) =>
sideOffset={sideOffset} disabled ? null : (
className={cn( <TooltipPrimitive.Content
'z-50 overflow-hidden rounded-md border bg-black px-3 py-1.5 text-sm text-popover shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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', ref={ref}
className sideOffset={sideOffset}
)} className={cn(
{...props} 'z-50 overflow-hidden rounded-md border bg-black px-3 py-1.5 text-sm text-popover shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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',
/> className
)); )}
{...props}
/>
)
);
TooltipContent.displayName = TooltipPrimitive.Content.displayName; TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -4,7 +4,7 @@ import { authMiddleware } from '@clerk/nextjs';
// Please edit this to allow other routes to be public as needed. // Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware // See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
export default authMiddleware({ export default authMiddleware({
publicRoutes: ['/share/overview/:id', '/api/trpc(.*)'], publicRoutes: ['/share/overview/:id', '/api/trpc(.*)', '/api/clerk/(.*)?'],
}); });
export const config = { export const config = {

View File

@@ -2,7 +2,7 @@ import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { clerkClient } from '@clerk/nextjs'; import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod'; import { z } from 'zod';
import { getOrganizationBySlug } from '@openpanel/db'; import { db, getOrganizationBySlug } from '@openpanel/db';
import { zInviteUser } from '@openpanel/validation'; import { zInviteUser } from '@openpanel/validation';
export const organizationRouter = createTRPCRouter({ export const organizationRouter = createTRPCRouter({
@@ -30,6 +30,7 @@ export const organizationRouter = createTRPCRouter({
name: input.name, name: input.name,
}); });
}), }),
inviteUser: protectedProcedure inviteUser: protectedProcedure
.input(zInviteUser) .input(zInviteUser)
.mutation(async ({ input, ctx }) => { .mutation(async ({ input, ctx }) => {
@@ -44,6 +45,50 @@ export const organizationRouter = createTRPCRouter({
emailAddress: input.email, emailAddress: input.email,
role: input.role, role: input.role,
inviterUserId: ctx.session.userId, inviterUserId: ctx.session.userId,
publicMetadata: {
access: input.access,
},
}); });
}), }),
revokeInvite: protectedProcedure
.input(
z.object({
organizationId: z.string(),
invitationId: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
return clerkClient.organizations.revokeOrganizationInvitation({
organizationId: input.organizationId,
invitationId: input.invitationId,
requestingUserId: ctx.session.userId,
});
}),
updateMemberAccess: protectedProcedure
.input(
z.object({
userId: z.string(),
organizationSlug: z.string(),
access: z.array(z.string()),
})
)
.mutation(async ({ input }) => {
return db.$transaction([
db.projectAccess.deleteMany({
where: {
user_id: input.userId,
organization_slug: input.organizationSlug,
},
}),
db.projectAccess.createMany({
data: input.access.map((projectId) => ({
user_id: input.userId,
organization_slug: input.organizationSlug,
project_id: projectId,
level: 'read',
})),
}),
]);
}),
}); });

View File

@@ -0,0 +1,17 @@
-- CreateEnum
CREATE TYPE "AccessLevel" AS ENUM ('read', 'write', 'admin');
-- CreateTable
CREATE TABLE "project_access" (
"id" TEXT NOT NULL,
"project_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"level" "AccessLevel" NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "project_access_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "project_access" ADD CONSTRAINT "project_access_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -0,0 +1,12 @@
/*
Warnings:
- The primary key for the `project_access` table will be changed. If it partially fails, the table could be left without primary key constraint.
- The `id` column on the `project_access` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "project_access" DROP CONSTRAINT "project_access_pkey",
DROP COLUMN "id",
ADD COLUMN "id" UUID NOT NULL DEFAULT gen_random_uuid(),
ADD CONSTRAINT "project_access_pkey" PRIMARY KEY ("id");

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- Added the required column `organization_slug` to the `project_access` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "project_access" ADD COLUMN "organization_slug" TEXT NOT NULL;

View File

@@ -27,9 +27,30 @@ model Project {
EventMeta EventMeta[] EventMeta EventMeta[]
Reference Reference[] Reference Reference[]
access ProjectAccess[]
@@map("projects") @@map("projects")
} }
enum AccessLevel {
read
write
admin
}
model ProjectAccess {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
project_id String
project Project @relation(fields: [project_id], references: [id])
organization_slug String
user_id String
level AccessLevel
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@map("project_access")
}
model Event { model Event {
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
name String name String

View File

@@ -2,21 +2,31 @@ import { auth, clerkClient } from '@clerk/nextjs';
import type { import type {
Organization, Organization,
OrganizationInvitation, OrganizationInvitation,
OrganizationMembership,
} from '@clerk/nextjs/dist/types/server'; } from '@clerk/nextjs/dist/types/server';
import type { ProjectAccess } from '../prisma-client';
import { db } from '../prisma-client'; import { db } from '../prisma-client';
export type IServiceOrganization = Awaited< export type IServiceOrganization = ReturnType<typeof transformOrganization>;
ReturnType<typeof getCurrentOrganizations> export type IServiceInvite = ReturnType<typeof transformInvite>;
>[number]; export type IServiceMember = ReturnType<typeof transformMember>;
export type IServiceProjectAccess = ReturnType<typeof transformAccess>;
export type IServiceInvites = Awaited<ReturnType<typeof getInvites>>;
export function transformOrganization(org: Organization) { export function transformOrganization(org: Organization) {
return { return {
id: org.id, id: org.id,
name: org.name, name: org.name,
slug: org.slug, slug: org.slug!,
};
}
export function transformAccess(access: ProjectAccess) {
return {
projectId: access.project_id,
userId: access.user_id,
level: access.level,
organizationSlug: access.organization_slug,
}; };
} }
@@ -50,18 +60,77 @@ export async function getOrganizationByProjectId(projectId: string) {
export function transformInvite(invite: OrganizationInvitation) { export function transformInvite(invite: OrganizationInvitation) {
return { return {
id: invite.id, id: invite.id,
organizationId: invite.organizationId,
email: invite.emailAddress, email: invite.emailAddress,
role: invite.role, role: invite.role,
status: invite.status, status: invite.status,
createdAt: invite.createdAt, createdAt: invite.createdAt,
updatedAt: invite.updatedAt, updatedAt: invite.updatedAt,
publicMetadata: invite.publicMetadata,
}; };
} }
export async function getInvites(organizationId: string) { export async function getInvites(organizationSlug: string) {
const org = await getOrganizationBySlug(organizationSlug);
if (!org) return [];
return await clerkClient.organizations return await clerkClient.organizations
.getOrganizationInvitationList({ .getOrganizationInvitationList({
organizationId, organizationId: org.id,
}) })
.then((invites) => invites.map(transformInvite)); .then((invites) => invites.map(transformInvite));
} }
export function transformMember(
item: OrganizationMembership & {
access: IServiceProjectAccess[];
}
) {
return {
memberId: item.id,
id: item.publicUserData?.userId,
name:
[item.publicUserData?.firstName, item.publicUserData?.lastName]
.filter(Boolean)
.join(' ') || 'Unknown',
role: item.role,
createdAt: item.createdAt,
updatedAt: item.updatedAt,
publicMetadata: item.publicMetadata,
organization: transformOrganization(item.organization),
access: item.access,
};
}
export async function getMembers(organizationSlug: string) {
const org = await getOrganizationBySlug(organizationSlug);
if (!org) return [];
const [members, access] = await Promise.all([
clerkClient.organizations.getOrganizationMembershipList({
organizationId: org.id,
}),
db.projectAccess.findMany({
where: {
organization_slug: organizationSlug,
},
}),
]);
return members
.map((member) => {
const projectAccess = access.filter(
(item) => item.user_id === member.publicUserData?.userId
);
return {
...member,
access: projectAccess.map(transformAccess),
};
})
.map(transformMember);
}
export async function getMember(organizationSlug: string, userId: string) {
const org = await getOrganizationBySlug(organizationSlug);
if (!org) return null;
const members = await getMembers(org.id);
return members.find((member) => member.id === userId) ?? null;
}

View File

@@ -1,3 +1,6 @@
import { auth } from '@clerk/nextjs';
import { project } from 'ramda';
import type { Project } from '../prisma-client'; import type { Project } from '../prisma-client';
import { db } from '../prisma-client'; import { db } from '../prisma-client';
@@ -33,3 +36,31 @@ export async function getProjectsByOrganizationSlug(slug: string) {
return res.map(transformProject); return res.map(transformProject);
} }
export async function getCurrentProjects(slug: string) {
const session = auth();
if (!session.userId) {
return [];
}
const access = await db.projectAccess.findMany({
where: {
organization_slug: slug,
user_id: session.userId,
},
});
const res = await db.project.findMany({
where: {
organization_slug: slug,
},
});
if (access.length === 0) {
return res.map(transformProject);
}
return res
.filter((project) => access.some((a) => a.project_id === project.id))
.map(transformProject);
}

View File

@@ -80,7 +80,8 @@ export const zChartInput = z.object({
export const zInviteUser = z.object({ export const zInviteUser = z.object({
email: z.string().email(), email: z.string().email(),
organizationSlug: z.string(), organizationSlug: z.string(),
role: z.enum(['admin', 'org:member']), role: z.enum(['org:admin', 'org:member']),
access: z.array(z.string()),
}); });
export const zShareOverview = z.object({ export const zShareOverview = z.object({

33
pnpm-lock.yaml generated
View File

@@ -168,6 +168,9 @@ importers:
'@radix-ui/react-progress': '@radix-ui/react-progress':
specifier: ^1.0.3 specifier: ^1.0.3
version: 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) version: 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-radio-group':
specifier: ^1.1.3
version: 1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-scroll-area': '@radix-ui/react-scroll-area':
specifier: ^1.0.5 specifier: ^1.0.5
version: 1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0) version: 1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
@@ -5327,6 +5330,36 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/@radix-ui/react-radio-group@1.1.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.23.9
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-context': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-direction': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@radix-ui/react-use-size': 1.0.1(@types/react@18.2.56)(react@18.2.0)
'@types/react': 18.2.56
'@types/react-dom': 18.2.19
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0): /@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.56)(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==}
peerDependencies: peerDependencies: