mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-13 14:46:10 -05:00
Add user migration admin feature
This commit is contained in:
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { json } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
|
||||
export function notFoundIfFalsy<T>(value: T | null | undefined): T {
|
||||
|
||||
Reference in New Issue
Block a user