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 (
+
+ );
+}
+
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 };