support tool to edit usernames
This commit is contained in:
parent
6a43f92438
commit
7c9313f4a6
4 changed files with 191 additions and 20 deletions
|
@ -30,7 +30,8 @@ GRANT SELECT ON closet_lists TO impress2020;
|
||||||
GRANT SELECT ON item_outfit_relationships TO impress2020;
|
GRANT SELECT ON item_outfit_relationships TO impress2020;
|
||||||
GRANT SELECT ON neopets_connections TO impress2020;
|
GRANT SELECT ON neopets_connections TO impress2020;
|
||||||
GRANT SELECT ON outfits TO impress2020;
|
GRANT SELECT ON outfits TO impress2020;
|
||||||
GRANT SELECT ON users TO impress2020;
|
GRANT SELECT, UPDATE ON users TO impress2020;
|
||||||
|
GRANT SELECT, UPDATE ON openneo_id.users TO impress2020;
|
||||||
|
|
||||||
-- mysqldump
|
-- mysqldump
|
||||||
GRANT LOCK TABLES ON * TO impress2020;
|
GRANT LOCK TABLES ON * TO impress2020;
|
||||||
|
|
|
@ -10,6 +10,11 @@ import {
|
||||||
InputGroup,
|
InputGroup,
|
||||||
InputLeftElement,
|
InputLeftElement,
|
||||||
InputRightElement,
|
InputRightElement,
|
||||||
|
Menu,
|
||||||
|
MenuButton,
|
||||||
|
MenuItem,
|
||||||
|
MenuList,
|
||||||
|
Portal,
|
||||||
Wrap,
|
Wrap,
|
||||||
VStack,
|
VStack,
|
||||||
useToast,
|
useToast,
|
||||||
|
@ -17,13 +22,14 @@ import {
|
||||||
import {
|
import {
|
||||||
ArrowForwardIcon,
|
ArrowForwardIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
EditIcon,
|
||||||
EmailIcon,
|
EmailIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
} from "@chakra-ui/icons";
|
} from "@chakra-ui/icons";
|
||||||
import gql from "graphql-tag";
|
import gql from "graphql-tag";
|
||||||
import { useHistory, useParams } from "react-router-dom";
|
import { useHistory, useParams } from "react-router-dom";
|
||||||
import { useQuery, useLazyQuery } from "@apollo/client";
|
import { useQuery, useLazyQuery, useMutation } from "@apollo/client";
|
||||||
import SimpleMarkdown from "simple-markdown";
|
import SimpleMarkdown from "simple-markdown";
|
||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
@ -37,9 +43,15 @@ import ItemCard, {
|
||||||
YouWantThisBadge,
|
YouWantThisBadge,
|
||||||
getZoneBadges,
|
getZoneBadges,
|
||||||
} from "./components/ItemCard";
|
} from "./components/ItemCard";
|
||||||
|
import SupportOnly from "./WardrobePage/support/SupportOnly";
|
||||||
|
import useSupport from "./WardrobePage/support/useSupport";
|
||||||
import useCurrentUser from "./components/useCurrentUser";
|
import useCurrentUser from "./components/useCurrentUser";
|
||||||
import WIPCallout from "./components/WIPCallout";
|
import WIPCallout from "./components/WIPCallout";
|
||||||
|
|
||||||
|
const BadgeButton = React.forwardRef((props, ref) => (
|
||||||
|
<Badge as="button" ref={ref} {...props} />
|
||||||
|
));
|
||||||
|
|
||||||
function UserItemsPage() {
|
function UserItemsPage() {
|
||||||
const { userId } = useParams();
|
const { userId } = useParams();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
|
@ -164,6 +176,14 @@ function UserItemsPage() {
|
||||||
Neomail
|
Neomail
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
<SupportOnly>
|
||||||
|
<UserSupportMenu user={data.user}>
|
||||||
|
<MenuButton as={BadgeButton} display="flex" alignItems="center">
|
||||||
|
<EditIcon marginRight="1" />
|
||||||
|
Support
|
||||||
|
</MenuButton>
|
||||||
|
</UserSupportMenu>
|
||||||
|
</SupportOnly>
|
||||||
{/* Usually I put "Own" before "Want", but this matches the natural
|
{/* Usually I put "Own" before "Want", but this matches the natural
|
||||||
* order on the page: the _matches_ for things you want are things
|
* order on the page: the _matches_ for things you want are things
|
||||||
* _this user_ owns, so they come first. I think it's also probably a
|
* _this user_ owns, so they come first. I think it's also probably a
|
||||||
|
@ -454,6 +474,76 @@ function MarkdownAndSafeHTML({ children }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UserSupportMenu({ children, user }) {
|
||||||
|
const { supportSecret } = useSupport();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
|
const [sendEditUsernameMutation] = useMutation(
|
||||||
|
gql`
|
||||||
|
mutation UserSupportMenuRename(
|
||||||
|
$userId: ID!
|
||||||
|
$newUsername: String!
|
||||||
|
$supportSecret: String!
|
||||||
|
) {
|
||||||
|
setUsername(
|
||||||
|
userId: $userId
|
||||||
|
newUsername: $newUsername
|
||||||
|
supportSecret: $supportSecret
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
onCompleted: (data) => {
|
||||||
|
const updatedUser = data.setUsername;
|
||||||
|
toast({
|
||||||
|
status: "success",
|
||||||
|
title: `Successfully renamed user ${updatedUser.id} to ${updatedUser.username}!`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const editUsername = React.useCallback(() => {
|
||||||
|
const newUsername = prompt(
|
||||||
|
"What should this user's username be?",
|
||||||
|
user.username
|
||||||
|
);
|
||||||
|
if (!newUsername || newUsername === user.username) {
|
||||||
|
toast({
|
||||||
|
status: "info",
|
||||||
|
title: "Got it, no change!",
|
||||||
|
description: `User ${user.id}'s username will continue to be ${user.username}.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendEditUsernameMutation({
|
||||||
|
variables: { userId: user.id, newUsername, supportSecret },
|
||||||
|
}).catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
toast({
|
||||||
|
status: "error",
|
||||||
|
title: "Error renaming user.",
|
||||||
|
description: "See error details in the console!",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [sendEditUsernameMutation, user.id, user.username, supportSecret, toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu>
|
||||||
|
{children}
|
||||||
|
<Portal>
|
||||||
|
<MenuList>
|
||||||
|
<MenuItem onClick={editUsername}>Edit username</MenuItem>
|
||||||
|
</MenuList>
|
||||||
|
</Portal>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function NeopetsStarIcon(props) {
|
function NeopetsStarIcon(props) {
|
||||||
// Converted from the Neopets favicon with https://www.vectorizer.io/.
|
// Converted from the Neopets favicon with https://www.vectorizer.io/.
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -32,6 +32,9 @@ const typePolicies = {
|
||||||
color: (_, { args, toReference }) => {
|
color: (_, { args, toReference }) => {
|
||||||
return toReference({ __typename: "Color", id: args.id }, true);
|
return toReference({ __typename: "Color", id: args.id }, true);
|
||||||
},
|
},
|
||||||
|
user: (_, { args, toReference }) => {
|
||||||
|
return toReference({ __typename: "User", id: args.id }, true);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,13 @@
|
||||||
const { gql } = require("apollo-server");
|
const { gql } = require("apollo-server");
|
||||||
|
const { ManagementClient } = require("auth0");
|
||||||
|
|
||||||
|
const auth0 = new ManagementClient({
|
||||||
|
domain: "openneo.us.auth0.com",
|
||||||
|
clientId: process.env.AUTH0_SUPPORT_CLIENT_ID,
|
||||||
|
clientSecret: process.env.AUTH0_SUPPORT_CLIENT_SECRET,
|
||||||
|
scope: "read:users update:users",
|
||||||
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
capitalize,
|
capitalize,
|
||||||
getPoseFromPetState,
|
getPoseFromPetState,
|
||||||
|
@ -51,6 +60,8 @@ const typeDefs = gql`
|
||||||
isGlitched: Boolean!
|
isGlitched: Boolean!
|
||||||
supportSecret: String!
|
supportSecret: String!
|
||||||
): PetAppearance!
|
): PetAppearance!
|
||||||
|
|
||||||
|
setUsername(userId: ID!, newUsername: String!, supportSecret: String!): User
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
@ -61,9 +72,7 @@ const resolvers = {
|
||||||
{ itemId, colorId, supportSecret },
|
{ itemId, colorId, supportSecret },
|
||||||
{ itemLoader, itemTranslationLoader, colorTranslationLoader, db }
|
{ itemLoader, itemTranslationLoader, colorTranslationLoader, db }
|
||||||
) => {
|
) => {
|
||||||
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
|
assertSupportSecretOrThrow(supportSecret);
|
||||||
throw new Error(`Support secret is incorrect. Try setting up again?`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldItem = await itemLoader.load(itemId);
|
const oldItem = await itemLoader.load(itemId);
|
||||||
|
|
||||||
|
@ -139,9 +148,7 @@ const resolvers = {
|
||||||
{ itemId, explicitlyBodySpecific, supportSecret },
|
{ itemId, explicitlyBodySpecific, supportSecret },
|
||||||
{ itemLoader, itemTranslationLoader, db }
|
{ itemLoader, itemTranslationLoader, db }
|
||||||
) => {
|
) => {
|
||||||
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
|
assertSupportSecretOrThrow(supportSecret);
|
||||||
throw new Error(`Support secret is incorrect. Try setting up again?`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldItem = await itemLoader.load(itemId);
|
const oldItem = await itemLoader.load(itemId);
|
||||||
|
|
||||||
|
@ -210,9 +217,7 @@ const resolvers = {
|
||||||
db,
|
db,
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
|
assertSupportSecretOrThrow(supportSecret);
|
||||||
throw new Error(`Support secret is incorrect. Try setting up again?`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldSwfAsset = await swfAssetLoader.load(layerId);
|
const oldSwfAsset = await swfAssetLoader.load(layerId);
|
||||||
|
|
||||||
|
@ -298,9 +303,7 @@ const resolvers = {
|
||||||
db,
|
db,
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
|
assertSupportSecretOrThrow(supportSecret);
|
||||||
throw new Error(`Support secret is incorrect. Try setting up again?`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldSwfAsset = await swfAssetLoader.load(layerId);
|
const oldSwfAsset = await swfAssetLoader.load(layerId);
|
||||||
|
|
||||||
|
@ -374,9 +377,7 @@ const resolvers = {
|
||||||
db,
|
db,
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
|
assertSupportSecretOrThrow(supportSecret);
|
||||||
throw new Error(`Support secret is incorrect. Try setting up again?`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldPetState = await petStateLoader.load(appearanceId);
|
const oldPetState = await petStateLoader.load(appearanceId);
|
||||||
|
|
||||||
|
@ -457,9 +458,7 @@ const resolvers = {
|
||||||
db,
|
db,
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
|
assertSupportSecretOrThrow(supportSecret);
|
||||||
throw new Error(`Support secret is incorrect. Try setting up again?`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oldPetState = await petStateLoader.load(appearanceId);
|
const oldPetState = await petStateLoader.load(appearanceId);
|
||||||
|
|
||||||
|
@ -526,7 +525,85 @@ const resolvers = {
|
||||||
|
|
||||||
return { id: appearanceId };
|
return { id: appearanceId };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setUsername: async (
|
||||||
|
_,
|
||||||
|
{ userId, newUsername, supportSecret },
|
||||||
|
{ userLoader, db }
|
||||||
|
) => {
|
||||||
|
assertSupportSecretOrThrow(supportSecret);
|
||||||
|
|
||||||
|
const oldUser = await userLoader.load(userId);
|
||||||
|
if (!oldUser) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [[result1, result2]] = await db.query(
|
||||||
|
`
|
||||||
|
UPDATE users SET name = ? WHERE id = ? LIMIT 1;
|
||||||
|
UPDATE openneo_id.users SET name = ? WHERE id = ? LIMIT 1;
|
||||||
|
`,
|
||||||
|
[newUsername, userId, newUsername, oldUser.remoteId]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result1.affectedRows !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`[UPDATE 1] Expected to affect 1 user, but affected ${result1.affectedRows}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result2.affectedRows !== 1) {
|
||||||
|
throw new Error(
|
||||||
|
`[UPDATE 2] Expected to affect 1 user, but affected ${result2.affectedRows}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// we changed it, so clear it from cache
|
||||||
|
userLoader.clear(userId);
|
||||||
|
|
||||||
|
// We also want to update the username in Auth0, which is separate! It's
|
||||||
|
// possible that this will fail even though the db update succeeded, but
|
||||||
|
// I'm not going to bother to write recovery code; in that case, the
|
||||||
|
// error will reach the support user console, and we can work to manually
|
||||||
|
// fix it.
|
||||||
|
await auth0.users.update(
|
||||||
|
{ id: `auth0|impress-${userId}` },
|
||||||
|
{ username: newUsername }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (process.env["SUPPORT_TOOLS_DISCORD_WEBHOOK_URL"]) {
|
||||||
|
try {
|
||||||
|
await logToDiscord({
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: `🛠 User ${oldUser.id}: ${newUsername}`,
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
name: `Username`,
|
||||||
|
value: `${oldUser.name} → **${newUsername}**`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
url: `https://impress-2020.openneo.net/user/${oldUser.id}/items`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error sending Discord support log", e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("No Discord support webhook provided, skipping");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { id: userId };
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function assertSupportSecretOrThrow(supportSecret) {
|
||||||
|
if (supportSecret !== process.env["SUPPORT_SECRET"]) {
|
||||||
|
throw new Error(`Support secret is incorrect. Try setting up again?`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = { typeDefs, resolvers };
|
module.exports = { typeDefs, resolvers };
|
||||||
|
|
Loading…
Reference in a new issue