diff --git a/apps/webapp/app/components/ImpersonationBanner.tsx b/apps/webapp/app/components/ImpersonationBanner.tsx
index ddbe576dc0a..8fb23f53e5b 100644
--- a/apps/webapp/app/components/ImpersonationBanner.tsx
+++ b/apps/webapp/app/components/ImpersonationBanner.tsx
@@ -1,20 +1,32 @@
-import { TertiaryA } from "./primitives/Buttons";
+import { Form } from "@remix-run/react";
+import { TertiaryA, TertiaryButton } from "./primitives/Buttons";
import { Body } from "./primitives/text/Body";
-export function ImpersonationBanner() {
+export function ImpersonationBanner({
+ impersonationId,
+}: {
+ impersonationId: string;
+}) {
return (
-
+
You are impersonating{" "}
- James Ritchie
+ {impersonationId}
-
- Stop impersonating
-
+
+ Stop impersonating
+
+
);
}
diff --git a/apps/webapp/app/components/layout/AppLayout.tsx b/apps/webapp/app/components/layout/AppLayout.tsx
index 2b0d6587392..8ea8094471a 100644
--- a/apps/webapp/app/components/layout/AppLayout.tsx
+++ b/apps/webapp/app/components/layout/AppLayout.tsx
@@ -1,15 +1,26 @@
import classNames from "classnames";
import { ImpersonationBanner } from "../ImpersonationBanner";
-export function AppLayout({ children }: { children: React.ReactNode }) {
+export function AppLayout({
+ children,
+ impersonationId,
+}: {
+ children: React.ReactNode;
+ impersonationId?: string;
+}) {
+ if (impersonationId) {
+ return (
+
+
+ {children}
+
+ );
+ }
+
return (
-
+
{children}
- //
- //
- // {children}
- //
);
}
diff --git a/apps/webapp/app/models/admin.server.ts b/apps/webapp/app/models/admin.server.ts
new file mode 100644
index 00000000000..f7cfcd59374
--- /dev/null
+++ b/apps/webapp/app/models/admin.server.ts
@@ -0,0 +1,9 @@
+import { prisma } from "~/db.server";
+
+export async function adminGetUsers() {
+ return await prisma.user.findMany({
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+}
diff --git a/apps/webapp/app/routes/__app.tsx b/apps/webapp/app/routes/__app.tsx
index e2e3048830f..fbdc9f7c1cc 100644
--- a/apps/webapp/app/routes/__app.tsx
+++ b/apps/webapp/app/routes/__app.tsx
@@ -1,6 +1,6 @@
import { Outlet } from "@remix-run/react";
import type { LoaderArgs } from "@remix-run/server-runtime";
-import { typedjson } from "remix-typedjson";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { Footer } from "~/components/layout/Footer";
import { AppBody, AppLayout } from "~/components/layout/AppLayout";
import { getOrganizations } from "~/models/organization.server";
@@ -10,15 +10,17 @@ import { Header } from "~/components/layout/Header";
import { NoMobileOverlay } from "~/components/NoMobileOverlay";
import { IntercomProvider, useIntercom } from "react-use-intercom";
import { useEffect } from "react";
-
+import { getImpersonationId } from "~/services/impersonation.server";
export const loader = async ({ request }: LoaderArgs) => {
const userId = await requireUserId(request);
const organizations = await getOrganizations({ userId });
+ const impersonationId = await getImpersonationId(request);
return typedjson(
{
organizations,
+ impersonationId,
},
{
headers: {
@@ -31,10 +33,11 @@ export const loader = async ({ request }: LoaderArgs) => {
const INTERCOM_APP_ID = "pfbctmiv";
export default function App() {
+ const { impersonationId } = useTypedLoaderData
();
return (
-
+
diff --git a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows.new.tsx b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows.new.tsx
index 5e5876caba6..7e539f49a51 100644
--- a/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows.new.tsx
+++ b/apps/webapp/app/routes/__app/orgs/$organizationSlug/__org/workflows.new.tsx
@@ -92,8 +92,7 @@ export const action = async ({ request, params }: ActionArgs) => {
const maxWidth = "flex max-w-4xl";
const subTitle = "text-slate-200 font-semibold mb-3";
-const carousel =
- "-ml-[26px] overflow-hidden overflow-x-auto pl-[1.5rem] scrollbar-hide";
+const carousel = "-ml-[26px] overflow-hidden overflow-x-auto pl-[1.5rem]";
export default function NewWorkflowPage() {
const environment = useCurrentEnvironment();
diff --git a/apps/webapp/app/routes/admin.tsx b/apps/webapp/app/routes/admin.tsx
new file mode 100644
index 00000000000..2eba322bd04
--- /dev/null
+++ b/apps/webapp/app/routes/admin.tsx
@@ -0,0 +1,212 @@
+/* This example requires Tailwind CSS v2.0+ */
+import { Dialog, Transition } from "@headlessui/react";
+import { HomeIcon, XMarkIcon } from "@heroicons/react/24/outline";
+import { Outlet } from "@remix-run/react";
+import type { LoaderArgs } from "@remix-run/server-runtime";
+import { Fragment, useState } from "react";
+import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
+import { getUser, requireUserId } from "~/services/session.server";
+
+const navigation = [{ name: "Home", href: "/admin", icon: HomeIcon }];
+
+export async function loader({ request }: LoaderArgs) {
+ await requireUserId(request);
+ const user = await getUser(request);
+ if (user == null) {
+ return redirect("/");
+ }
+
+ if (!user.admin) {
+ return redirect("/");
+ }
+
+ return typedjson({ user });
+}
+
+export default function Page() {
+ const data = useTypedLoaderData();
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+
+ return (
+ <>
+
+
+
+
+
+ {/* Static sidebar for desktop */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
+import { UserCircleIcon } from "@heroicons/react/24/solid";
+import classNames from "classnames";
+import type { User } from "~/models/user.server";
+
+function UserProfilePhoto({
+ user,
+ className,
+}: {
+ user: User;
+ className?: string;
+}) {
+ return user.avatarUrl ? (
+
+ ) : (
+
+ );
+}
diff --git a/apps/webapp/app/routes/admin/index.tsx b/apps/webapp/app/routes/admin/index.tsx
new file mode 100644
index 00000000000..108cfbf908c
--- /dev/null
+++ b/apps/webapp/app/routes/admin/index.tsx
@@ -0,0 +1,124 @@
+import { Form } from "@remix-run/react";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
+import { adminGetUsers } from "~/models/admin.server";
+import { PrimaryButton } from "~/components/primitives/Buttons";
+import { ActionArgs, redirect } from "@remix-run/server-runtime";
+import { z } from "zod";
+import {
+ commitImpersonationSession,
+ setImpersonationId,
+} from "~/services/impersonation.server";
+
+export async function loader() {
+ const users = await adminGetUsers();
+
+ return typedjson({ users });
+}
+
+const FormSchema = z.object({ id: z.string() });
+
+export async function action({ request }: ActionArgs) {
+ if (request.method.toLowerCase() !== "post") {
+ return new Response("Method not allowed", { status: 405 });
+ }
+
+ const payload = Object.fromEntries(await request.formData());
+ const { id } = FormSchema.parse(payload);
+
+ const session = await setImpersonationId(id, request);
+
+ return redirect("/", {
+ headers: { "Set-Cookie": await commitImpersonationSession(session) },
+ });
+}
+
+const headerClassName =
+ "py-3 px-2 pr-3 text-xs font-semibold leading-tight text-slate-900 text-left";
+const cellClassName = "whitespace-nowrap px-2 py-2 text-xs text-slate-500";
+
+export default function AdminDashboardRoute() {
+ const { users } = useTypedLoaderData();
+
+ return (
+
+ {/* Primary column */}
+
+ Accounts ({users.length})
+
+
+
+
+ |
+ Email
+ |
+
+ GitHub username
+ |
+
+ id
+ |
+
+ Created At
+ |
+
+ Admin?
+ |
+
+ Actions
+ |
+
+
+
+ {users.map((user) => {
+ return (
+
+ | {user.email} |
+
+
+ {user.displayName}
+
+ |
+ {user.id} |
+
+ {user.createdAt.toISOString()}
+ |
+ {user.admin ? "✅" : ""} |
+
+
+ |
+
+ );
+ })}
+
+
+
+
+ {/* Secondary column (hidden on smaller screens) */}
+
+
+ );
+}
diff --git a/apps/webapp/app/routes/resources/impersonation.ts b/apps/webapp/app/routes/resources/impersonation.ts
new file mode 100644
index 00000000000..7195a3ff7e7
--- /dev/null
+++ b/apps/webapp/app/routes/resources/impersonation.ts
@@ -0,0 +1,16 @@
+import { ActionArgs } from "@remix-run/server-runtime";
+import { redirect } from "remix-typedjson";
+import {
+ clearImpersonationId,
+ commitImpersonationSession,
+} from "~/services/impersonation.server";
+
+export async function action({ request }: ActionArgs) {
+ const session = await clearImpersonationId(request);
+
+ return redirect("/admin", {
+ headers: {
+ "Set-Cookie": await commitImpersonationSession(session),
+ },
+ });
+}
diff --git a/apps/webapp/app/services/impersonation.server.ts b/apps/webapp/app/services/impersonation.server.ts
new file mode 100644
index 00000000000..7ba0231be09
--- /dev/null
+++ b/apps/webapp/app/services/impersonation.server.ts
@@ -0,0 +1,44 @@
+import { createCookieSessionStorage, Session } from "@remix-run/node";
+import { env } from "~/env.server";
+
+export const impersonationSessionStorage = createCookieSessionStorage({
+ cookie: {
+ name: "__impersonate", // use any name you want here
+ sameSite: "lax", // this helps with CSRF
+ path: "/", // remember to add this so the cookie will work in all routes
+ httpOnly: true, // for security reasons, make this cookie http only
+ secrets: [env.SESSION_SECRET],
+ secure: env.NODE_ENV === "production", // enable this in prod only
+ maxAge: 60 * 60 * 24, // 1 day
+ },
+});
+
+export function getImpersonationSession(request: Request) {
+ return impersonationSessionStorage.getSession(request.headers.get("Cookie"));
+}
+
+export function commitImpersonationSession(session: Session) {
+ return impersonationSessionStorage.commitSession(session);
+}
+
+export async function getImpersonationId(request: Request) {
+ const session = await getImpersonationSession(request);
+
+ return session.get("impersonatedUserId");
+}
+
+export async function setImpersonationId(userId: string, request: Request) {
+ const session = await getImpersonationSession(request);
+
+ session.set("impersonatedUserId", userId);
+
+ return session;
+}
+
+export async function clearImpersonationId(request: Request) {
+ const session = await getImpersonationSession(request);
+
+ session.unset("impersonatedUserId");
+
+ return session;
+}
diff --git a/apps/webapp/app/services/session.server.ts b/apps/webapp/app/services/session.server.ts
index 8d0215aab0f..8240ca2e7f9 100644
--- a/apps/webapp/app/services/session.server.ts
+++ b/apps/webapp/app/services/session.server.ts
@@ -1,8 +1,13 @@
import { redirect } from "@remix-run/node";
import { getUserById } from "~/models/user.server";
import { authenticator } from "./auth.server";
+import { getImpersonationId } from "./impersonation.server";
export async function getUserId(request: Request): Promise {
+ const impersonatedUserId = await getImpersonationId(request);
+
+ if (impersonatedUserId) return impersonatedUserId;
+
let authUser = await authenticator.isAuthenticated(request);
return authUser?.userId;
}