v3 is restricted to approved orgs (#965)

* v3 projects can only be created if the org has permissions to do it

* Admin page

* Removed unused classes

* Admin org page, with links between user and org pages

* Set v3 enabled or not
This commit is contained in:
Matt Aitken
2024-03-25 11:28:21 +00:00
committed by GitHub
parent c6d6a9191c
commit 67c62f7d25
9 changed files with 527 additions and 80 deletions
+205 -2
View File
@@ -1,9 +1,212 @@
import { prisma } from "~/db.server";
import { SearchParams } from "~/routes/admin._index";
export async function adminGetUsers() {
return await prisma.user.findMany({
const pageSize = 20;
export async function adminGetUsers(userId: string, { page, search }: SearchParams) {
page = page || 1;
search = search ? decodeURIComponent(search) : undefined;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (user?.admin !== true) {
throw new Error("Unauthorized");
}
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
admin: true,
createdAt: true,
displayName: true,
orgMemberships: {
select: {
organization: {
select: {
title: true,
slug: true,
},
},
},
},
},
where: search
? {
OR: [
{
name: {
contains: search,
mode: "insensitive",
},
},
{
email: {
contains: search,
mode: "insensitive",
},
},
{
orgMemberships: {
some: {
organization: {
title: {
contains: search,
mode: "insensitive",
},
},
},
},
},
{
orgMemberships: {
some: {
organization: {
slug: {
contains: search,
mode: "insensitive",
},
},
},
},
},
],
}
: undefined,
orderBy: {
createdAt: "desc",
},
take: pageSize,
skip: (page - 1) * pageSize,
});
const totalUsers = await prisma.user.count();
return {
users,
page,
pageCount: Math.ceil(totalUsers / pageSize),
filters: {
search,
},
};
}
export async function adminGetOrganizations(userId: string, { page, search }: SearchParams) {
page = page || 1;
search = search ? decodeURIComponent(search) : undefined;
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (user?.admin !== true) {
throw new Error("Unauthorized");
}
const organizations = await prisma.organization.findMany({
select: {
id: true,
slug: true,
title: true,
v3Enabled: true,
members: {
select: {
user: {
select: {
email: true,
},
},
},
},
},
where: search
? {
OR: [
{
members: {
some: {
user: {
name: {
contains: search,
mode: "insensitive",
},
},
},
},
},
{
members: {
some: {
user: {
email: {
contains: search,
mode: "insensitive",
},
},
},
},
},
{
slug: {
contains: search,
mode: "insensitive",
},
},
{
title: {
contains: search,
mode: "insensitive",
},
},
],
}
: undefined,
orderBy: {
createdAt: "desc",
},
take: pageSize,
skip: (page - 1) * pageSize,
});
const totalOrgs = await prisma.organization.count();
return {
organizations,
page,
pageCount: Math.ceil(totalOrgs / pageSize),
filters: {
search,
},
};
}
export async function setV3Enabled(userId: string, id: string, v3Enabled: boolean) {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});
if (user?.admin !== true) {
throw new Error("Unauthorized");
}
return prisma.organization.update({
where: {
id,
},
data: {
v3Enabled,
},
});
}
+11
View File
@@ -3,6 +3,7 @@ import slug from "slug";
import { prisma } from "~/db.server";
import type { Project } from "@trigger.dev/database";
import { Organization, createEnvironment } from "./organization.server";
import { env } from "~/env.server";
export type { Project } from "@trigger.dev/database";
const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20);
@@ -30,6 +31,16 @@ export async function createProject(
);
}
if (version === "v3") {
if (!organization.v3Enabled) {
throw new Error(`Organization can't create v3 projects.`);
}
if (!env.V3_ENABLED) {
throw new Error(`v3 is not available yet.`);
}
}
//ensure the slug is globally unique
const uniqueProjectSlug = `${slug(name)}-${nanoid(4)}`;
const projectWithSameSlug = await prisma.project.findFirst({
@@ -40,6 +40,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
select: {
id: true,
title: true,
v3Enabled: true,
_count: {
select: {
projects: {
@@ -62,6 +63,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
title: organization.title,
slug: organizationSlug,
projectsCount: organization._count.projects,
v3Enabled: organization.v3Enabled,
},
defaultVersion: url.searchParams.get("version") ?? "v2",
});
@@ -107,6 +109,8 @@ export default function NewOrganizationPage() {
const lastSubmission = useActionData();
const { v3Enabled } = useFeatures();
const canCreateV3Projects = organization.v3Enabled && v3Enabled;
const [form, { projectName, projectVersion }] = useForm({
id: "create-project",
// TODO: type this
@@ -141,7 +145,7 @@ export default function NewOrganizationPage() {
/>
<FormError id={projectName.errorId}>{projectName.error}</FormError>
</InputGroup>
{v3Enabled ? (
{canCreateV3Projects ? (
<InputGroup>
<Label htmlFor={projectVersion.id}>Project version</Label>
<SelectGroup>
@@ -95,9 +95,12 @@ export const action: ActionFunction = async ({ request }) => {
export default function NewOrganizationPage() {
const { hasOrganizations } = useTypedLoaderData<typeof loader>();
const lastSubmission = useActionData();
const { isManagedCloud, v3Enabled } = useFeatures();
const { isManagedCloud } = useFeatures();
const navigation = useNavigation();
//this is temporary whilst v3 is invite-only. Switch to the useFeatures value when v3 is generally available.
const v3Enabled = false;
const [form, { orgName, projectName, projectVersion }] = useForm({
id: "create-organization",
// TODO: type this
+118 -76
View File
@@ -1,18 +1,47 @@
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Header1 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { useUser } from "~/hooks/useUser";
import { adminGetUsers } from "~/models/admin.server";
import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server";
import { requireUserId } from "~/services/session.server";
import { createSearchParams } from "~/utils/searchParams";
export async function loader() {
const users = await adminGetUsers();
export const SearchParams = z.object({
page: z.coerce.number().optional(),
search: z.string().optional(),
});
return typedjson({ users });
}
export type SearchParams = z.infer<typeof SearchParams>;
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const searchParams = createSearchParams(request.url, SearchParams);
if (!searchParams.success) {
throw new Error(searchParams.error);
}
const result = await adminGetUsers(userId, searchParams.params.getAll());
return typedjson(result);
};
const FormSchema = z.object({ id: z.string() });
@@ -30,87 +59,100 @@ export async function action({ request }: ActionFunctionArgs) {
headers: { "Set-Cookie": await commitImpersonationSession(session) },
});
}
const headerClassName =
"py-3 px-2 pr-3 text-xs font-semibold leading-tight text-text-bright text-left";
const cellClassName = "whitespace-nowrap px-2 py-2 text-xs text-text-bright";
export default function AdminDashboardRoute() {
const user = useUser();
const { users } = useTypedLoaderData<typeof loader>();
const { users, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
return (
<main
aria-labelledby="primary-heading"
className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto p-4 lg:order-last"
className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 lg:order-last"
>
<h1 className="mb-2 text-2xl">Accounts ({users.length})</h1>
<div className=" space-y-4">
<Form className="flex items-center gap-2">
<Input
placeholder="Search users or orgs"
variant="small"
icon={MagnifyingGlassIcon}
fullWidth={true}
name="search"
defaultValue={filters.search}
/>
<Button type="submit" variant="tertiary/small">
Search
</Button>
</Form>
<LinkButton to="/" variant="secondary/small" className="mb-4">
Back to me
</LinkButton>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Email</TableHeaderCell>
<TableHeaderCell>Orgs</TableHeaderCell>
<TableHeaderCell>GitHub</TableHeaderCell>
<TableHeaderCell>id</TableHeaderCell>
<TableHeaderCell>Created</TableHeaderCell>
<TableHeaderCell>Admin?</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{users.length === 0 ? (
<TableBlankRow colSpan={9}>
<Paragraph>No users found for search</Paragraph>
</TableBlankRow>
) : (
users.map((user) => {
return (
<TableRow key={user.id}>
<TableCell>{user.email}</TableCell>
<TableCell>
{user.orgMemberships.map((org) => (
<LinkButton
key={org.organization.slug}
variant="minimal/small"
to={`/admin/orgs?search=${encodeURIComponent(org.organization.slug)}`}
>
{org.organization.title} ({org.organization.slug})
</LinkButton>
))}
</TableCell>
<TableCell>
<a
href={`https://github.com/${user.displayName}`}
target="_blank"
className="text-indigo-500 underline"
rel="noreferrer"
>
{user.displayName}
</a>
</TableCell>
<TableCell>{user.id}</TableCell>
<TableCell>{user.createdAt.toISOString()}</TableCell>
<TableCell>{user.admin ? "✅" : ""}</TableCell>
<TableCell isSticky={true}>
<Form method="post" reloadDocument>
<input type="hidden" name="id" value={user.id} />
<table className="divide-border w-full divide-y">
<thead className="bg-midnight-800 sticky -top-4 text-left">
<tr>
<th scope="col" className={headerClassName}>
Email
</th>
<th scope="col" className={headerClassName}>
GitHub username
</th>
<th scope="col" className={headerClassName}>
id
</th>
<th scope="col" className={headerClassName}>
Created At
</th>
<th scope="col" className={headerClassName}>
Admin?
</th>
<th scope="col" className={headerClassName}>
Actions
</th>
</tr>
</thead>
<tbody className="divide-border divide-y">
{users.map((user) => {
return (
<tr key={user.id} className="w-full px-4 py-2 text-left hover:bg-charcoal-900">
<td className={cellClassName}>{user.email}</td>
<td className={cellClassName}>
<a
href={`https://github.com/${user.displayName}`}
target="_blank"
className="text-indigo-500 underline"
rel="noreferrer"
>
{user.displayName}
</a>
</td>
<td className={cellClassName}>{user.id}</td>
<td className={cellClassName}>{user.createdAt.toISOString()}</td>
<td className={cellClassName}>{user.admin ? "✅" : ""}</td>
<td className={cellClassName}>
<Form method="post" reloadDocument>
<input type="hidden" name="id" value={user.id} />
<Button
type="submit"
name="action"
value="impersonate"
className="mr-2"
variant="tertiary/small"
>
Impersonate
</Button>
</Form>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<Button
type="submit"
name="action"
value="impersonate"
className="mr-2"
variant="primary/small"
>
Impersonate
</Button>
</Form>
</td>
</tr>
);
})}
</tbody>
</table>
<PaginationControls currentPage={page} totalPages={pageCount} />
</div>
</main>
);
}
+160
View File
@@ -0,0 +1,160 @@
import { MagnifyingGlassIcon } from "@heroicons/react/20/solid";
import { Form } from "@remix-run/react";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect } from "@remix-run/server-runtime";
import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { z } from "zod";
import { Button, LinkButton } from "~/components/primitives/Buttons";
import { Header1 } from "~/components/primitives/Headers";
import { Input } from "~/components/primitives/Input";
import { PaginationControls } from "~/components/primitives/Pagination";
import { Paragraph } from "~/components/primitives/Paragraph";
import {
Table,
TableBlankRow,
TableBody,
TableCell,
TableHeader,
TableHeaderCell,
TableRow,
} from "~/components/primitives/Table";
import { useUser } from "~/hooks/useUser";
import { adminGetOrganizations, adminGetUsers, setV3Enabled } from "~/models/admin.server";
import { redirectWithSuccessMessage } from "~/models/message.server";
import { commitImpersonationSession, setImpersonationId } from "~/services/impersonation.server";
import { requireUserId } from "~/services/session.server";
import { createSearchParams } from "~/utils/searchParams";
export const SearchParams = z.object({
page: z.coerce.number().optional(),
search: z.string().optional(),
});
export type SearchParams = z.infer<typeof SearchParams>;
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
const searchParams = createSearchParams(request.url, SearchParams);
if (!searchParams.success) {
throw new Error(searchParams.error);
}
const result = await adminGetOrganizations(userId, searchParams.params.getAll());
return typedjson(result);
};
const FormSchema = z.object({ id: z.string(), v3: z.enum(["enable", "disable"]) });
export async function action({ request }: ActionFunctionArgs) {
if (request.method.toLowerCase() !== "post") {
return new Response("Method not allowed", { status: 405 });
}
const userId = await requireUserId(request);
const payload = Object.fromEntries(await request.formData());
const { id, v3 } = FormSchema.parse(payload);
const result = await setV3Enabled(userId, id, v3 === "enable");
return redirectWithSuccessMessage("/admin/orgs", request, `v3 ${v3}d for org ${id}`);
}
export default function AdminDashboardRoute() {
const { organizations, filters, page, pageCount } = useTypedLoaderData<typeof loader>();
return (
<main
aria-labelledby="primary-heading"
className="flex h-full min-w-0 flex-1 flex-col overflow-y-auto px-4 pb-4 lg:order-last"
>
<div className=" space-y-4">
<Form className="flex items-center gap-2">
<Input
placeholder="Search users or orgs"
variant="small"
icon={MagnifyingGlassIcon}
fullWidth={true}
name="search"
defaultValue={filters.search}
/>
<Button type="submit" variant="tertiary/small">
Search
</Button>
</Form>
<Table>
<TableHeader>
<TableRow>
<TableHeaderCell>Name</TableHeaderCell>
<TableHeaderCell>Slug</TableHeaderCell>
<TableHeaderCell>Members</TableHeaderCell>
<TableHeaderCell>id</TableHeaderCell>
<TableHeaderCell>v3?</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{organizations.length === 0 ? (
<TableBlankRow colSpan={9}>
<Paragraph>No orgs found for search</Paragraph>
</TableBlankRow>
) : (
organizations.map((org) => {
return (
<TableRow key={org.id}>
<TableCell>{org.title}</TableCell>
<TableCell>{org.slug}</TableCell>
<TableCell>
{org.members.map((member) => (
<LinkButton
key={member.user.email}
variant="minimal/small"
to={`/admin?search=${encodeURIComponent(member.user.email)}`}
>
{member.user.email}
</LinkButton>
))}
</TableCell>
<TableCell>{org.id}</TableCell>
<TableCell>{org.v3Enabled ? "✅" : ""}</TableCell>
<TableCell isSticky={true}>
<Form method="post" reloadDocument>
<input type="hidden" name="id" value={org.id} />
{org.v3Enabled ? (
<Button
type="submit"
name="v3"
value="disable"
className="mr-2"
variant="tertiary/small"
>
Disable v3
</Button>
) : (
<Button
type="submit"
name="v3"
value="enable"
className="mr-2"
variant="tertiary/small"
>
Enable v3
</Button>
)}
</Form>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<PaginationControls currentPage={page} totalPages={pageCount} />
</div>
</main>
);
}
+20
View File
@@ -1,6 +1,8 @@
import { Outlet } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
import { redirect, typedjson } from "remix-typedjson";
import { LinkButton } from "~/components/primitives/Buttons";
import { Tabs } from "~/components/primitives/Tabs";
import { getUser, requireUserId } from "~/services/session.server";
export async function loader({ request }: LoaderFunctionArgs) {
@@ -20,6 +22,24 @@ export async function loader({ request }: LoaderFunctionArgs) {
export default function Page() {
return (
<div className="h-full w-full">
<div className="flex items-center justify-between p-4">
<Tabs
tabs={[
{
label: "Users",
to: "/admin",
},
{
label: "Organizations",
to: "/admin/orgs",
},
]}
layoutId={"admin"}
/>
<LinkButton to="/" variant="tertiary/small" className="mb-4">
Back to me
</LinkButton>
</div>
<Outlet />
</div>
);
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Organization" ADD COLUMN "v3Enabled" BOOLEAN NOT NULL DEFAULT false;
+2
View File
@@ -119,6 +119,8 @@ model Organization {
runsEnabled Boolean @default(true)
v3Enabled Boolean @default(false)
environments RuntimeEnvironment[]
connections IntegrationConnection[]
endpoints Endpoint[]