diff --git a/scripts/setup-mysql.sql b/scripts/setup-mysql.sql index 1018209..ff19a19 100644 --- a/scripts/setup-mysql.sql +++ b/scripts/setup-mysql.sql @@ -30,7 +30,8 @@ GRANT SELECT ON closet_lists TO impress2020; GRANT SELECT ON item_outfit_relationships TO impress2020; GRANT SELECT ON neopets_connections 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 GRANT LOCK TABLES ON * TO impress2020; diff --git a/src/app/UserItemsPage.js b/src/app/UserItemsPage.js index ac45038..336da4c 100644 --- a/src/app/UserItemsPage.js +++ b/src/app/UserItemsPage.js @@ -10,6 +10,11 @@ import { InputGroup, InputLeftElement, InputRightElement, + Menu, + MenuButton, + MenuItem, + MenuList, + Portal, Wrap, VStack, useToast, @@ -17,13 +22,14 @@ import { import { ArrowForwardIcon, CheckIcon, + EditIcon, EmailIcon, SearchIcon, StarIcon, } from "@chakra-ui/icons"; import gql from "graphql-tag"; 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 DOMPurify from "dompurify"; @@ -37,9 +43,15 @@ import ItemCard, { YouWantThisBadge, getZoneBadges, } from "./components/ItemCard"; +import SupportOnly from "./WardrobePage/support/SupportOnly"; +import useSupport from "./WardrobePage/support/useSupport"; import useCurrentUser from "./components/useCurrentUser"; import WIPCallout from "./components/WIPCallout"; +const BadgeButton = React.forwardRef((props, ref) => ( + +)); + function UserItemsPage() { const { userId } = useParams(); const currentUser = useCurrentUser(); @@ -164,6 +176,14 @@ function UserItemsPage() { Neomail )} + + + + + Support + + + {/* Usually I put "Own" before "Want", but this matches the natural * 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 @@ -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 ( + + {children} + + + Edit username + + + + ); +} + function NeopetsStarIcon(props) { // Converted from the Neopets favicon with https://www.vectorizer.io/. return ( diff --git a/src/app/apolloClient.js b/src/app/apolloClient.js index 0b9e37d..a33becf 100644 --- a/src/app/apolloClient.js +++ b/src/app/apolloClient.js @@ -32,6 +32,9 @@ const typePolicies = { color: (_, { args, toReference }) => { return toReference({ __typename: "Color", id: args.id }, true); }, + user: (_, { args, toReference }) => { + return toReference({ __typename: "User", id: args.id }, true); + }, }, }, diff --git a/src/server/types/MutationsForSupport.js b/src/server/types/MutationsForSupport.js index 85cebf3..85b2d2b 100644 --- a/src/server/types/MutationsForSupport.js +++ b/src/server/types/MutationsForSupport.js @@ -1,4 +1,13 @@ 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 { capitalize, getPoseFromPetState, @@ -51,6 +60,8 @@ const typeDefs = gql` isGlitched: Boolean! supportSecret: String! ): PetAppearance! + + setUsername(userId: ID!, newUsername: String!, supportSecret: String!): User } `; @@ -61,9 +72,7 @@ const resolvers = { { itemId, colorId, supportSecret }, { itemLoader, itemTranslationLoader, colorTranslationLoader, db } ) => { - if (supportSecret !== process.env["SUPPORT_SECRET"]) { - throw new Error(`Support secret is incorrect. Try setting up again?`); - } + assertSupportSecretOrThrow(supportSecret); const oldItem = await itemLoader.load(itemId); @@ -139,9 +148,7 @@ const resolvers = { { itemId, explicitlyBodySpecific, supportSecret }, { itemLoader, itemTranslationLoader, db } ) => { - if (supportSecret !== process.env["SUPPORT_SECRET"]) { - throw new Error(`Support secret is incorrect. Try setting up again?`); - } + assertSupportSecretOrThrow(supportSecret); const oldItem = await itemLoader.load(itemId); @@ -210,9 +217,7 @@ const resolvers = { db, } ) => { - if (supportSecret !== process.env["SUPPORT_SECRET"]) { - throw new Error(`Support secret is incorrect. Try setting up again?`); - } + assertSupportSecretOrThrow(supportSecret); const oldSwfAsset = await swfAssetLoader.load(layerId); @@ -298,9 +303,7 @@ const resolvers = { db, } ) => { - if (supportSecret !== process.env["SUPPORT_SECRET"]) { - throw new Error(`Support secret is incorrect. Try setting up again?`); - } + assertSupportSecretOrThrow(supportSecret); const oldSwfAsset = await swfAssetLoader.load(layerId); @@ -374,9 +377,7 @@ const resolvers = { db, } ) => { - if (supportSecret !== process.env["SUPPORT_SECRET"]) { - throw new Error(`Support secret is incorrect. Try setting up again?`); - } + assertSupportSecretOrThrow(supportSecret); const oldPetState = await petStateLoader.load(appearanceId); @@ -457,9 +458,7 @@ const resolvers = { db, } ) => { - if (supportSecret !== process.env["SUPPORT_SECRET"]) { - throw new Error(`Support secret is incorrect. Try setting up again?`); - } + assertSupportSecretOrThrow(supportSecret); const oldPetState = await petStateLoader.load(appearanceId); @@ -526,7 +525,85 @@ const resolvers = { 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 };