Add user migration admin feature

This commit is contained in:
Kalle
2022-07-05 20:15:30 +03:00
parent ab82ed4d28
commit 2ca589faf6
4 changed files with 139 additions and 43 deletions

View File

@@ -1,5 +1,10 @@
import clsx from "clsx";
import type * as React from "react";
export const Main = ({ children }: { children: React.ReactNode }) => (
<main className="layout__main">{children}</main>
);
export const Main = ({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) => <main className={clsx("layout__main", className)}>{children}</main>;

View File

@@ -53,10 +53,32 @@ const updateProfileStm = sql.prepare(`
WHERE "id" = $id
`);
export function updateProfile(params: Pick<User, "country" | "id" | "bio">) {
updateProfileStm.run(params);
export function updateProfile(args: Pick<User, "country" | "id" | "bio">) {
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"

View File

@@ -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<AdminPageLoaderData>();
const fetcher = useFetcher();
const [userIdToLogInAs, setUserIdToLogInAs] = React.useState<number>();
return (
<Main>
<fetcher.Form
method="post"
action={impersonateUrl(userIdToLogInAs ?? 0)}
className="stack md"
reloadDocument
>
<h2>Impersonate user</h2>
<div>
<label>User to log in as</label>
<UserCombobox
inputName="user"
onChange={(selected) =>
setUserIdToLogInAs(
selected?.value ? Number(selected.value) : undefined
)
}
/>
</div>
<div className="stack vertical md">
<Button type="submit" disabled={!userIdToLogInAs}>
Go
</Button>
{isImpersonating ? (
<Button type="submit" formAction={STOP_IMPERSONATING_URL}>
Stop impersonating
</Button>
) : null}
</div>
</fetcher.Form>
<Main className="stack lg">
<Impersonate />
<MigrateUser />
</Main>
);
}
function Impersonate() {
const fetcher = useFetcher();
const [userId, setUserId] = React.useState<number>();
const { isImpersonating } = useLoaderData<AdminPageLoaderData>();
return (
<fetcher.Form
method="post"
action={impersonateUrl(userId ?? 0)}
className="stack md"
reloadDocument
>
<h2>Impersonate user</h2>
<div>
<label>User to log in as</label>
<UserCombobox
inputName="user"
onChange={(selected) =>
setUserId(selected?.value ? Number(selected.value) : undefined)
}
/>
</div>
<div className="stack vertical md">
<Button type="submit" disabled={!userId}>
Go
</Button>
{isImpersonating ? (
<Button type="submit" formAction={STOP_IMPERSONATING_URL}>
Stop impersonating
</Button>
) : null}
</div>
</fetcher.Form>
);
}
function MigrateUser() {
const [oldUserId, setOldUserId] = React.useState<number>();
const [newUserId, setNewUserId] = React.useState<number>();
return (
<Form className="stack md" method="post">
<h2>Migrate user data</h2>
<div className="stack vertical md">
<div>
<label>Old user</label>
<UserCombobox
inputName="old-user"
onChange={(selected) =>
setOldUserId(selected?.value ? Number(selected.value) : undefined)
}
/>
</div>
<div>
<label>New user</label>
<UserCombobox
inputName="new-user"
onChange={(selected) =>
setNewUserId(selected?.value ? Number(selected.value) : undefined)
}
/>
</div>
</div>
<div className="stack vertical md">
<Button type="submit" disabled={!oldUserId || !newUserId}>
Migrate
</Button>
</div>
</Form>
);
}
export const CatchBoundary = Catcher;

View File

@@ -1,4 +1,3 @@
import { json } from "@remix-run/node";
import { z } from "zod";
export function notFoundIfFalsy<T>(value: T | null | undefined): T {