Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement username editing #451

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions public/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,23 @@
},
"title": "Change password"
},
"changeUsername": {
"title": "Change username",
"new": {
"invalid": "Username is invalid",
"required": "Username is required",
"tooLong": "Username can not be more than {{max, number}} characters long",
"tooShort": "Username must be at least {{min, number}} characters long",
"regex": "Username must only contain alphanumeric characters"
},
"usernameTaken": "Username is taken",
"newUsernameLabel": "New username",
"submit": "Change username",
"success": {
"message": "Your username has been changed successfully.",
"title": "Username changed"
}
},
"deleteAccount": {
"button": "Delete account",
"modal": {
Expand Down
17 changes: 17 additions & 0 deletions public/locales/fi/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,23 @@
},
"title": "Vaihda salasana"
},
"changeUsername": {
"title": "Vaihda käyttäjänimi",
"new": {
"invalid": "Käyttäjänimi ei ole kelvollinen",
"required": "Käyttäjänimi vaaditaan",
"tooLong": "Käyttäjänimi voi olla enintään {{max, number}} merkkiä pitkä",
"tooShort": "Käyttäjänimen tulee olla vähintään {{min, number}} merkkiä pitkä",
"regex": "Käyttäjänimi voi sisältää vain alphanumeerisia merkkejä"
},
"newUsernameLabel": "Uusi käyttäjänimi",
"submit": "Vaihda käyttäjänimi",
"usernameTaken": "Käyttäjänimi on varattu",
"success": {
"message": "Käyttäjänimesi on vaihdettu onnistuneesti",
"title": "Käyttäjänimi vaihdettu"
}
},
"deleteAccount": {
"button": "Poista tili",
"modal": {
Expand Down
119 changes: 66 additions & 53 deletions src/app/[locale]/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { FriendCodeField } from "./FriendCodeField";
import { AuthTokenField } from "./AuthTokenField";
import { redirect } from "next/navigation";
import { getMe } from "../../../api/usersApi";
import ChangeUsernameForm from "../../../components/ChangeUsernameForm";

export type ProfilePageProps = {
username: string;
Expand Down Expand Up @@ -58,65 +59,77 @@ export default async function ProfilePage({
const { t } = await initTranslations(locale, ["common"]);

return (
<div>
<Title order={2}>{t("profile.title")}</Title>
<Text mt={15}>{t("profile.username", { username })}</Text>
<Text mt={15}>
{t("profile.registrationTime", {
registrationTime: format(
new Date(registrationTime),
"d.M.yyyy HH:mm",
),
})}
</Text>
<Stack mt={40} gap={15}>
<Title order={2}>{t("profile.account.title")}</Title>
<Title order={3}>{t("profile.changePassword.title")}</Title>
<ChangePasswordForm />
<WithTooltip
tooltipLabel={
<Text>{t("profile.accountVisibility.description")}</Text>
}
>
<Title order={3}>{t("profile.accountVisibility.title")}</Title>
</WithTooltip>
<div>
<ProfileVisibilityToggle isPublic={me.is_public} />
</div>
<Title order={3}>{t("profile.deleteAccount.title")}</Title>
<div>
<DeleteAccountButton username={me.username} />
</div>
</Stack>
<Stack mt={40} gap={15}>
<WithTooltip
tooltipLabel={
<Text>
{t("profile.authenticationToken.tooltip.label")}{" "}
<Anchor component={Link} href={`/${locale}/extensions`}>
{t("profile.authenticationToken.tooltip.install")}
</Anchor>
</Text>
}
>
<Title order={3}>{t("profile.authenticationToken.title")}</Title>
</WithTooltip>
<AuthTokenField token={token} />
<Stack gap={32}>
<Stack gap={16}>
<Title order={2}>{t("profile.title")}</Title>
<Text>{t("profile.username", { username })}</Text>
<Text>
{t("profile.registrationTime", {
registrationTime: format(
new Date(registrationTime),
"d.M.yyyy HH:mm",
),
})}
</Text>
</Stack>
<Stack mt={40} gap={15}>
<WithTooltip
tooltipLabel={<Text>{t("profile.friendCode.tooltip")}</Text>}
>
<Title order={3}>{t("profile.friendCode.title")}</Title>
</WithTooltip>
<FriendCodeField friendCode={friendCode} />
<Stack gap={32}>
<Title order={2}>{t("profile.account.title")}</Title>
<Stack gap="xs">
<Title order={3}>{t("profile.changePassword.title")}</Title>
<ChangePasswordForm />
</Stack>
<Stack gap="xs">
<Title order={3}>{t("profile.changeUsername.title")}</Title>
<ChangeUsernameForm />
</Stack>
<Stack gap="xs">
<WithTooltip
tooltipLabel={
<Text>{t("profile.accountVisibility.description")}</Text>
}
>
<Title order={3}>{t("profile.accountVisibility.title")}</Title>
</WithTooltip>
<div>
<ProfileVisibilityToggle isPublic={me.is_public} />
</div>
</Stack>
<Stack gap="xs">
<Title order={3}>{t("profile.deleteAccount.title")}</Title>
<div>
<DeleteAccountButton username={me.username} />
</div>
</Stack>
<Stack gap="xs">
<WithTooltip
tooltipLabel={
<Text>
{t("profile.authenticationToken.tooltip.label")}{" "}
<Anchor component={Link} href={`/${locale}/extensions`}>
{t("profile.authenticationToken.tooltip.install")}
</Anchor>
</Text>
}
>
<Title order={3}>{t("profile.authenticationToken.title")}</Title>
</WithTooltip>
<AuthTokenField token={token} />
</Stack>
<Stack gap="xs">
<WithTooltip
tooltipLabel={<Text>{t("profile.friendCode.tooltip")}</Text>}
>
<Title order={3}>{t("profile.friendCode.title")}</Title>
</WithTooltip>
<FriendCodeField friendCode={friendCode} />
</Stack>
</Stack>
<Stack mt={40} gap={15}>
<Stack gap="xs">
<Title order={2}>{t("profile.settings.title")}</Title>
<SmoothChartsSelector />
<LanguageSelector locale={locale} />
<DefaultDayRangeSelector />
</Stack>
</div>
</Stack>
);
}
10 changes: 10 additions & 0 deletions src/components/ChangeUsernameForm/ChangeUsernameForm.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.form {
display: flex;
flex-direction: column;
gap: 8px;
align-items: flex-start;
}

.input {
width: 100%;
}
93 changes: 93 additions & 0 deletions src/components/ChangeUsernameForm/ChangeUsernameForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"use client";

import { Form, Formik } from "formik";
import { FormikTextInput } from "../forms/FormikTextInput";
import { Button } from "@mantine/core";
import { useTranslation } from "react-i18next";
import styles from "./ChangeUsernameForm.module.css";
import { changeUsername } from "./actions";
import { ChangeUsernameError } from "../../types";
import { useRouter } from "next/navigation";
import { showNotification } from "@mantine/notifications";
import { logOutAndRedirect } from "../../utils/authUtils";
import * as Yup from "yup";

export const ChangeUsernameForm = () => {
const { t } = useTranslation();
const router = useRouter();

return (
<Formik
initialValues={{
newUsername: "",
}}
validationSchema={Yup.object({
newUsername: Yup.string()
.required(t("profile.changeUsername.new.required"))
.min(2, t("profile.changeUsername.new.tooShort", { min: 2 }))
.max(32, t("profile.changeUsername.new.tooLong", { max: 32 }))
.matches(/^[a-zA-Z0-9]*$/, t("profile.changeUsername.new.regex")),
})}
onSubmit={async (values, formik) => {
const result = await changeUsername(values.newUsername);
if (result && "error" in result) {
switch (result.error) {
case ChangeUsernameError.InvalidUsername:
showNotification({
title: t("error"),
color: "red",
message: t("profile.changeUsername.new.invalid"),
});
break;
case ChangeUsernameError.RateLimited:
router.push("/rate-limited");
break;
case ChangeUsernameError.Unauthorized:
showNotification({
title: t("error"),
color: "red",
message: t("errors.unauthorized"),
});
await logOutAndRedirect();
break;
case ChangeUsernameError.UsernameTaken:
showNotification({
title: t("error"),
color: "red",
message: t("profile.changeUsername.usernameTaken"),
});
break;
case ChangeUsernameError.UnknownError:
showNotification({
title: t("error"),
color: "red",
message: t("unknownErrorOccurred"),
});
break;
}
} else {
showNotification({
title: t("profile.changeUsername.success.title"),
color: "green",
message: t("profile.changeUsername.success.message"),
});
formik.resetForm();
router.refresh();
}
}}
>
{({ isSubmitting }) => (
<Form className={styles.form}>
<FormikTextInput
name="newUsername"
label={t("profile.changeUsername.newUsernameLabel")}
className={styles.input}
/>
<Button type="submit" loading={isSubmitting}>
{t("profile.changeUsername.submit")}
</Button>
</Form>
)}
</Formik>
);
};
43 changes: 43 additions & 0 deletions src/components/ChangeUsernameForm/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"use server";

import { cookies } from "next/headers";
import { ChangeUsernameError } from "../../types";

export const changeUsername = async (newUsername: string) => {
const token = cookies().get("secure-access-token")?.value;

const response = await fetch(
process.env.NEXT_PUBLIC_API_URL + "/auth/changeusername",
{
method: "POST",
cache: "no-cache",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
new: newUsername,
}),
},
);

if (!response.ok) {
if (response.status === 400) {
return { error: ChangeUsernameError.InvalidUsername };
} else if (response.status === 401) {
return { error: ChangeUsernameError.Unauthorized };
} else if (response.status === 429) {
return { error: ChangeUsernameError.RateLimited };
} else if (response.status === 409) {
return { error: ChangeUsernameError.UsernameTaken };
}

const errorText = await response.text();
console.log(
"Unknown error when changing username: status",
response.status,
errorText,
);
return { error: ChangeUsernameError.UnknownError };
}
};
3 changes: 3 additions & 0 deletions src/components/ChangeUsernameForm/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { ChangeUsernameForm } from "./ChangeUsernameForm";

export default ChangeUsernameForm;
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,11 @@ export enum RemoveFriendError {
RateLimited = "Rate limited",
UnknownError = "Unknown error",
}

export enum ChangeUsernameError {
InvalidUsername,
Unauthorized,
UsernameTaken,
RateLimited,
UnknownError,
}