dashboard: restrict access to organization users
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
37
apps/dashboard/src/app/api/clerk/webhook/route.ts
Normal file
37
apps/dashboard/src/app/api/clerk/webhook/route.ts
Normal 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!' });
|
||||
}
|
||||
Reference in New Issue
Block a user