revoke invites and remove users from organizations

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-06-14 21:56:29 +02:00
parent 1dcd501b13
commit ee88c9e391
28 changed files with 220 additions and 90 deletions

View File

@@ -151,9 +151,7 @@ export async function charts(
return getChart({
...query.data,
name: 'export-api',
metric: 'sum',
lineType: 'monotone',
chartType: 'linear',
metric: 'sum',
});
}

View File

@@ -4,7 +4,7 @@ import { memo } from 'react';
import { ChartSwitch } from '@/components/report/chart';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import type { IChartInput } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
type Props = {
profileId: string;
@@ -12,7 +12,7 @@ type Props = {
};
const ProfileCharts = ({ profileId, projectId }: Props) => {
const pageViewsChart: IChartInput = {
const pageViewsChart: IChartProps = {
projectId,
chartType: 'linear',
events: [
@@ -45,7 +45,7 @@ const ProfileCharts = ({ profileId, projectId }: Props) => {
metric: 'sum',
};
const eventsChart: IChartInput = {
const eventsChart: IChartProps = {
projectId,
chartType: 'linear',
events: [

View File

@@ -9,7 +9,7 @@ import { api } from '@/trpc/client';
import { cn } from '@/utils/cn';
import dynamic from 'next/dynamic';
import type { IChartInput } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
interface RealtimeLiveHistogramProps {
projectId: string;
@@ -18,20 +18,18 @@ interface RealtimeLiveHistogramProps {
export function RealtimeLiveHistogram({
projectId,
}: RealtimeLiveHistogramProps) {
const report: IChartInput = {
const report: IChartProps = {
projectId,
events: [
{
segment: 'user',
filters: [
{
id: '1',
name: 'name',
operator: 'is',
value: ['screen_view', 'session_start'],
},
],
id: 'A',
name: '*',
displayName: 'Active users',
},
@@ -45,7 +43,7 @@ export function RealtimeLiveHistogram({
lineType: 'monotone',
previous: false,
};
const countReport: IChartInput = {
const countReport: IChartProps = {
name: '',
projectId,
events: [
@@ -85,7 +83,7 @@ export function RealtimeLiveHistogram({
{staticArray.map((percent, i) => (
<div
key={i}
className="bg-def-200 flex-1 animate-pulse rounded-md"
className="flex-1 animate-pulse rounded-md bg-def-200"
style={{ height: `${percent}%` }}
/>
))}

View File

@@ -42,6 +42,7 @@ export default function EditOrganization({
return (
<form
className="opacity-50"
onSubmit={handleSubmit((values) => {
mutation.mutate(values);
})}
@@ -49,12 +50,18 @@ export default function EditOrganization({
<Widget>
<WidgetHead className="flex items-center justify-between">
<span className="title">Org. details</span>
<Button size="sm" type="submit" disabled={!formState.isDirty}>
<Button
size="sm"
type="submit"
disabled
// disabled={!formState.isDirty}
>
Save
</Button>
</WidgetHead>
<WidgetBody>
<InputWithLabel
disabled
label="Name"
{...register('name')}
defaultValue={organization?.name}

View File

@@ -3,6 +3,13 @@
import { Dot } from '@/components/dot';
import { TooltipComplete } from '@/components/tooltip-complete';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
@@ -12,7 +19,11 @@ import {
TableRow,
} 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 { toast } from 'sonner';
import type { IServiceInvite, IServiceProject } from '@openpanel/db';
@@ -38,6 +49,7 @@ const Invites = ({ invites, projects }: Props) => {
<TableHead>Created</TableHead>
<TableHead>Status</TableHead>
<TableHead>Access</TableHead>
<TableHead>More</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -62,8 +74,19 @@ function Item({
projects,
publicMetadata,
status,
organizationId,
}: ItemProps) {
const router = useRouter();
const access = (publicMetadata?.access ?? []) as string[];
const revoke = api.organization.revokeInvite.useMutation({
onSuccess() {
toast.success(`Invite for ${email} revoked`);
router.refresh();
},
onError() {
toast.error(`Failed to revoke invite for ${email}`);
},
});
return (
<TableRow key={id}>
<TableCell className="font-medium">{email}</TableCell>
@@ -104,6 +127,23 @@ function Item({
<Badge variant={'secondary'}>All projects</Badge>
)}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
revoke.mutate({ organizationId, invitationId: id });
}}
>
Revoke invite
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
}

View File

@@ -1,7 +1,14 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
@@ -12,6 +19,9 @@ import {
} from '@/components/ui/table';
import { Widget, WidgetHead } from '@/components/widget';
import { api } from '@/trpc/client';
import { MoreHorizontalIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import type { IServiceMember, IServiceProject } from '@openpanel/db';
@@ -33,6 +43,7 @@ const Members = ({ members, projects }: Props) => {
<TableHead>Role</TableHead>
<TableHead>Created</TableHead>
<TableHead>Access</TableHead>
<TableHead>More</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -58,7 +69,17 @@ function Item({
projects,
access: prevAccess,
}: ItemProps) {
const router = useRouter();
const mutation = api.organization.updateMemberAccess.useMutation();
const revoke = api.organization.removeMember.useMutation({
onSuccess() {
toast.success(`${name} has been removed from the organization`);
router.refresh();
},
onError() {
toast.error(`Failed to remove ${name} from the organization`);
},
});
const [access, setAccess] = useState<string[]>(
prevAccess.map((item) => item.projectId)
);
@@ -86,6 +107,23 @@ function Item({
}))}
/>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button icon={MoreHorizontalIcon} size="icon" variant={'outline'} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
className="text-destructive"
onClick={() => {
revoke.mutate({ organizationId: organization.id, userId: id! });
}}
>
Remove member
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
);
}

View File

@@ -1,4 +1,7 @@
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 { ShieldAlertIcon } from 'lucide-react';
import { notFound } from 'next/navigation';
import { getOrganizationBySlug } from '@openpanel/db';
@@ -17,26 +20,49 @@ 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!,
});
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';
return (
<>
<PageLayout
title={organization.name}
organizationSlug={organizationSlug}
/>
<div className="grid gap-8 p-4 lg:grid-cols-2">
<EditOrganization organization={organization} />
<div className="col-span-2">
<MembersServer organizationSlug={organizationSlug} />
{hasAccess ? (
<div className="grid gap-8 p-4 lg:grid-cols-2">
<EditOrganization organization={organization} />
<div className="col-span-2">
<MembersServer organizationSlug={organizationSlug} />
</div>
<div className="col-span-2">
<InvitesServer organizationSlug={organizationSlug} />
</div>
</div>
<div className="col-span-2">
<InvitesServer organizationSlug={organizationSlug} />
</div>
</div>
) : (
<>
<FullPageEmptyState icon={ShieldAlertIcon} title="No access">
You do not have access to this page. You need to be an admin of this
organization to access this page.
</FullPageEmptyState>
</>
)}
</>
);
}

View File

@@ -13,7 +13,7 @@ export function CreateClientSuccess({ id, secret, cors }: Props) {
<div className="grid gap-4">
<button className="text-left" onClick={() => clipboard(id)}>
<Label>Client ID</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
<div className="flex items-center justify-between rounded border-input bg-background p-2 px-3 font-mono text-sm">
{id}
<Copy size={16} />
</div>
@@ -25,7 +25,7 @@ export function CreateClientSuccess({ id, secret, cors }: Props) {
onClick={() => clipboard(secret)}
>
<Label>Client secret</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
<div className="flex items-center justify-between rounded border-input bg-background p-2 px-3 font-mono text-sm">
{secret}
<Copy size={16} />
</div>
@@ -40,7 +40,7 @@ export function CreateClientSuccess({ id, secret, cors }: Props) {
{cors && (
<div className="text-left">
<Label>CORS settings</Label>
<div className="flex items-center justify-between rounded bg-gray-100 p-2 px-3">
<div className="flex items-center justify-between rounded border-input bg-background p-2 px-3 font-mono text-sm">
{cors}
</div>
</div>

View File

@@ -1,10 +1,10 @@
import { pushModal } from '@/modals';
import { ScanEyeIcon } from 'lucide-react';
import type { IChartInput } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
type Props = {
chart: IChartInput;
chart: IChartProps;
};
const OverviewDetailsButton = ({ chart }: Props) => {

View File

@@ -3,7 +3,7 @@
import { api } from '@/trpc/client';
import { cn } from '@/utils/cn';
import type { IChartInput } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
@@ -14,7 +14,7 @@ interface OverviewLiveHistogramProps {
export function OverviewLiveHistogram({
projectId,
}: OverviewLiveHistogramProps) {
const report: IChartInput = {
const report: IChartProps = {
projectId,
events: [
{
@@ -41,7 +41,7 @@ export function OverviewLiveHistogram({
lineType: 'monotone',
previous: false,
};
const countReport: IChartInput = {
const countReport: IChartProps = {
name: '',
projectId,
events: [
@@ -81,7 +81,7 @@ export function OverviewLiveHistogram({
{staticArray.map((percent, i) => (
<div
key={i}
className="bg-def-200 flex-1 animate-pulse rounded"
className="flex-1 animate-pulse rounded bg-def-200"
style={{ height: `${percent}%` }}
/>
))}

View File

@@ -5,7 +5,7 @@ import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import type { IChartInput } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
import { OverviewLiveHistogram } from './overview-live-histogram';
@@ -186,7 +186,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
metric: 'average',
unit: 'min',
},
] satisfies (IChartInput & { id: string })[];
] satisfies (IChartProps & { id: string })[];
const selectedMetric = reports[metric]!;

View File

@@ -1,13 +1,13 @@
import { parseAsStringEnum, useQueryState } from 'nuqs';
import { mapKeys } from '@openpanel/validation';
import type { IChartInput } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
export function useOverviewWidget<T extends string>(
key: string,
widgets: Record<
T,
{ title: string; btn: string; chart: IChartInput; hide?: boolean }
{ title: string; btn: string; chart: IChartProps; hide?: boolean }
>
) {
const keys = Object.keys(widgets) as T[];

View File

@@ -2,7 +2,7 @@
import { api } from '@/trpc/client';
import type { IChartInput } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
import { ChartEmpty } from './ChartEmpty';
import { ReportAreaChart } from './ReportAreaChart';
@@ -13,7 +13,7 @@ import { ReportMapChart } from './ReportMapChart';
import { ReportMetricChart } from './ReportMetricChart';
import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartInput;
export type ReportChartProps = IChartProps;
export function Chart({
interval,
@@ -45,20 +45,16 @@ export function Chart({
const [data] = api.chart.chart.useSuspenseQuery(
{
// dont send lineType since it does not need to be sent
lineType: 'monotone',
interval,
chartType,
events,
breakdowns,
name,
range,
startDate,
endDate,
projectId,
previous,
formula,
unit,
metric,
},
{

View File

@@ -11,12 +11,12 @@ import {
} from 'react';
import type { IChartSerie } from '@openpanel/trpc/src/routers/chart';
import type { IChartInput } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
import { ChartLoading } from './ChartLoading';
import { MetricCardLoading } from './MetricCard';
export interface ChartContextType extends IChartInput {
export interface ChartContextType extends IChartProps {
editMode?: boolean;
hideID?: boolean;
onClick?: (item: IChartSerie) => void;

View File

@@ -3,12 +3,11 @@
import React, { useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import type { ReportChartProps } from '.';
import { ChartSwitch } from '.';
import { ChartLoading } from './ChartLoading';
import type { ChartContextType } from './ChartProvider';
export function LazyChart(props: ReportChartProps & ChartContextType) {
export function LazyChart(props: ChartContextType) {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(ref, undefined, {

View File

@@ -1,15 +1,13 @@
'use client';
import type { IChartInput } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
import { Funnel } from '../funnel';
import { Chart } from './Chart';
import { withChartProivder } from './ChartProvider';
export type ReportChartProps = IChartInput;
export const ChartSwitch = withChartProivder(function ChartSwitch(
props: ReportChartProps
props: IChartProps
) {
if (props.chartType === 'funnel') {
return <Funnel {...props} />;
@@ -19,12 +17,12 @@ export const ChartSwitch = withChartProivder(function ChartSwitch(
});
interface ChartSwitchShortcutProps {
projectId: ReportChartProps['projectId'];
range?: ReportChartProps['range'];
previous?: ReportChartProps['previous'];
chartType?: ReportChartProps['chartType'];
interval?: ReportChartProps['interval'];
events: ReportChartProps['events'];
projectId: IChartProps['projectId'];
range?: IChartProps['range'];
previous?: IChartProps['previous'];
chartType?: IChartProps['chartType'];
interval?: IChartProps['interval'];
events: IChartProps['events'];
}
export const ChartSwitchShortcut = ({

View File

@@ -5,7 +5,6 @@ import { AutoSizer } from '@/components/react-virtualized-auto-sizer';
import { Progress } from '@/components/ui/progress';
import { Widget, WidgetBody } from '@/components/widget';
import { pushModal } from '@/modals';
import { useSelector } from '@/redux';
import type { RouterOutputs } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';

View File

@@ -1,38 +1,28 @@
'use client';
import type { RouterOutputs } from '@/trpc/client';
import { api } from '@/trpc/client';
import type { IChartInput } from '@openpanel/validation';
import type { IChartInput, IChartProps } from '@openpanel/validation';
import { ChartEmpty } from '../chart/ChartEmpty';
import { withChartProivder } from '../chart/ChartProvider';
import { FunnelSteps } from './Funnel';
export type ReportChartProps = IChartInput & {
initialData?: RouterOutputs['chart']['funnel'];
};
export type ReportChartProps = IChartProps;
export const Funnel = withChartProivder(function Chart({
events,
name,
range,
projectId,
}: ReportChartProps) {
const input: IChartInput = {
events,
name,
range,
projectId,
lineType: 'monotone',
interval: 'day',
chartType: 'funnel',
breakdowns: [],
startDate: null,
endDate: null,
previous: false,
formula: undefined,
unit: undefined,
metric: 'sum',
};
const [data] = api.chart.funnel.useSuspenseQuery(input, {

View File

@@ -18,14 +18,14 @@ import {
import type {
IChartBreakdown,
IChartEvent,
IChartInput,
IChartLineType,
IChartProps,
IChartRange,
IChartType,
IInterval,
} from '@openpanel/validation';
type InitialState = IChartInput & {
type InitialState = IChartProps & {
dirty: boolean;
ready: boolean;
startDate: string | null;
@@ -72,7 +72,7 @@ export const reportSlice = createSlice({
ready: true,
};
},
setReport(state, action: PayloadAction<IChartInput>) {
setReport(state, action: PayloadAction<IChartProps>) {
return {
...state,
...action.payload,
@@ -97,7 +97,7 @@ export const reportSlice = createSlice({
removeEvent: (
state,
action: PayloadAction<{
id: string;
id?: string;
}>
) => {
state.dirty = true;
@@ -135,7 +135,7 @@ export const reportSlice = createSlice({
removeBreakdown: (
state,
action: PayloadAction<{
id: string;
id?: string;
}>
) => {
state.dirty = true;

View File

@@ -1,12 +1,12 @@
import { ChartSwitch } from '@/components/report/chart';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { IChartInput } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
import { ModalContent, ModalHeader } from './Modal/Container';
type Props = {
chart: IChartInput;
chart: IChartProps;
};
const OverviewChartDetails = (props: Props) => {

View File

@@ -11,13 +11,13 @@ import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import type { IChartInput } from '@openpanel/validation';
import type { IChartProps } from '@openpanel/validation';
import { popModal } from '.';
import { ModalContent, ModalHeader } from './Modal/Container';
type SaveReportProps = {
report: IChartInput;
report: IChartProps;
reportId?: string;
};

View File

@@ -71,8 +71,8 @@
--accent: 0 0% 15.1%; /* #262626 */
--accent-foreground: 0 0% 98%; /* #fafafa */
--destructive: 0 0% 30.6%; /* #4e4e4e */
--destructive-foreground: 0 0% 98%; /* #fafafa */
--destructive: 0 84.2% 60.2%; /* #F2677D */
--destructive-foreground: 0 100% 97.25%; /* #F8F9FB */
--border: 0 0% 15.1%; /* #262626 */
--input: 0 0% 15.1%; /* #262626 */

View File

@@ -2,8 +2,8 @@
"name": "@openpanel/worker",
"version": "0.0.1",
"scripts": {
"dev": "dotenv -e ../../.env -c -v WATCH=1 tsup",
"testing": "WORKER_PORT=9999 pnpm dev",
"qweqweq": "dotenv -e ../../.env -c -v WATCH=1 tsup",
"qweqwe": "WORKER_PORT=9999 pnpm dev",
"start": "node dist/index.js",
"build": "rm -rf dist && tsup",
"lint": "eslint .",
@@ -48,4 +48,4 @@
]
},
"prettier": "@openpanel/prettier-config"
}
}

View File

@@ -7,8 +7,8 @@ import type {
IChartBreakdown,
IChartEvent,
IChartEventFilter,
IChartInput,
IChartLineType,
IChartProps,
IChartRange,
} from '@openpanel/validation';
@@ -46,7 +46,7 @@ export function transformReportEvent(
export function transformReport(
report: DbReport
): IChartInput & { id: string } {
): IChartProps & { id: string } {
return {
id: report.id,
projectId: report.projectId,

View File

@@ -66,6 +66,38 @@ export const organizationRouter = createTRPCRouter({
});
}),
removeMember: protectedProcedure
.input(
z.object({
organizationId: z.string(),
userId: z.string(),
})
)
.mutation(async ({ input, ctx }) => {
if (ctx.session.userId === input.userId) {
throw new Error('You cannot remove yourself from the organization');
}
const organization = await clerkClient.organizations.getOrganization({
organizationId: input.organizationId,
});
if (!organization?.slug) {
throw new Error('Organization not found');
}
await db.projectAccess.deleteMany({
where: {
userId: input.userId,
organizationSlug: organization.slug,
},
});
return clerkClient.organizations.deleteOrganizationMembership({
organizationId: input.organizationId,
userId: input.userId,
});
}),
updateMemberAccess: protectedProcedure
.input(
z.object({

View File

@@ -1,7 +1,7 @@
import { z } from 'zod';
import { db } from '@openpanel/db';
import { zChartInput } from '@openpanel/validation';
import { zReportInput } from '@openpanel/validation';
import { createTRPCRouter, protectedProcedure } from '../trpc';
@@ -9,7 +9,7 @@ export const reportRouter = createTRPCRouter({
create: protectedProcedure
.input(
z.object({
report: zChartInput.omit({ projectId: true }),
report: zReportInput.omit({ projectId: true }),
dashboardId: z.string(),
})
)
@@ -38,7 +38,7 @@ export const reportRouter = createTRPCRouter({
.input(
z.object({
reportId: z.string(),
report: zChartInput.omit({ projectId: true }),
report: zReportInput.omit({ projectId: true }),
})
)
.mutation(({ input: { report, reportId } }) => {

View File

@@ -60,9 +60,7 @@ export const zMetric = z.enum(objectToZodEnums(metrics));
export const zRange = z.enum(objectToZodEnums(timeWindows));
export const zChartInput = z.object({
name: z.string().default(''),
chartType: zChartType.default('linear'),
lineType: zLineType.default('monotone'),
interval: zTimeInterval.default('day'),
events: zChartEvents,
breakdowns: zChartBreakdowns.default([]),
@@ -70,13 +68,17 @@ export const zChartInput = z.object({
previous: z.boolean().default(false),
formula: z.string().optional(),
metric: zMetric.default('sum'),
unit: z.string().optional(),
previousIndicatorInverted: z.boolean().optional(),
projectId: z.string(),
startDate: z.string().nullish(),
endDate: z.string().nullish(),
});
export const zReportInput = zChartInput.extend({
name: z.string(),
lineType: zLineType,
unit: z.string().optional(),
});
export const zInviteUser = z.object({
email: z.string().email(),
organizationSlug: z.string(),

View File

@@ -8,10 +8,17 @@ import type {
zLineType,
zMetric,
zRange,
zReportInput,
zTimeInterval,
} from './index';
export type IChartInput = z.infer<typeof zChartInput>;
export type IChartProps = z.infer<typeof zReportInput> & {
name: string;
lineType: IChartLineType;
unit?: string;
previousIndicatorInverted?: boolean;
};
export type IChartEvent = z.infer<typeof zChartEvent>;
export type IChartEventFilter = IChartEvent['filters'][number];
export type IChartEventFilterValue =