From 7d1b4e71a5f3f143bcad86d9969c2c898ae05a74 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Tue, 7 Feb 2023 18:16:55 +0000 Subject: [PATCH 1/2] Made the horizontal scrollbar visible --- .../__app/orgs/$organizationSlug/__org/workflows.new.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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(); From 283f6d6be686b25c4ed4f51e053f997d9f5724ef Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 8 Feb 2023 09:50:36 +0000 Subject: [PATCH 2/2] Added a very simple admin section to assist in user support --- .../app/components/ImpersonationBanner.tsx | 30 ++- .../app/components/layout/AppLayout.tsx | 23 +- apps/webapp/app/models/admin.server.ts | 9 + apps/webapp/app/routes/__app.tsx | 9 +- apps/webapp/app/routes/admin.tsx | 212 ++++++++++++++++++ apps/webapp/app/routes/admin/index.tsx | 124 ++++++++++ .../app/routes/resources/impersonation.ts | 16 ++ .../app/services/impersonation.server.ts | 44 ++++ apps/webapp/app/services/session.server.ts | 5 + 9 files changed, 454 insertions(+), 18 deletions(-) create mode 100644 apps/webapp/app/models/admin.server.ts create mode 100644 apps/webapp/app/routes/admin.tsx create mode 100644 apps/webapp/app/routes/admin/index.tsx create mode 100644 apps/webapp/app/routes/resources/impersonation.ts create mode 100644 apps/webapp/app/services/impersonation.server.ts 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/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 ( + <> +
+ + + +
+ + +
+ + + +
+ +
+
+
+
+ Workflow +
+ +
+
+ +
+
+
+ +
+
+
+ + {/* Static sidebar for desktop */} +
+
+
+
+
+ Workflow +
+ +
+
+ +
+
+
+
+ +
+ +
+
+ + ); +} + +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 ? ( + {user.name + ) : ( + + ); +} 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})

+ + + + + + + + + + + + + + {users.map((user) => { + return ( + + + + + + + + + ); + })} + +
+ Email + + GitHub username + + id + + Created At + + Admin? + + Actions +
{user.email} + + {user.displayName} + + {user.id} + {user.createdAt.toISOString()} + {user.admin ? "✅" : ""} +
+ + + + Impersonate + +
+
+
+ + {/* 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; }