Compare commits
3 commits
562ecc46bd
...
2a66e3e25e
Author | SHA1 | Date | |
---|---|---|---|
2a66e3e25e | |||
5a8b7aae80 | |||
b1165c3780 |
14 changed files with 204 additions and 45 deletions
|
@ -22,19 +22,27 @@ public class MetaController : ApiControllerBase
|
|||
),
|
||||
new Limits(
|
||||
MemberCount: MembersController.MaxMemberCount,
|
||||
BioLength: ValidationUtils.MaxBioLength))
|
||||
BioLength: ValidationUtils.MaxBioLength,
|
||||
CustomPreferences: UsersController.MaxCustomPreferences))
|
||||
);
|
||||
}
|
||||
|
||||
[HttpGet("/api/v2/coffee")]
|
||||
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);
|
||||
|
||||
// All limits that the frontend should know about (for UI purposes)
|
||||
private record Limits(
|
||||
int MemberCount,
|
||||
int BioLength);
|
||||
int BioLength,
|
||||
int CustomPreferences);
|
||||
}
|
|
@ -177,14 +177,16 @@ public class UsersController(
|
|||
public bool Favourite { get; set; }
|
||||
}
|
||||
|
||||
public const int MaxCustomPreferences = 25;
|
||||
|
||||
private static List<(string, ValidationError?)> ValidateCustomPreferences(
|
||||
List<CustomPreferencesUpdateRequest> preferences)
|
||||
{
|
||||
var errors = new List<(string, ValidationError?)>();
|
||||
|
||||
if (preferences.Count > 25)
|
||||
if (preferences.Count > MaxCustomPreferences)
|
||||
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;
|
||||
|
||||
// TODO: validate individual preferences
|
||||
|
|
|
@ -43,6 +43,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
|
|||
user.Links,
|
||||
user.Names, user.Pronouns, user.Fields, user.CustomPreferences,
|
||||
flags.Select(f => RenderPrideFlag(f.PrideFlag)),
|
||||
user.Role,
|
||||
renderMembers ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) : null,
|
||||
renderAuthMethods
|
||||
? authMethods.Select(a => new AuthenticationMethodResponse(
|
||||
|
@ -78,6 +79,8 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
|
|||
IEnumerable<Field> Fields,
|
||||
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
|
||||
IEnumerable<PrideFlagResponse> Flags,
|
||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||
UserRole Role,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
IEnumerable<MemberRendererService.PartialMember>? Members,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
|
|
|
@ -7,13 +7,8 @@ namespace Foxnouns.Backend.Utils;
|
|||
/// <summary>
|
||||
/// Static methods for validating user input (mostly making sure it's not too short or too long)
|
||||
/// </summary>
|
||||
public static class ValidationUtils
|
||||
public static partial class ValidationUtils
|
||||
{
|
||||
private static readonly Regex UsernameRegex = new("^[\\w-.]{2,40}$", RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly Regex MemberRegex =
|
||||
new("^[^@\\?!#/\\\\[\\]\"\\{\\}'$%&()+<=>^|~`,\\*]{1,100}$", RegexOptions.IgnoreCase);
|
||||
|
||||
private static readonly string[] InvalidUsernames =
|
||||
[
|
||||
"..",
|
||||
|
@ -40,7 +35,7 @@ public static class ValidationUtils
|
|||
|
||||
public static ValidationError? ValidateUsername(string username)
|
||||
{
|
||||
if (!UsernameRegex.IsMatch(username))
|
||||
if (!UsernameRegex().IsMatch(username))
|
||||
return username.Length switch
|
||||
{
|
||||
< 2 => ValidationError.LengthError("Username is too short", 2, 40, username.Length),
|
||||
|
@ -57,7 +52,7 @@ public static class ValidationUtils
|
|||
|
||||
public static ValidationError? ValidateMemberName(string memberName)
|
||||
{
|
||||
if (!MemberRegex.IsMatch(memberName))
|
||||
if (!MemberRegex().IsMatch(memberName))
|
||||
return memberName.Length switch
|
||||
{
|
||||
< 1 => ValidationError.LengthError("Name is too short", 1, 100, memberName.Length),
|
||||
|
@ -128,7 +123,7 @@ public static class ValidationUtils
|
|||
}
|
||||
|
||||
public const int MaxBioLength = 1024;
|
||||
|
||||
|
||||
public static ValidationError? ValidateBio(string? bio)
|
||||
{
|
||||
return bio?.Length switch
|
||||
|
@ -291,4 +286,9 @@ public static class ValidationUtils
|
|||
|
||||
return errors;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
|
||||
private static partial Regex UsernameRegex();
|
||||
[GeneratedRegex("""^[^@'$%&()+<=>^|~`,*!#/\\\[\]""\{\}\?]{1,100}$""", RegexOptions.IgnoreCase, "en-NL")]
|
||||
private static partial Regex MemberRegex();
|
||||
}
|
|
@ -27,3 +27,8 @@
|
|||
max-width: 200px;
|
||||
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;
|
||||
}
|
||||
|
|
22
Foxnouns.Frontend/app/components/profile/AvatarImage.tsx
Normal file
22
Foxnouns.Frontend/app/components/profile/AvatarImage.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
export default function AvatarImage({
|
||||
src,
|
||||
width,
|
||||
alt,
|
||||
lazyLoad,
|
||||
}: {
|
||||
src: string;
|
||||
width: number;
|
||||
alt: string;
|
||||
lazyLoad?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={width}
|
||||
className="rounded-circle img-fluid"
|
||||
loading={lazyLoad ? "lazy" : "eager"}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -6,6 +6,7 @@ import ProfileLink from "~/components/profile/ProfileLink";
|
|||
import ProfileField from "~/components/profile/ProfileField";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { renderMarkdown } from "~/lib/markdown";
|
||||
import AvatarImage from "~/components/profile/AvatarImage";
|
||||
|
||||
export type Props = {
|
||||
name: string;
|
||||
|
@ -31,20 +32,16 @@ export default function BaseProfile({
|
|||
<div className="row">
|
||||
<div className="col-md-4 text-center">
|
||||
{userI18nKeys ? (
|
||||
<img
|
||||
<AvatarImage
|
||||
src={profile.avatar_url || defaultAvatarUrl}
|
||||
alt={t("user.avatar-alt", { username: name })}
|
||||
width={200}
|
||||
height={200}
|
||||
className="rounded-circle img-fluid"
|
||||
alt={t("user.avatar-alt", { username: name })}
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
<AvatarImage
|
||||
src={profile.avatar_url || defaultAvatarUrl}
|
||||
alt={t("member.avatar-alt", { name: name })}
|
||||
width={200}
|
||||
height={200}
|
||||
className="rounded-circle img-fluid"
|
||||
alt={t("member.avatar-alt", { name: name })}
|
||||
/>
|
||||
)}
|
||||
{profile.flags && profile.bio && (
|
||||
|
|
|
@ -8,9 +8,11 @@ export default interface Meta {
|
|||
active_day: number;
|
||||
};
|
||||
members: number;
|
||||
limits: Limits;
|
||||
}
|
||||
|
||||
export type Limits = {
|
||||
member_count: number;
|
||||
bio_length: number;
|
||||
custom_preferences: number;
|
||||
};
|
||||
|
|
|
@ -14,6 +14,14 @@ export type User = PartialUser & {
|
|||
pronouns: Pronoun[];
|
||||
fields: Field[];
|
||||
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[] };
|
||||
|
@ -58,6 +66,14 @@ export type PrideFlag = {
|
|||
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 = {
|
||||
icon: string;
|
||||
tooltip: string;
|
||||
|
|
|
@ -10,6 +10,7 @@ import { defaultAvatarUrl } from "~/lib/utils";
|
|||
import { useTranslation } from "react-i18next";
|
||||
import { OverlayTrigger, Tooltip } from "react-bootstrap";
|
||||
import { Lock } from "react-bootstrap-icons";
|
||||
import AvatarImage from "~/components/profile/AvatarImage";
|
||||
|
||||
export default function MemberCard({ user, member }: { user: PartialUser; member: PartialMember }) {
|
||||
const { t } = useTranslation();
|
||||
|
@ -37,13 +38,11 @@ export default function MemberCard({ user, member }: { user: PartialUser; member
|
|||
return (
|
||||
<div className="col">
|
||||
<Link to={`/@${user.username}/${member.name}`}>
|
||||
<img
|
||||
<AvatarImage
|
||||
src={member.avatar_url || defaultAvatarUrl}
|
||||
alt={t("user.member-avatar-alt", { name: member.name })}
|
||||
width={200}
|
||||
height={200}
|
||||
loading="lazy"
|
||||
className="rounded-circle img-fluid"
|
||||
alt={t("user.member-avatar-alt", { name: member.name })}
|
||||
lazyLoad
|
||||
/>
|
||||
</Link>
|
||||
<p className="m-2">
|
||||
|
|
|
@ -15,7 +15,6 @@ import {
|
|||
useLoaderData,
|
||||
ShouldRevalidateFunction,
|
||||
useNavigate,
|
||||
Navigate,
|
||||
} from "@remix-run/react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Form, Button, Alert } from "react-bootstrap";
|
||||
|
|
|
@ -1,30 +1,94 @@
|
|||
import { Table } from "react-bootstrap";
|
||||
import { Button, Form, InputGroup, Table } from "react-bootstrap";
|
||||
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 { LoaderFunctionArgs, json } from "@remix-run/node";
|
||||
import serverRequest, { getToken } from "~/lib/request.server";
|
||||
import { PartialMember } from "~/lib/api/user";
|
||||
import { limits } from "~/env.server";
|
||||
import { loader as rootLoader } from "../../root";
|
||||
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 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() {
|
||||
const { members, maxMemberCount } = useLoaderData<typeof loader>();
|
||||
const { user } = useRouteLoaderData<typeof settingsLoader>("routes/settings")!;
|
||||
const actionData = useActionData<typeof action>();
|
||||
const { meta } = useRouteLoaderData<typeof rootLoader>("root")!;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const createdAt = idTimestamp(user.id);
|
||||
|
||||
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>
|
||||
<tr>
|
||||
<th scope="row">{t("settings.general.id")}</th>
|
||||
|
@ -39,7 +103,23 @@ export default function SettingsIndex() {
|
|||
<tr>
|
||||
<th scope="row">{t("settings.general.member-count")}</th>
|
||||
<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>
|
||||
</tr>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { LoaderFunctionArgs, json, redirect, MetaFunction } from "@remix-run/node";
|
||||
import i18n from "~/i18next.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 { Nav } from "react-bootstrap";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
@ -16,7 +16,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
|||
|
||||
if (token) {
|
||||
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") } });
|
||||
} catch (e) {
|
||||
return redirect("/auth/log-in");
|
||||
|
|
|
@ -92,9 +92,18 @@
|
|||
},
|
||||
"settings": {
|
||||
"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",
|
||||
"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",
|
||||
"nav": {
|
||||
|
@ -104,5 +113,7 @@
|
|||
"authentication": "Authentication",
|
||||
"export": "Export your data"
|
||||
}
|
||||
}
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No"
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue