From 8f4955812d8fd05eba1a9892558db8fe40d2fc48 Mon Sep 17 00:00:00 2001 From: zk Date: Sun, 2 Nov 2025 18:06:43 +0000 Subject: [PATCH] feat: support changing your own names (#1383) --- .vscode/settings.json | 2 +- .../dashboard/users/UserSettingsPage.tsx | 408 +++++++++++++----- common/src/types/documents.ts | 7 + server/src/external/mongo/db.ts | 2 + server/src/external/mongo/schemas.ts | 6 + .../router/api/v1/users/_userID/router.ts | 161 ++++++- server/src/utils/user.ts | 22 + 7 files changed, 488 insertions(+), 120 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index d01d0f021..958b4efe8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,7 +1,7 @@ { "typescript.tsdk": "node_modules/typescript/lib", "[typescriptreact]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint" + "editor.defaultFormatter": "dbaeumer.vscode-eslint", }, "[typescript]": { "editor.defaultFormatter": "dbaeumer.vscode-eslint" diff --git a/client/src/app/pages/dashboard/users/UserSettingsPage.tsx b/client/src/app/pages/dashboard/users/UserSettingsPage.tsx index 1e9287db2..d096cf84e 100644 --- a/client/src/app/pages/dashboard/users/UserSettingsPage.tsx +++ b/client/src/app/pages/dashboard/users/UserSettingsPage.tsx @@ -1,6 +1,7 @@ import { APIFetchV1, ToAPIURL } from "util/api"; import { DelayedPageReload, FetchJSONBody, UppercaseFirst } from "util/misc"; import { Themes, getStoredTheme, mediaQueryPrefers, setTheme } from "util/themeUtils"; +import { ONE_DAY } from "util/constants/time"; import useSetSubheader from "components/layout/header/useSetSubheader"; import Card from "components/layout/page/Card"; import ProfilePicture from "components/user/ProfilePicture"; @@ -10,15 +11,16 @@ import Muted from "components/util/Muted"; import SelectButton from "components/util/SelectButton"; import { UserSettingsContext } from "context/UserSettingsContext"; import { useFormik } from "formik"; -import React, { useContext, useRef, useState } from "react"; +import React, { useContext, useEffect, useMemo, useRef, useState } from "react"; import Alert from "react-bootstrap/Alert"; import Button from "react-bootstrap/Button"; import Form from "react-bootstrap/Form"; import InputGroup from "react-bootstrap/InputGroup"; import Stack from "react-bootstrap/Stack"; -import { UserDocument, UserSettingsDocument } from "tachi-common"; +import { integer, UserDocument, UserSettingsDocument } from "tachi-common"; import toast from "react-hot-toast"; import { SetState } from "types/react"; +import useApiQuery from "components/util/query/useApiQuery"; interface Props { reqUser: UserDocument; @@ -97,36 +99,34 @@ export default function UserSettingsDocumentPage({ reqUser }: Props) { } export function AccountSettings({ reqUser }: { reqUser: UserDocument }) { - const formikPassword = useFormik({ - initialValues: { - "!oldPassword": "", - "!password": "", - confPass: "", - }, - onSubmit: async (values) => { - const r = await APIFetchV1( - `/users/${reqUser.id}/change-password`, - { - method: "POST", - ...FetchJSONBody({ - "!oldPassword": values["!oldPassword"], - "!password": values["!password"], - }), - }, - true, - true - ); + const [page, setPage] = useState<"email" | "password" | "username">("email"); - if (r.success) { - formikPassword.setValues({ - "!oldPassword": "", - "!password": "", - confPass: "", - }); - } - }, - }); + return ( + <> +
+ + Email + + + Password + + + Username + +
+ + {page === "email" ? ( + + ) : page === "password" ? ( + + ) : ( + + )} + + ); +} +function ChangeEmailForm({ reqUser }: { reqUser: UserDocument }) { const formikEmail = useFormik({ initialValues: { "!password": "", @@ -157,113 +157,285 @@ export function AccountSettings({ reqUser }: { reqUser: UserDocument }) { }, }); + return ( +
+ + Password + + {formikEmail.values["!password"].length < 8 && ( + + Passwords have to be at least 8 characters long. + + )} + + + New Email + + + + Confirm New Email + + + {!(formikEmail.values.email === formikEmail.values.confEmail) && ( + Emails don't match! + )} + +
+ ); +} + +function ChangePasswordForm({ reqUser }: { reqUser: UserDocument }) { + const formikPassword = useFormik({ + initialValues: { + "!oldPassword": "", + "!password": "", + confPass: "", + }, + onSubmit: async (values) => { + const r = await APIFetchV1( + `/users/${reqUser.id}/change-password`, + { + method: "POST", + ...FetchJSONBody({ + "!oldPassword": values["!oldPassword"], + "!password": values["!password"], + }), + }, + true, + true + ); + + if (r.success) { + formikPassword.setValues({ + "!oldPassword": "", + "!password": "", + confPass: "", + }); + } + }, + }); + + return ( +
+ + Old Password + + + + New Password + + {formikPassword.values["!password"].length < 8 && ( + + Passwords have to be at least 8 characters long. + + )} + + + Confirm New Password + + + {!(formikPassword.values["!password"] === formikPassword.values.confPass) && ( + Passwords don't match! + )} + +
+ ); +} + +function ChangeUsernameForm({ reqUser }: { reqUser: UserDocument }) { + const lastUsernameChange = useApiQuery< + { canChange: true } | { canChange: false; nextAvailableChange: integer | null } + >(`/users/${reqUser.id}/last-username-change`); + + const nameChangeFormik = useFormik({ + initialValues: { + "!password": "", + newUsername: "", + }, + onSubmit: async (values) => { + const r = await APIFetchV1( + `/users/${reqUser.id}/change-username`, + { + method: "POST", + ...FetchJSONBody({ + "!password": values["!password"], + newUsername: values.newUsername, + }), + }, + true, + true + ); + + if (r.success) { + nameChangeFormik.setValues({ + "!password": "", + newUsername: "", + }); + + setTimeout(() => { + window.location.href = "/u/me"; + }, 300); + } + }, + }); + + const [usernameState, setUsernameState] = useState({ + isTaken: true, + isValid: false, + }); + const [lastTimeout, setLastTimeout] = useState(null); + + useEffect(() => { + if (lastTimeout !== null) { + clearTimeout(lastTimeout); + } + + const isValid = /^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u.test( + nameChangeFormik.values.newUsername + ); + + setUsernameState((state) => ({ + ...state, + isValid, + })); + + if (!isValid) { + return; + } + + const handle = window.setTimeout(async () => { + const usernameQuery = await APIFetchV1( + `/users/${nameChangeFormik.values.newUsername}` + ); + + setUsernameState((state) => ({ + ...state, + isTaken: usernameQuery.statusCode === 200, + })); + }, 600); + + setLastTimeout(handle); + }, [nameChangeFormik.values.newUsername]); + return ( <> -
+ Password - {formikEmail.values["!password"].length < 8 && ( - - Passwords have to be at least 8 characters long. - + + + New Username + + {nameChangeFormik.values.newUsername.length > 3 && ( +
+ {usernameState.isValid && usernameState.isTaken && ( + Username is already in use. + )} + + {!usernameState.isValid && ( + + Username is invalid. Must be 3-20 characters long. Must start + with a letter. Must only contain letters, numbers, and + underscores. + + )} +
)}
- - New Email - - - - Confirm New Email - - - {!(formikEmail.values.email === formikEmail.values.confEmail) && ( - Emails don't match! + {!lastUsernameChange.data?.canChange && ( + + You can only change your username every 6 months. Your next username change + will be available on{" "} + {new Date( + lastUsernameChange.data?.nextAvailableChange ?? 0 + ).toLocaleDateString()} + . + )} -
- -
- - Old Password - - - - New Password - - {formikPassword.values["!password"].length < 8 && ( - - Passwords have to be at least 8 characters long. - - )} - - - Confirm New Password - - - {!(formikPassword.values["!password"] === formikPassword.values.confPass) && ( - Passwords don't match! - )} -
diff --git a/common/src/types/documents.ts b/common/src/types/documents.ts index 4ca64f484..903a53540 100644 --- a/common/src/types/documents.ts +++ b/common/src/types/documents.ts @@ -776,3 +776,10 @@ export interface ImportTrackerFailed extends BaseImportTracker { * is kept track of via { @see ImportDocument }. */ export type ImportTrackerDocument = ImportTrackerFailed | ImportTrackerOngoing; + +export interface UserNameChangeDocument { + userID: integer; + username: string; + timestamp: integer; + previousUsername: string; +} diff --git a/server/src/external/mongo/db.ts b/server/src/external/mongo/db.ts index a57394876..bf0cc26c9 100644 --- a/server/src/external/mongo/db.ts +++ b/server/src/external/mongo/db.ts @@ -49,6 +49,7 @@ import type { CGCardInfo, GPTStrings, MytCardInfo, + UserNameChangeDocument, } from "tachi-common"; import type { MigrationDocument, PrivateUserInfoDocument } from "utils/types"; @@ -212,6 +213,7 @@ const db = { migrations: monkDB.get("migrations"), notifications: monkDB.get("notifications"), "import-trackers": monkDB.get("import-trackers"), + "user-name-changes": monkDB.get("user-name-changes"), }; export type StaticDatabases = Exclude< diff --git a/server/src/external/mongo/schemas.ts b/server/src/external/mongo/schemas.ts index f070f42ef..125e03a39 100644 --- a/server/src/external/mongo/schemas.ts +++ b/server/src/external/mongo/schemas.ts @@ -25,6 +25,12 @@ function prSchemaify(schema: PrudenceSchema) { } export const DatabaseSchemas: Record = { + "user-name-changes": prSchemaify({ + userID: p.isPositiveNonZeroInteger, + username: "string", + timestamp: p.isPositiveInteger, + previousUsername: "string", + }), "import-locks": prSchemaify({ userID: p.isPositiveNonZeroInteger, locked: "boolean", diff --git a/server/src/server/router/api/v1/users/_userID/router.ts b/server/src/server/router/api/v1/users/_userID/router.ts index ad141c807..b4b448713 100644 --- a/server/src/server/router/api/v1/users/_userID/router.ts +++ b/server/src/server/router/api/v1/users/_userID/router.ts @@ -32,7 +32,15 @@ import { GetRecentlyViewedFoldersAnyGPT, } from "utils/queries/summary"; import { GetUser } from "utils/req-tachi-data"; -import { CheckIfEmailInUse, FormatUserDoc, GetAllRankings, GetUserWithID } from "utils/user"; +import { + CanChangeUsername, + CheckIfEmailInUse, + FormatUserDoc, + GetAllRankings, + GetNextAvailableUsernameChange, + GetUserCaseInsensitive, + GetUserWithID, +} from "utils/user"; import type { AnyProfileRatingAlg, GPTString, @@ -515,6 +523,157 @@ router.post( } ); +/** + * Changes the users username. + * Requires self-key level permissions. + * + * @param !password - The new password. Must pass password validation rules. + * @param newUsername - The new username. Must pass username validation rules. + * + * @name POST /api/v1/users/:userID/change-username + */ +router.post( + "/change-username", + RequireSelfRequestFromUser, + prValidate( + { + "!password": ValidatePassword, + newUsername: p.regex(/^[a-zA-Z_-][a-zA-Z0-9_-]{2,20}$/u), + }, + { + newUsername: + "Username must be between 3 and 20 characters long, can only contain alphanumeric characters and cannot start with a number.", + "!password": "Invalid password.", + } + ), + async (req, res) => { + const body = req.safeBody as { + "!password": string; + newUsername: string; + }; + + const user = req.session.tachi?.user; + + /* istanbul ignore next */ + if (!user) { + logger.severe( + `IP ${req.ip} got to /change-username without a user, but passed RequireSelfRequest?` + ); + + // this should be a 500, but lie to them. + return res.status(403).json({ + success: false, + description: `You are not authorised to perform this action.`, + }); + } + + const privateInfo = await db["user-private-information"].findOne({ + userID: user.id, + }); + + /* istanbul ignore next */ + if (!privateInfo) { + logger.severe(`User ${FormatUserDoc(user)} has no private information?`, { user }); + return res.status(500).json({ + success: false, + description: `An internal server error has occured.`, + }); + } + + const isPasswordValid = await PasswordCompare(body["!password"], privateInfo.password); + + if (!isPasswordValid) { + return res.status(401).json({ + success: false, + description: `Invalid password.`, + }); + } + + const existingUser = await GetUserCaseInsensitive(body.newUsername); + + if (existingUser) { + logger.verbose(`Invalid username ${body.newUsername}, already in use.`); + return res.status(409).json({ + success: false, + description: "This username is already in use.", + }); + } + + const canChangeUsername = await CanChangeUsername(user.id); + + if (!canChangeUsername) { + return res.status(403).json({ + success: false, + description: "You can only change your username every 6 months.", + }); + } + + await db.users.update( + { + id: user.id, + }, + { + $set: { + username: body.newUsername, + usernameLowercase: body.newUsername.toLowerCase(), + }, + } + ); + + await db["user-name-changes"].insert({ + userID: user.id, + username: body.newUsername, + timestamp: Date.now(), + previousUsername: user.username, + }); + + if (req.session.tachi?.user) { + req.session.tachi.user = { + ...user, + username: body.newUsername, + }; + } + + return res.status(200).json({ + success: true, + description: `Updated your username!`, + body: {}, + }); + } +); + +/** + * Get the last time the user changed their username, + * and whether they can change their username again. + * + * @name GET /api/v1/users/:userID/last-username-change + */ +router.get("/last-username-change", async (req, res) => { + const user = GetUser(req); + + const nextAvailableChange = await GetNextAvailableUsernameChange(user.id); + + const canChange = await CanChangeUsername(user.id); + + let body; + + if (canChange) { + body = { + canChange: true, + }; + } else { + body = { + canChange: false, + nextAvailableChange, + }; + } + + return res.status(200).json({ + success: true, + description: `Next available username change.`, + body, + }); +}); /** * Get the recent import types this user has used. * diff --git a/server/src/utils/user.ts b/server/src/utils/user.ts index 06cad2e99..3701e7249 100644 --- a/server/src/utils/user.ts +++ b/server/src/utils/user.ts @@ -1,4 +1,5 @@ import db from "external/mongo/db"; +import { ONE_DAY } from "lib/constants/time"; import CreateLogCtx from "lib/logger/logger"; import { GetGamePTConfig, UserAuthLevels } from "tachi-common"; import type { FindOneResult } from "monk"; @@ -285,3 +286,24 @@ export async function GetAllUserRivals(userID: integer) { return rivals; } + +const USERNAME_CHANGE_COOLDOWN = ONE_DAY * 180; // 6 months + +export async function CanChangeUsername(userID: integer) { + const nextAvailableChange = await GetNextAvailableUsernameChange(userID); + + return nextAvailableChange === null || nextAvailableChange < Date.now(); +} + +export async function GetNextAvailableUsernameChange(userID: integer): Promise { + const lastChange = await db["user-name-changes"].findOne( + { userID }, + { sort: { timestamp: -1 } } + ); + + if (!lastChange) { + return null; + } + + return lastChange.timestamp + USERNAME_CHANGE_COOLDOWN; +}