feat: support changing your own names (#1383)

This commit is contained in:
zk
2025-11-02 18:06:43 +00:00
committed by GitHub
parent 6e580eb5d6
commit 8f4955812d
7 changed files with 488 additions and 120 deletions
+1 -1
View File
@@ -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"
@@ -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<UserSettingsDocument>(
`/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 (
<>
<div className="btn-group d-flex justify-content-center">
<SelectButton value={page} setValue={setPage} id="email">
<Icon type="envelope" /> Email
</SelectButton>
<SelectButton value={page} setValue={setPage} id="password">
<Icon type="lock" /> Password
</SelectButton>
<SelectButton value={page} setValue={setPage} id="username">
<Icon type="user" /> Username
</SelectButton>
</div>
<Divider />
{page === "email" ? (
<ChangeEmailForm reqUser={reqUser} />
) : page === "password" ? (
<ChangePasswordForm reqUser={reqUser} />
) : (
<ChangeUsernameForm reqUser={reqUser} />
)}
</>
);
}
function ChangeEmailForm({ reqUser }: { reqUser: UserDocument }) {
const formikEmail = useFormik({
initialValues: {
"!password": "",
@@ -157,113 +157,285 @@ export function AccountSettings({ reqUser }: { reqUser: UserDocument }) {
},
});
return (
<Form onSubmit={formikEmail.handleSubmit} className="d-flex flex-column gap-4">
<Form.Group>
<Form.Label>Password</Form.Label>
<Form.Control
type="password"
id="!password"
value={formikEmail.values["!password"]}
placeholder="Your Current Password"
onChange={formikEmail.handleChange}
/>
{formikEmail.values["!password"].length < 8 && (
<Form.Text className="text-warning">
Passwords have to be at least 8 characters long.
</Form.Text>
)}
</Form.Group>
<Form.Group>
<Form.Label>New Email</Form.Label>
<Form.Control
type="email"
id="email"
value={formikEmail.values.email}
placeholder="New Email"
onChange={formikEmail.handleChange}
/>
</Form.Group>
<Form.Group>
<Form.Label>Confirm New Email</Form.Label>
<Form.Control
type="email"
id="confEmail"
value={formikEmail.values.confEmail}
placeholder="New Email"
onChange={formikEmail.handleChange}
/>
</Form.Group>
{!(formikEmail.values.email === formikEmail.values.confEmail) && (
<Form.Text className="text-danger">Emails don't match!</Form.Text>
)}
<Button
className="mt-8"
variant="danger"
type="submit"
disabled={
!(
formikEmail.values.email === formikEmail.values.confEmail &&
formikEmail.values["!password"].length >= 8
)
}
>
Change Email
</Button>
</Form>
);
}
function ChangePasswordForm({ reqUser }: { reqUser: UserDocument }) {
const formikPassword = useFormik({
initialValues: {
"!oldPassword": "",
"!password": "",
confPass: "",
},
onSubmit: async (values) => {
const r = await APIFetchV1<UserSettingsDocument>(
`/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 (
<Form onSubmit={formikPassword.handleSubmit} className="d-flex flex-column gap-4">
<Form.Group>
<Form.Label>Old Password</Form.Label>
<Form.Control
type="password"
id="!oldPassword"
value={formikPassword.values["!oldPassword"]}
placeholder="Your Current Password"
onChange={formikPassword.handleChange}
/>
</Form.Group>
<Form.Group>
<Form.Label>New Password</Form.Label>
<Form.Control
type="password"
id="!password"
value={formikPassword.values["!password"]}
placeholder="New Password"
onChange={formikPassword.handleChange}
/>
{formikPassword.values["!password"].length < 8 && (
<Form.Text className="text-warning">
Passwords have to be at least 8 characters long.
</Form.Text>
)}
</Form.Group>
<Form.Group>
<Form.Label>Confirm New Password</Form.Label>
<Form.Control
type="password"
id="confPass"
value={formikPassword.values.confPass}
placeholder="New Password"
onChange={formikPassword.handleChange}
/>
</Form.Group>
{!(formikPassword.values["!password"] === formikPassword.values.confPass) && (
<Form.Text className="text-danger">Passwords don't match!</Form.Text>
)}
<Button
className="mt-8"
variant="danger"
type="submit"
disabled={
!(
formikPassword.values["!password"] === formikPassword.values.confPass &&
formikPassword.values.confPass.length >= 8
)
}
>
Change Password
</Button>
</Form>
);
}
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 | number>(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<UserDocument | null>(
`/users/${nameChangeFormik.values.newUsername}`
);
setUsernameState((state) => ({
...state,
isTaken: usernameQuery.statusCode === 200,
}));
}, 600);
setLastTimeout(handle);
}, [nameChangeFormik.values.newUsername]);
return (
<>
<Form onSubmit={formikEmail.handleSubmit} className="d-flex flex-column gap-4">
<Form onSubmit={nameChangeFormik.handleSubmit} className="d-flex flex-column gap-4">
<Form.Group>
<Form.Label>Password</Form.Label>
<Form.Control
type="password"
id="!password"
value={formikEmail.values["!password"]}
value={nameChangeFormik.values["!password"]}
placeholder="Your Current Password"
onChange={formikEmail.handleChange}
onChange={nameChangeFormik.handleChange}
/>
{formikEmail.values["!password"].length < 8 && (
<Form.Text className="text-warning">
Passwords have to be at least 8 characters long.
</Form.Text>
</Form.Group>
<Form.Group>
<Form.Label>New Username</Form.Label>
<Form.Control
className="mb-4"
type="text"
id="newUsername"
value={nameChangeFormik.values.newUsername}
placeholder="New Username"
onChange={nameChangeFormik.handleChange}
/>
{nameChangeFormik.values.newUsername.length > 3 && (
<div className="d-flex flex-column gap-2">
{usernameState.isValid && usernameState.isTaken && (
<Alert variant="danger">Username is already in use.</Alert>
)}
{!usernameState.isValid && (
<Alert variant="danger">
Username is invalid. Must be 3-20 characters long. Must start
with a letter. Must only contain letters, numbers, and
underscores.
</Alert>
)}
</div>
)}
</Form.Group>
<Form.Group>
<Form.Label>New Email</Form.Label>
<Form.Control
type="email"
id="email"
value={formikEmail.values.email}
placeholder="New Email"
onChange={formikEmail.handleChange}
/>
</Form.Group>
<Form.Group>
<Form.Label>Confirm New Email</Form.Label>
<Form.Control
type="email"
id="confEmail"
value={formikEmail.values.confEmail}
placeholder="New Email"
onChange={formikEmail.handleChange}
/>
</Form.Group>
{!(formikEmail.values.email === formikEmail.values.confEmail) && (
<Form.Text className="text-danger">Emails don't match!</Form.Text>
{!lastUsernameChange.data?.canChange && (
<Alert variant="danger">
You can only change your username every 6 months. Your next username change
will be available on{" "}
{new Date(
lastUsernameChange.data?.nextAvailableChange ?? 0
).toLocaleDateString()}
.
</Alert>
)}
<Button
className="mt-8"
variant="danger"
type="submit"
disabled={
!(
formikEmail.values.email === formikEmail.values.confEmail &&
formikEmail.values["!password"].length >= 8
)
nameChangeFormik.values["!password"].length < 8 ||
!lastUsernameChange.data?.canChange
}
>
Change Email
</Button>
</Form>
<Divider />
<Form onSubmit={formikPassword.handleSubmit} className="d-flex flex-column gap-4">
<Form.Group>
<Form.Label>Old Password</Form.Label>
<Form.Control
type="password"
id="!oldPassword"
value={formikPassword.values["!oldPassword"]}
placeholder="Your Current Password"
onChange={formikPassword.handleChange}
/>
</Form.Group>
<Form.Group>
<Form.Label>New Password</Form.Label>
<Form.Control
type="password"
id="!password"
value={formikPassword.values["!password"]}
placeholder="New Password"
onChange={formikPassword.handleChange}
/>
{formikPassword.values["!password"].length < 8 && (
<Form.Text className="text-warning">
Passwords have to be at least 8 characters long.
</Form.Text>
)}
</Form.Group>
<Form.Group>
<Form.Label>Confirm New Password</Form.Label>
<Form.Control
type="password"
id="confPass"
value={formikPassword.values.confPass}
placeholder="New Password"
onChange={formikPassword.handleChange}
/>
</Form.Group>
{!(formikPassword.values["!password"] === formikPassword.values.confPass) && (
<Form.Text className="text-danger">Passwords don't match!</Form.Text>
)}
<Button
className="mt-8"
variant="danger"
type="submit"
disabled={
!(
formikPassword.values["!password"] === formikPassword.values.confPass &&
formikPassword.values.confPass.length >= 8
)
}
>
Change Password
Change Username
</Button>
</Form>
</>
+7
View File
@@ -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;
}
+2
View File
@@ -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<MigrationDocument>("migrations"),
notifications: monkDB.get<NotificationDocument>("notifications"),
"import-trackers": monkDB.get<ImportTrackerDocument>("import-trackers"),
"user-name-changes": monkDB.get<UserNameChangeDocument>("user-name-changes"),
};
export type StaticDatabases = Exclude<
+6
View File
@@ -25,6 +25,12 @@ function prSchemaify(schema: PrudenceSchema) {
}
export const DatabaseSchemas: Record<Databases, SchemaValidatorFunction> = {
"user-name-changes": prSchemaify({
userID: p.isPositiveNonZeroInteger,
username: "string",
timestamp: p.isPositiveInteger,
previousUsername: "string",
}),
"import-locks": prSchemaify({
userID: p.isPositiveNonZeroInteger,
locked: "boolean",
@@ -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.
*
+22
View File
@@ -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<integer | null> {
const lastChange = await db["user-name-changes"].findOne(
{ userID },
{ sort: { timestamp: -1 } }
);
if (!lastChange) {
return null;
}
return lastChange.timestamp + USERNAME_CHANGE_COOLDOWN;
}