diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs index ffd4fe6..53a38e7 100644 --- a/Foxnouns.Backend/Controllers/MetaController.cs +++ b/Foxnouns.Backend/Controllers/MetaController.cs @@ -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); } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index e292fc3..9a182f4 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -177,14 +177,16 @@ public class UsersController( public bool Favourite { get; set; } } + public const int MaxCustomPreferences = 25; + private static List<(string, ValidationError?)> ValidateCustomPreferences( List 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 diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index 95d40d3..ee64fe1 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -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 Fields, Dictionary CustomPreferences, IEnumerable Flags, + [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] + UserRole Role, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] IEnumerable? Members, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] diff --git a/Foxnouns.Frontend/app/app.scss b/Foxnouns.Frontend/app/app.scss index 0d30b1e..50b0ad0 100644 --- a/Foxnouns.Frontend/app/app.scss +++ b/Foxnouns.Frontend/app/app.scss @@ -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; +} diff --git a/Foxnouns.Frontend/app/lib/api/meta.ts b/Foxnouns.Frontend/app/lib/api/meta.ts index 8f67ada..5f2bd11 100644 --- a/Foxnouns.Frontend/app/lib/api/meta.ts +++ b/Foxnouns.Frontend/app/lib/api/meta.ts @@ -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; }; diff --git a/Foxnouns.Frontend/app/lib/api/user.ts b/Foxnouns.Frontend/app/lib/api/user.ts index 0b6f375..4f07301 100644 --- a/Foxnouns.Frontend/app/lib/api/user.ts +++ b/Foxnouns.Frontend/app/lib/api/user.ts @@ -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; diff --git a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx index f1e8fb7..c5200fd 100644 --- a/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx +++ b/Foxnouns.Frontend/app/routes/auth.callback.discord/route.tsx @@ -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"; diff --git a/Foxnouns.Frontend/app/routes/settings._index/route.tsx b/Foxnouns.Frontend/app/routes/settings._index/route.tsx index de202a3..d575108 100644 --- a/Foxnouns.Frontend/app/routes/settings._index/route.tsx +++ b/Foxnouns.Frontend/app/routes/settings._index/route.tsx @@ -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("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("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(); const { user } = useRouteLoaderData("routes/settings")!; + const actionData = useActionData(); + const { meta } = useRouteLoaderData("root")!; const { t } = useTranslation(); const createdAt = idTimestamp(user.id); return ( <> - +
+
+ +
+ + {t("settings.general.username")} + + + + + + +
+ +

+ {t("settings.general.username-change-hint")} +

+ {actionData?.error && } +
+
+ +
+
+
+

{t("settings.general.log-out-everywhere")}

+

+
+

{t("settings.general.table-header")}

+
@@ -39,7 +103,23 @@ export default function SettingsIndex() { + + + + + + + + + + + + @@ -47,3 +127,18 @@ export default function SettingsIndex() { ); } + +function UsernameUpdateError({ error }: { error: ApiError }) { + const { t } = useTranslation(); + const usernameError = firstErrorFor(error, "username"); + if (!usernameError) { + return ; + } + + return ( +

+ {" "} + {t("settings.general.username-update-error", { message: usernameError.message })} +

+ ); +} diff --git a/Foxnouns.Frontend/app/routes/settings/route.tsx b/Foxnouns.Frontend/app/routes/settings/route.tsx index 74123e9..3ef35fd 100644 --- a/Foxnouns.Frontend/app/routes/settings/route.tsx +++ b/Foxnouns.Frontend/app/routes/settings/route.tsx @@ -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("GET", "/users/@me", { token }); + const user = await serverRequest("GET", "/users/@me", { token }); return json({ user, meta: { title: t("settings.title") } }); } catch (e) { return redirect("/auth/log-in"); diff --git a/Foxnouns.Frontend/public/locales/en.json b/Foxnouns.Frontend/public/locales/en.json index 0baebf2..279173b 100644 --- a/Foxnouns.Frontend/public/locales/en.json +++ b/Foxnouns.Frontend/public/locales/en.json @@ -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" }
{t("settings.general.id")}
{t("settings.general.member-count")} - {members.length}/{maxMemberCount} + {user.members.length}/{meta.limits.member_count} +
{t("settings.general.member-list-hidden")}{user.member_list_hidden ? t("yes") : t("no")}
{t("settings.general.custom-preferences")} + {Object.keys(user.custom_preferences).length}/{meta.limits.custom_preferences} +
{t("settings.general.role")} + {user.role}