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:
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Organization" ADD COLUMN "v3Enabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -119,6 +119,8 @@ model Organization {
|
||||
|
||||
runsEnabled Boolean @default(true)
|
||||
|
||||
v3Enabled Boolean @default(false)
|
||||
|
||||
environments RuntimeEnvironment[]
|
||||
connections IntegrationConnection[]
|
||||
endpoints Endpoint[]
|
||||
|
||||
Reference in New Issue
Block a user