feat(frontend): add username editing

This commit is contained in:
sam 2024-10-01 16:06:02 +02:00
parent 5a8b7aae80
commit 2a66e3e25e
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
10 changed files with 164 additions and 23 deletions

View file

@ -22,19 +22,27 @@ public class MetaController : ApiControllerBase
), ),
new Limits( new Limits(
MemberCount: MembersController.MaxMemberCount, MemberCount: MembersController.MaxMemberCount,
BioLength: ValidationUtils.MaxBioLength)) BioLength: ValidationUtils.MaxBioLength,
CustomPreferences: UsersController.MaxCustomPreferences))
); );
} }
[HttpGet("/api/v2/coffee")] [HttpGet("/api/v2/coffee")]
public IActionResult BrewCoffee() => Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot); public IActionResult BrewCoffee() => Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
private record MetaResponse(string Repository, string Version, string Hash, int Members, UserInfo Users, Limits Limits); private record MetaResponse(
string Repository,
string Version,
string Hash,
int Members,
UserInfo Users,
Limits Limits);
private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay); private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay);
// All limits that the frontend should know about (for UI purposes) // All limits that the frontend should know about (for UI purposes)
private record Limits( private record Limits(
int MemberCount, int MemberCount,
int BioLength); int BioLength,
int CustomPreferences);
} }

View file

@ -177,14 +177,16 @@ public class UsersController(
public bool Favourite { get; set; } public bool Favourite { get; set; }
} }
public const int MaxCustomPreferences = 25;
private static List<(string, ValidationError?)> ValidateCustomPreferences( private static List<(string, ValidationError?)> ValidateCustomPreferences(
List<CustomPreferencesUpdateRequest> preferences) List<CustomPreferencesUpdateRequest> preferences)
{ {
var errors = new List<(string, ValidationError?)>(); var errors = new List<(string, ValidationError?)>();
if (preferences.Count > 25) if (preferences.Count > MaxCustomPreferences)
errors.Add(("custom_preferences", errors.Add(("custom_preferences",
ValidationError.LengthError("Too many custom preferences", 0, 25, preferences.Count))); ValidationError.LengthError("Too many custom preferences", 0, MaxCustomPreferences, preferences.Count)));
if (preferences.Count > 50) return errors; if (preferences.Count > 50) return errors;
// TODO: validate individual preferences // TODO: validate individual preferences

View file

@ -43,6 +43,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
user.Links, user.Links,
user.Names, user.Pronouns, user.Fields, user.CustomPreferences, user.Names, user.Pronouns, user.Fields, user.CustomPreferences,
flags.Select(f => RenderPrideFlag(f.PrideFlag)), flags.Select(f => RenderPrideFlag(f.PrideFlag)),
user.Role,
renderMembers ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) : null, renderMembers ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) : null,
renderAuthMethods renderAuthMethods
? authMethods.Select(a => new AuthenticationMethodResponse( ? authMethods.Select(a => new AuthenticationMethodResponse(
@ -78,6 +79,8 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
IEnumerable<Field> Fields, IEnumerable<Field> Fields,
Dictionary<Snowflake, User.CustomPreference> CustomPreferences, Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
IEnumerable<PrideFlagResponse> Flags, IEnumerable<PrideFlagResponse> Flags,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
UserRole Role,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
IEnumerable<MemberRendererService.PartialMember>? Members, IEnumerable<MemberRendererService.PartialMember>? Members,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]

View file

@ -27,3 +27,8 @@
max-width: 200px; max-width: 200px;
border-radius: 3px; border-radius: 3px;
} }
// This is necessary for line breaks in translation strings to show up. Don't ask me why
.text-has-newline {
white-space: pre-line;
}

View file

@ -8,9 +8,11 @@ export default interface Meta {
active_day: number; active_day: number;
}; };
members: number; members: number;
limits: Limits;
} }
export type Limits = { export type Limits = {
member_count: number; member_count: number;
bio_length: number; bio_length: number;
custom_preferences: number;
}; };

View file

@ -14,6 +14,14 @@ export type User = PartialUser & {
pronouns: Pronoun[]; pronouns: Pronoun[];
fields: Field[]; fields: Field[];
flags: PrideFlag[]; flags: PrideFlag[];
role: "USER" | "MODERATOR" | "ADMIN";
};
export type MeUser = UserWithMembers & {
auth_methods: AuthMethod[];
member_list_hidden: boolean;
last_active: string;
last_sid_reroll: string;
}; };
export type UserWithMembers = User & { members: PartialMember[] }; export type UserWithMembers = User & { members: PartialMember[] };
@ -58,6 +66,14 @@ export type PrideFlag = {
description: string | null; description: string | null;
}; };
export type AuthMethod = {
id: string;
type: "DISCORD" | "GOOGLE" | "TUMBLR" | "FEDIVERSE" | "EMAIL";
remote_id: string;
remote_username?: string;
fediverse_instance?: string;
};
export type CustomPreference = { export type CustomPreference = {
icon: string; icon: string;
tooltip: string; tooltip: string;

View file

@ -15,7 +15,6 @@ import {
useLoaderData, useLoaderData,
ShouldRevalidateFunction, ShouldRevalidateFunction,
useNavigate, useNavigate,
Navigate,
} from "@remix-run/react"; } from "@remix-run/react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { Form, Button, Alert } from "react-bootstrap"; import { Form, Button, Alert } from "react-bootstrap";

View file

@ -1,30 +1,94 @@
import { Table } from "react-bootstrap"; import { Button, Form, InputGroup, Table } from "react-bootstrap";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useLoaderData, useRouteLoaderData } from "@remix-run/react"; import { Form as RemixForm, useActionData, useRouteLoaderData } from "@remix-run/react";
import { loader as settingsLoader } from "../settings/route"; import { loader as settingsLoader } from "../settings/route";
import { LoaderFunctionArgs, json } from "@remix-run/node"; import { loader as rootLoader } from "../../root";
import serverRequest, { getToken } from "~/lib/request.server";
import { PartialMember } from "~/lib/api/user";
import { limits } from "~/env.server";
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { idTimestamp } from "~/lib/utils"; import { defaultAvatarUrl, idTimestamp } from "~/lib/utils";
import { ExclamationTriangleFill, InfoCircleFill } from "react-bootstrap-icons";
import AvatarImage from "~/components/profile/AvatarImage";
import { ActionFunctionArgs, json } from "@remix-run/node";
import { type ApiError, ErrorCode, firstErrorFor } from "~/lib/api/error";
import serverRequest, { getToken } from "~/lib/request.server";
import { MeUser } from "~/lib/api/user";
import ErrorAlert from "~/components/ErrorAlert";
export const loader = async ({ request }: LoaderFunctionArgs) => { export const action = async ({ request }: ActionFunctionArgs) => {
const data = await request.formData();
const username = data.get("username") as string | null;
const token = getToken(request); const token = getToken(request);
const members = await serverRequest<PartialMember[]>("GET", "/users/@me/members", { token });
return json({ members, maxMemberCount: limits.member_count }); if (!username) {
return json({
error: {
status: 403,
code: ErrorCode.BadRequest,
message: "Invalid username",
} as ApiError,
user: null,
});
}
try {
const resp = await serverRequest<MeUser>("PATCH", "/users/@me", { body: { username }, token });
return json({ user: resp, error: null });
} catch (e) {
return json({ error: e as ApiError, user: null });
}
}; };
export default function SettingsIndex() { export default function SettingsIndex() {
const { members, maxMemberCount } = useLoaderData<typeof loader>();
const { user } = useRouteLoaderData<typeof settingsLoader>("routes/settings")!; const { user } = useRouteLoaderData<typeof settingsLoader>("routes/settings")!;
const actionData = useActionData<typeof action>();
const { meta } = useRouteLoaderData<typeof rootLoader>("root")!;
const { t } = useTranslation(); const { t } = useTranslation();
const createdAt = idTimestamp(user.id); const createdAt = idTimestamp(user.id);
return ( return (
<> <>
<Table striped bordered> <div className="row">
<div className="col-md">
<RemixForm method="POST">
<Form as="div">
<Form.Group className="mb-3" controlId="username">
<Form.Label>{t("settings.general.username")}</Form.Label>
<InputGroup className="m-1 w-75">
<Form.Control
defaultValue={user.username}
id="username"
name="username"
type="text"
required
/>
<Button variant="secondary" type="submit">
{t("settings.general.change-username")}
</Button>
</InputGroup>
</Form.Group>
</Form>
</RemixForm>
<p className="text-muted text-has-newline">
<InfoCircleFill /> {t("settings.general.username-change-hint")}
</p>
{actionData?.error && <UsernameUpdateError error={actionData.error} />}
</div>
<div className="col-md text-center">
<AvatarImage
src={user.avatar_url || defaultAvatarUrl}
width={200}
alt={t("user.avatar-alt", { username: user.username })}
/>
</div>
</div>
<div>
<h4>{t("settings.general.log-out-everywhere")}</h4>
<p></p>
</div>
<h4>{t("settings.general.table-header")}</h4>
<Table striped bordered hover>
<tbody> <tbody>
<tr> <tr>
<th scope="row">{t("settings.general.id")}</th> <th scope="row">{t("settings.general.id")}</th>
@ -39,7 +103,23 @@ export default function SettingsIndex() {
<tr> <tr>
<th scope="row">{t("settings.general.member-count")}</th> <th scope="row">{t("settings.general.member-count")}</th>
<td> <td>
{members.length}/{maxMemberCount} {user.members.length}/{meta.limits.member_count}
</td>
</tr>
<tr>
<th scope="row">{t("settings.general.member-list-hidden")}</th>
<td>{user.member_list_hidden ? t("yes") : t("no")}</td>
</tr>
<tr>
<th scope="row">{t("settings.general.custom-preferences")}</th>
<td>
{Object.keys(user.custom_preferences).length}/{meta.limits.custom_preferences}
</td>
</tr>
<tr>
<th scope="row">{t("settings.general.role")}</th>
<td>
<code>{user.role}</code>
</td> </td>
</tr> </tr>
</tbody> </tbody>
@ -47,3 +127,18 @@ export default function SettingsIndex() {
</> </>
); );
} }
function UsernameUpdateError({ error }: { error: ApiError }) {
const { t } = useTranslation();
const usernameError = firstErrorFor(error, "username");
if (!usernameError) {
return <ErrorAlert error={error} />;
}
return (
<p className="text-danger-emphasis text-has-newline">
<ExclamationTriangleFill />{" "}
{t("settings.general.username-update-error", { message: usernameError.message })}
</p>
);
}

View file

@ -1,7 +1,7 @@
import { LoaderFunctionArgs, json, redirect, MetaFunction } from "@remix-run/node"; import { LoaderFunctionArgs, json, redirect, MetaFunction } from "@remix-run/node";
import i18n from "~/i18next.server"; import i18n from "~/i18next.server";
import serverRequest, { getToken } from "~/lib/request.server"; import serverRequest, { getToken } from "~/lib/request.server";
import { User } from "~/lib/api/user"; import { MeUser } from "~/lib/api/user";
import { Link, Outlet, useLocation } from "@remix-run/react"; import { Link, Outlet, useLocation } from "@remix-run/react";
import { Nav } from "react-bootstrap"; import { Nav } from "react-bootstrap";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -16,7 +16,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
if (token) { if (token) {
try { try {
const user = await serverRequest<User>("GET", "/users/@me", { token }); const user = await serverRequest<MeUser>("GET", "/users/@me", { token });
return json({ user, meta: { title: t("settings.title") } }); return json({ user, meta: { title: t("settings.title") } });
} catch (e) { } catch (e) {
return redirect("/auth/log-in"); return redirect("/auth/log-in");

View file

@ -92,9 +92,18 @@
}, },
"settings": { "settings": {
"general": { "general": {
"username": "Username",
"change-username": "Change username",
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
"log-out-everywhere": "Log out everywhere",
"table-header": "General account information",
"id": "Your user ID", "id": "Your user ID",
"created": "Account created at", "created": "Account created at",
"member-count": "Members" "member-count": "Members",
"member-list-hidden": "Member list hidden?",
"custom-preferences": "Custom preferences",
"role": "Account role",
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}"
}, },
"title": "Settings", "title": "Settings",
"nav": { "nav": {
@ -104,5 +113,7 @@
"authentication": "Authentication", "authentication": "Authentication",
"export": "Export your data" "export": "Export your data"
} }
} },
"yes": "Yes",
"no": "No"
} }