From 2ca589faf67706cbe70bf39137d247512887cffc Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 5 Jul 2022 20:15:30 +0300 Subject: [PATCH] Add user migration admin feature --- app/components/Main.tsx | 11 ++- app/db/models/users.server.ts | 26 +++++- app/routes/admin.tsx | 144 +++++++++++++++++++++++++--------- app/utils/remix.ts | 1 - 4 files changed, 139 insertions(+), 43 deletions(-) diff --git a/app/components/Main.tsx b/app/components/Main.tsx index d80ffc1fa..a84f14733 100644 --- a/app/components/Main.tsx +++ b/app/components/Main.tsx @@ -1,5 +1,10 @@ +import clsx from "clsx"; import type * as React from "react"; -export const Main = ({ children }: { children: React.ReactNode }) => ( -
{children}
-); +export const Main = ({ + children, + className, +}: { + children: React.ReactNode; + className?: string; +}) =>
{children}
; diff --git a/app/db/models/users.server.ts b/app/db/models/users.server.ts index 5e31fed99..872ab1514 100644 --- a/app/db/models/users.server.ts +++ b/app/db/models/users.server.ts @@ -53,10 +53,32 @@ const updateProfileStm = sql.prepare(` WHERE "id" = $id `); -export function updateProfile(params: Pick) { - updateProfileStm.run(params); +export function updateProfile(args: Pick) { + updateProfileStm.run(args); } +const deleteStm = sql.prepare(` + delete from "User" where id = $id + returning * +`); + +const updateDiscordId = sql.prepare(` + update "User" + set "discordId" = $discordId + where "id" = $id +`); + +export const migrate = sql.transaction( + (args: { newUserId: User["id"]; oldUserId: User["id"] }) => { + const deletedUser = deleteStm.get({ id: args.newUserId }) as User; + + updateDiscordId.run({ + id: args.oldUserId, + discordId: deletedUser.discordId, + }); + } +); + const findByIdentifierStm = sql.prepare(` SELECT "User".*, "PlusTier".tier as "plusTier" FROM "User" diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index 066002be6..4c36cd1aa 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -1,6 +1,10 @@ import { json, redirect } from "@remix-run/node"; -import type { LoaderFunction, MetaFunction } from "@remix-run/node"; -import { useFetcher, useLoaderData } from "@remix-run/react"; +import type { + LoaderFunction, + MetaFunction, + ActionFunction, +} from "@remix-run/node"; +import { Form, useFetcher, useLoaderData } from "@remix-run/react"; import * as React from "react"; import { Button } from "~/components/Button"; import { Catcher } from "~/components/Catcher"; @@ -9,8 +13,11 @@ import { Main } from "~/components/Main"; import { requireUser } from "~/modules/auth"; import { isImpersonating } from "~/modules/auth/user.server"; import { canPerformAdminActions } from "~/permissions"; -import { makeTitle } from "~/utils/remix"; +import { makeTitle, parseRequestFormData, validate } from "~/utils/remix"; import { impersonateUrl, STOP_IMPERSONATING_URL } from "~/utils/urls"; +import { db } from "~/db"; +import { z } from "zod"; +import { actualNumber } from "~/utils/zod"; export const meta: MetaFunction = () => { return { @@ -18,6 +25,28 @@ export const meta: MetaFunction = () => { }; }; +const adminActionSchema = z.object({ + "old-user[value]": z.preprocess(actualNumber, z.number().positive()), + "new-user[value]": z.preprocess(actualNumber, z.number().positive()), +}); + +export const action: ActionFunction = async ({ request }) => { + const data = await parseRequestFormData({ + request, + schema: adminActionSchema, + }); + const user = await requireUser(request); + + validate(canPerformAdminActions(user)); + + db.users.migrate({ + oldUserId: data["old-user[value]"], + newUserId: data["new-user[value]"], + }); + + return null; +}; + interface AdminPageLoaderData { isImpersonating: boolean; } @@ -35,43 +64,84 @@ export const loader: LoaderFunction = async ({ request }) => { }; export default function AdminPage() { - const { isImpersonating } = useLoaderData(); - const fetcher = useFetcher(); - const [userIdToLogInAs, setUserIdToLogInAs] = React.useState(); - return ( -
- -

Impersonate user

-
- - - setUserIdToLogInAs( - selected?.value ? Number(selected.value) : undefined - ) - } - /> -
-
- - {isImpersonating ? ( - - ) : null} -
-
+
+ +
); } +function Impersonate() { + const fetcher = useFetcher(); + const [userId, setUserId] = React.useState(); + const { isImpersonating } = useLoaderData(); + + return ( + +

Impersonate user

+
+ + + setUserId(selected?.value ? Number(selected.value) : undefined) + } + /> +
+
+ + {isImpersonating ? ( + + ) : null} +
+
+ ); +} + +function MigrateUser() { + const [oldUserId, setOldUserId] = React.useState(); + const [newUserId, setNewUserId] = React.useState(); + + return ( +
+

Migrate user data

+
+
+ + + setOldUserId(selected?.value ? Number(selected.value) : undefined) + } + /> +
+
+ + + setNewUserId(selected?.value ? Number(selected.value) : undefined) + } + /> +
+
+
+ +
+
+ ); +} + export const CatchBoundary = Catcher; diff --git a/app/utils/remix.ts b/app/utils/remix.ts index c41145a15..27c239956 100644 --- a/app/utils/remix.ts +++ b/app/utils/remix.ts @@ -1,4 +1,3 @@ -import { json } from "@remix-run/node"; import { z } from "zod"; export function notFoundIfFalsy(value: T | null | undefined): T {