Compare commits
7 commits
0bdd0148d2
...
562ecc46bd
Author | SHA1 | Date | |
---|---|---|---|
562ecc46bd | |||
4002893323 | |||
80ac16694c | |||
8f3478d57a | |||
2b8e4c3e8d | |||
646c2694e1 | |||
19bfee6203 |
20 changed files with 303 additions and 108 deletions
0
.noai
Normal file
0
.noai
Normal file
|
@ -41,6 +41,8 @@ public class MembersController(
|
||||||
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
return Ok(memberRenderer.RenderMember(member, CurrentToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public const int MaxMemberCount = 500;
|
||||||
|
|
||||||
[HttpPost("/api/v2/users/@me/members")]
|
[HttpPost("/api/v2/users/@me/members")]
|
||||||
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberRendererService.MemberResponse>(StatusCodes.Status200OK)]
|
||||||
[Authorize("member.create")]
|
[Authorize("member.create")]
|
||||||
|
@ -58,6 +60,10 @@ public class MembersController(
|
||||||
.. ValidationUtils.ValidateLinks(req.Links)
|
.. ValidationUtils.ValidateLinks(req.Links)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
var memberCount = await db.Members.CountAsync(m => m.UserId == CurrentUser.Id, ct);
|
||||||
|
if (memberCount >= MaxMemberCount)
|
||||||
|
throw new ApiError.BadRequest("Maximum number of members reached");
|
||||||
|
|
||||||
var member = new Member
|
var member = new Member
|
||||||
{
|
{
|
||||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
@ -100,7 +106,9 @@ public class MembersController(
|
||||||
var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
var member = await db.ResolveMemberAsync(CurrentUser!.Id, memberRef);
|
||||||
var errors = new List<(string, ValidationError?)>();
|
var errors = new List<(string, ValidationError?)>();
|
||||||
|
|
||||||
if (req.Name != null)
|
// We might add extra validations for names later down the line.
|
||||||
|
// These should only take effect when a member's name is changed, not on other changes.
|
||||||
|
if (req.Name != null && req.Name != member.Name)
|
||||||
{
|
{
|
||||||
errors.Add(("name", ValidationUtils.ValidateMemberName(req.Name)));
|
errors.Add(("name", ValidationUtils.ValidateMemberName(req.Name)));
|
||||||
member.Name = req.Name;
|
member.Name = req.Name;
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers;
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
@ -18,14 +19,22 @@ public class MetaController : ApiControllerBase
|
||||||
(int)FoxnounsMetrics.UsersActiveMonthCount.Value,
|
(int)FoxnounsMetrics.UsersActiveMonthCount.Value,
|
||||||
(int)FoxnounsMetrics.UsersActiveWeekCount.Value,
|
(int)FoxnounsMetrics.UsersActiveWeekCount.Value,
|
||||||
(int)FoxnounsMetrics.UsersActiveDayCount.Value
|
(int)FoxnounsMetrics.UsersActiveDayCount.Value
|
||||||
))
|
),
|
||||||
|
new Limits(
|
||||||
|
MemberCount: MembersController.MaxMemberCount,
|
||||||
|
BioLength: ValidationUtils.MaxBioLength))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
[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);
|
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)
|
||||||
|
private record Limits(
|
||||||
|
int MemberCount,
|
||||||
|
int BioLength);
|
||||||
}
|
}
|
|
@ -127,12 +127,14 @@ public static class ValidationUtils
|
||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public const int MaxBioLength = 1024;
|
||||||
|
|
||||||
public static ValidationError? ValidateBio(string? bio)
|
public static ValidationError? ValidateBio(string? bio)
|
||||||
{
|
{
|
||||||
return bio?.Length switch
|
return bio?.Length switch
|
||||||
{
|
{
|
||||||
0 => ValidationError.LengthError("Bio is too short", 1, 1024, bio.Length),
|
0 => ValidationError.LengthError("Bio is too short", 1, MaxBioLength, bio.Length),
|
||||||
> 1024 => ValidationError.LengthError("Bio is too long", 1, 1024, bio.Length),
|
> MaxBioLength => ValidationError.LengthError("Bio is too long", 1, MaxBioLength, bio.Length),
|
||||||
_ => null
|
_ => null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,11 @@
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { env } from "node:process";
|
import { env } from "node:process";
|
||||||
|
import { Limits } from "~/lib/api/meta";
|
||||||
|
|
||||||
export const API_BASE = env.API_BASE || "https://pronouns.localhost/api";
|
export const API_BASE = env.API_BASE || "https://pronouns.localhost/api";
|
||||||
export const LANGUAGE = env.LANGUAGE || "en";
|
export const LANGUAGE = env.LANGUAGE || "en";
|
||||||
|
|
||||||
|
const apiLimits: Limits = await fetch(`${API_BASE}/v2/meta`)
|
||||||
|
.then((resp) => resp.json())
|
||||||
|
.then((m) => m.limits);
|
||||||
|
export const limits: Limits = Object.freeze(apiLimits);
|
||||||
|
|
|
@ -9,3 +9,8 @@ export default interface Meta {
|
||||||
};
|
};
|
||||||
members: number;
|
members: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Limits = {
|
||||||
|
member_count: number;
|
||||||
|
bio_length: number;
|
||||||
|
};
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
|
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
|
||||||
import { API_BASE } from "~/env.server";
|
import { API_BASE } from "~/env.server";
|
||||||
import { ApiError, ErrorCode } from "./api/error";
|
import { ApiError, ErrorCode } from "./api/error";
|
||||||
|
import { tokenCookieName } from "~/lib/utils";
|
||||||
|
|
||||||
export type RequestParams = {
|
export type RequestParams = {
|
||||||
token?: string;
|
token?: string;
|
||||||
|
@ -39,7 +40,7 @@ export default async function serverRequest<T>(
|
||||||
return (await resp.json()) as T;
|
return (await resp.json()) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getToken = (req: Request) => getCookie(req, "pronounscc-token");
|
export const getToken = (req: Request) => getCookie(req, tokenCookieName);
|
||||||
|
|
||||||
export function getCookie(req: Request, cookieName: string): string | undefined {
|
export function getCookie(req: Request, cookieName: string): string | undefined {
|
||||||
const header = req.headers.get("Cookie");
|
const header = req.headers.get("Cookie");
|
||||||
|
@ -57,4 +58,5 @@ export const writeCookie = (cookieName: string, value: string, maxAge: number |
|
||||||
path: "/",
|
path: "/",
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
secure: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1 +1,6 @@
|
||||||
|
import { DateTime } from "luxon";
|
||||||
|
|
||||||
export const defaultAvatarUrl = "https://pronouns.cc/default/512.webp";
|
export const defaultAvatarUrl = "https://pronouns.cc/default/512.webp";
|
||||||
|
export const tokenCookieName = "__Host-pronounscc-token";
|
||||||
|
export const idTimestamp = (id: string) =>
|
||||||
|
DateTime.fromMillis(parseInt(id, 10) / (1 << 22) + 1_640_995_200_000);
|
||||||
|
|
|
@ -13,7 +13,7 @@ import { LoaderFunctionArgs } from "@remix-run/node";
|
||||||
import { useChangeLanguage } from "remix-i18next/react";
|
import { useChangeLanguage } from "remix-i18next/react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import serverRequest, { getCookie, writeCookie } from "./lib/request.server";
|
import serverRequest, { getToken, writeCookie } from "./lib/request.server";
|
||||||
import Meta from "./lib/api/meta";
|
import Meta from "./lib/api/meta";
|
||||||
import Navbar from "./components/nav/Navbar";
|
import Navbar from "./components/nav/Navbar";
|
||||||
import { User, UserSettings } from "./lib/api/user";
|
import { User, UserSettings } from "./lib/api/user";
|
||||||
|
@ -26,11 +26,12 @@ import { errorCodeDesc } from "./components/ErrorAlert";
|
||||||
import { Container } from "react-bootstrap";
|
import { Container } from "react-bootstrap";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
import BaseNavbar from "~/components/nav/BaseNavbar";
|
import BaseNavbar from "~/components/nav/BaseNavbar";
|
||||||
|
import { tokenCookieName } from "~/lib/utils";
|
||||||
|
|
||||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
const meta = await serverRequest<Meta>("GET", "/meta");
|
const meta = await serverRequest<Meta>("GET", "/meta");
|
||||||
|
|
||||||
const token = getCookie(request, "pronounscc-token");
|
const token = getToken(request);
|
||||||
let setCookie = "";
|
let setCookie = "";
|
||||||
|
|
||||||
let meUser: User | undefined;
|
let meUser: User | undefined;
|
||||||
|
@ -43,7 +44,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// If we get an unauthorized error, clear the token, as it's not valid anymore.
|
// If we get an unauthorized error, clear the token, as it's not valid anymore.
|
||||||
if ((e as ApiError).code === ErrorCode.AuthenticationRequired) {
|
if ((e as ApiError).code === ErrorCode.AuthenticationRequired) {
|
||||||
setCookie = writeCookie("pronounscc-token", token, 0);
|
setCookie = writeCookie(tokenCookieName, token, 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -93,7 +93,7 @@ export default function UserPage() {
|
||||||
{user.member_title || t("user.heading.members")}{" "}
|
{user.member_title || t("user.heading.members")}{" "}
|
||||||
{isMeUser && (
|
{isMeUser && (
|
||||||
// @ts-expect-error using as=Link causes an error here, even though it runs completely fine
|
// @ts-expect-error using as=Link causes an error here, even though it runs completely fine
|
||||||
<Button as={Link} to="/settings/members/create" variant="success">
|
<Button as={Link} to="/settings/new-member" variant="success">
|
||||||
<PersonPlusFill /> {t("user.create-member-button")}
|
<PersonPlusFill /> {t("user.create-member-button")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -41,7 +41,7 @@ export default function MemberPage() {
|
||||||
<Trans t={t} i18nKey="member.own-profile-alert" values={{ memberName: member.name }}>
|
<Trans t={t} i18nKey="member.own-profile-alert" values={{ memberName: member.name }}>
|
||||||
You are currently viewing the <strong>public</strong> profile of {{ memberName }}.
|
You are currently viewing the <strong>public</strong> profile of {{ memberName }}.
|
||||||
<br />
|
<br />
|
||||||
<Link to={`/settings/profile/${member.id}`}>Edit your profile</Link>
|
<Link to={`/settings/members/${member.id}`}>Edit profile</Link>
|
||||||
</Trans>
|
</Trans>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -14,11 +14,15 @@ import {
|
||||||
useActionData,
|
useActionData,
|
||||||
useLoaderData,
|
useLoaderData,
|
||||||
ShouldRevalidateFunction,
|
ShouldRevalidateFunction,
|
||||||
|
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";
|
||||||
import ErrorAlert from "~/components/ErrorAlert";
|
import ErrorAlert from "~/components/ErrorAlert";
|
||||||
import i18n from "~/i18next.server";
|
import i18n from "~/i18next.server";
|
||||||
|
import { tokenCookieName } from "~/lib/utils";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||||
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
|
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
|
||||||
|
@ -53,7 +57,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": writeCookie("pronounscc-token", resp.token!),
|
"Set-Cookie": writeCookie(tokenCookieName, resp.token!),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -90,7 +94,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
|
|
||||||
return redirect("/auth/welcome", {
|
return redirect("/auth/welcome", {
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": writeCookie("pronounscc-token", resp.token),
|
"Set-Cookie": writeCookie(tokenCookieName, resp.token),
|
||||||
},
|
},
|
||||||
status: 303,
|
status: 303,
|
||||||
});
|
});
|
||||||
|
@ -105,6 +109,16 @@ export default function DiscordCallbackPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const data = useLoaderData<typeof loader>();
|
const data = useLoaderData<typeof loader>();
|
||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (data.hasAccount) {
|
||||||
|
navigate(`/@${data.user!.username}`);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (data.hasAccount) {
|
if (data.hasAccount) {
|
||||||
const username = data.user!.username;
|
const username = data.user!.username;
|
||||||
|
|
|
@ -19,6 +19,7 @@ import { AuthResponse, AuthUrls } from "~/lib/api/auth";
|
||||||
import { ApiError, ErrorCode } from "~/lib/api/error";
|
import { ApiError, ErrorCode } from "~/lib/api/error";
|
||||||
import ErrorAlert from "~/components/ErrorAlert";
|
import ErrorAlert from "~/components/ErrorAlert";
|
||||||
import { User } from "~/lib/api/user";
|
import { User } from "~/lib/api/user";
|
||||||
|
import { tokenCookieName } from "~/lib/utils";
|
||||||
|
|
||||||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||||
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
|
return [{ title: `${data?.meta.title || "Log in"} • pronouns.cc` }];
|
||||||
|
@ -61,7 +62,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||||
return redirect("/", {
|
return redirect("/", {
|
||||||
status: 303,
|
status: 303,
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": writeCookie("pronounscc-token", resp.token),
|
"Set-Cookie": writeCookie(tokenCookieName, resp.token),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import { ActionFunction } from "@remix-run/node";
|
import { ActionFunction } from "@remix-run/node";
|
||||||
import { writeCookie } from "~/lib/request.server";
|
import { writeCookie } from "~/lib/request.server";
|
||||||
|
import { tokenCookieName } from "~/lib/utils";
|
||||||
|
|
||||||
export const action: ActionFunction = async () => {
|
export const action: ActionFunction = async () => {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
headers: {
|
headers: {
|
||||||
"Set-Cookie": writeCookie("pronounscc-token", "token", 0),
|
"Set-Cookie": writeCookie(tokenCookieName, "token", 0),
|
||||||
},
|
},
|
||||||
status: 204,
|
status: 204,
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { ActionFunction } from "@remix-run/node";
|
import { ActionFunction } from "@remix-run/node";
|
||||||
import { UserSettings } from "~/lib/api/user";
|
import { UserSettings } from "~/lib/api/user";
|
||||||
import serverRequest, { getCookie, writeCookie } from "~/lib/request.server";
|
import serverRequest, { getToken, writeCookie } from "~/lib/request.server";
|
||||||
|
|
||||||
// Handles theme switching
|
// Handles theme switching
|
||||||
// Remix itself handles redirecting back to the original page after the setting is set
|
// Remix itself handles redirecting back to the original page after the setting is set
|
||||||
|
@ -15,7 +15,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||||
const body = await request.formData();
|
const body = await request.formData();
|
||||||
const theme = (body.get("theme") as string | null) || "auto";
|
const theme = (body.get("theme") as string | null) || "auto";
|
||||||
|
|
||||||
const token = getCookie(request, "pronounscc-token");
|
const token = getToken(request);
|
||||||
if (token) {
|
if (token) {
|
||||||
await serverRequest<UserSettings>("PATCH", "/users/@me/settings", {
|
await serverRequest<UserSettings>("PATCH", "/users/@me/settings", {
|
||||||
token,
|
token,
|
||||||
|
|
49
Foxnouns.Frontend/app/routes/settings._index/route.tsx
Normal file
49
Foxnouns.Frontend/app/routes/settings._index/route.tsx
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { Table } from "react-bootstrap";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useLoaderData, 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 { DateTime } from "luxon";
|
||||||
|
import { idTimestamp } from "~/lib/utils";
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const token = getToken(request);
|
||||||
|
const members = await serverRequest<PartialMember[]>("GET", "/users/@me/members", { token });
|
||||||
|
return json({ members, maxMemberCount: limits.member_count });
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsIndex() {
|
||||||
|
const { members, maxMemberCount } = useLoaderData<typeof loader>();
|
||||||
|
const { user } = useRouteLoaderData<typeof settingsLoader>("routes/settings")!;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const createdAt = idTimestamp(user.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Table striped bordered>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{t("settings.general.id")}</th>
|
||||||
|
<td>
|
||||||
|
<code>{user.id}</code>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{t("settings.general.created")}</th>
|
||||||
|
<td>{createdAt.toLocaleString(DateTime.DATETIME_MED)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{t("settings.general.member-count")}</th>
|
||||||
|
<td>
|
||||||
|
{members.length}/{maxMemberCount}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
65
Foxnouns.Frontend/app/routes/settings/route.tsx
Normal file
65
Foxnouns.Frontend/app/routes/settings/route.tsx
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
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 { Link, Outlet, useLocation } from "@remix-run/react";
|
||||||
|
import { Nav } from "react-bootstrap";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const meta: MetaFunction<typeof loader> = ({ data }) => {
|
||||||
|
return [{ title: `${data?.meta.title || "Settings"} • pronouns.cc` }];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||||
|
const t = await i18n.getFixedT(request);
|
||||||
|
const token = getToken(request);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
try {
|
||||||
|
const user = await serverRequest<User>("GET", "/users/@me", { token });
|
||||||
|
return json({ user, meta: { title: t("settings.title") } });
|
||||||
|
} catch (e) {
|
||||||
|
return redirect("/auth/log-in");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect("/auth/log-in");
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SettingsLayout() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Nav variant="pills" justify fill className="flex-column flex-md-row">
|
||||||
|
<Nav.Link active={pathname === "/settings"} as={Link} to="/settings">
|
||||||
|
{t("settings.nav.general-information")}
|
||||||
|
</Nav.Link>
|
||||||
|
<Nav.Link
|
||||||
|
active={pathname.startsWith("/settings/profile")}
|
||||||
|
as={Link}
|
||||||
|
to="/settings/profile"
|
||||||
|
>
|
||||||
|
{t("settings.nav.profile")}
|
||||||
|
</Nav.Link>
|
||||||
|
<Nav.Link
|
||||||
|
active={pathname.startsWith("/settings/members")}
|
||||||
|
as={Link}
|
||||||
|
to="/settings/members"
|
||||||
|
>
|
||||||
|
{t("settings.nav.members")}
|
||||||
|
</Nav.Link>
|
||||||
|
<Nav.Link active={pathname.startsWith("/settings/auth")} as={Link} to="/settings/auth">
|
||||||
|
{t("settings.nav.authentication")}
|
||||||
|
</Nav.Link>
|
||||||
|
<Nav.Link active={pathname === "/settings/export"} as={Link} to="/settings/export">
|
||||||
|
{t("settings.nav.export")}
|
||||||
|
</Nav.Link>
|
||||||
|
</Nav>
|
||||||
|
<div className="my-3">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -30,6 +30,7 @@
|
||||||
"i18next-fs-backend": "^2.3.2",
|
"i18next-fs-backend": "^2.3.2",
|
||||||
"i18next-http-backend": "^2.6.1",
|
"i18next-http-backend": "^2.6.1",
|
||||||
"isbot": "^4.1.0",
|
"isbot": "^4.1.0",
|
||||||
|
"luxon": "^3.5.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
@ -46,6 +47,7 @@
|
||||||
"@types/compression": "^1.7.5",
|
"@types/compression": "^1.7.5",
|
||||||
"@types/cookie": "^0.6.0",
|
"@types/cookie": "^0.6.0",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
|
"@types/luxon": "^3.4.2",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/react": "^18.2.20",
|
"@types/react": "^18.2.20",
|
||||||
|
|
|
@ -1,93 +1,108 @@
|
||||||
{
|
{
|
||||||
"error": {
|
"error": {
|
||||||
"heading": "An error occurred",
|
"heading": "An error occurred",
|
||||||
"validation": {
|
"validation": {
|
||||||
"too-long": "Value is too long, maximum length is {{maxLength}}, current length is {{actualLength}}.",
|
"too-long": "Value is too long, maximum length is {{maxLength}}, current length is {{actualLength}}.",
|
||||||
"too-short": "Value is too short, minimum length is {{minLength}}, current length is {{actualLength}}.",
|
"too-short": "Value is too short, minimum length is {{minLength}}, current length is {{actualLength}}.",
|
||||||
"disallowed-value": "The value <1>{{actualValue}}</1> is not allowed here. Allowed values are: <4>{{allowedValues}}</4>",
|
"disallowed-value": "The value <1>{{actualValue}}</1> is not allowed here. Allowed values are: <4>{{allowedValues}}</4>",
|
||||||
"generic": "The value <1>{{actualValue}}</1> is not allowed here. Reason: {{reason}}",
|
"generic": "The value <1>{{actualValue}}</1> is not allowed here. Reason: {{reason}}",
|
||||||
"generic-no-value": "The value you entered is not allowed here. Reason: {{reason}}"
|
"generic-no-value": "The value you entered is not allowed here. Reason: {{reason}}"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"authentication-error": "There was an error validating your credentials.",
|
"authentication-error": "There was an error validating your credentials.",
|
||||||
"authentication-required": "You need to log in.",
|
"authentication-required": "You need to log in.",
|
||||||
"bad-request": "Server rejected your input, please check anything for errors.",
|
"bad-request": "Server rejected your input, please check anything for errors.",
|
||||||
"forbidden": "You are not allowed to perform that action.",
|
"forbidden": "You are not allowed to perform that action.",
|
||||||
"generic-error": "An unknown error occurred.",
|
"generic-error": "An unknown error occurred.",
|
||||||
"internal-server-error": "Server experienced an internal error, please try again later.",
|
"internal-server-error": "Server experienced an internal error, please try again later.",
|
||||||
"member-not-found": "Member not found, please check your spelling and try again.",
|
"member-not-found": "Member not found, please check your spelling and try again.",
|
||||||
"user-not-found": "User not found, please check your spelling and try again."
|
"user-not-found": "User not found, please check your spelling and try again."
|
||||||
},
|
},
|
||||||
"title": "An error occurred",
|
"title": "An error occurred",
|
||||||
"more-info": "Click here for a more detailed error"
|
"more-info": "Click here for a more detailed error"
|
||||||
},
|
},
|
||||||
"navbar": {
|
"navbar": {
|
||||||
"view-profile": "View profile",
|
"view-profile": "View profile",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"log-out": "Log out",
|
"log-out": "Log out",
|
||||||
"log-in": "Log in or sign up"
|
"log-in": "Log in or sign up"
|
||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"avatar-alt": "Avatar for @{{username}}",
|
"avatar-alt": "Avatar for @{{username}}",
|
||||||
"heading": {
|
"heading": {
|
||||||
"names": "Names",
|
"names": "Names",
|
||||||
"pronouns": "Pronouns",
|
"pronouns": "Pronouns",
|
||||||
"members": "Members"
|
"members": "Members"
|
||||||
},
|
},
|
||||||
"member-avatar-alt": "Avatar for {{name}}",
|
"member-avatar-alt": "Avatar for {{name}}",
|
||||||
"member-hidden": "This member is unlisted, and not shown in your public member list.",
|
"member-hidden": "This member is unlisted, and not shown in your public member list.",
|
||||||
"own-profile-alert": "You are currently viewing your <1>public</1> profile.<3></3><4>Edit your profile</4>",
|
"own-profile-alert": "You are currently viewing your <1>public</1> profile.<3></3><4>Edit your profile</4>",
|
||||||
"create-member-button": "Create member",
|
"create-member-button": "Create member",
|
||||||
"no-members-blurb": "You don't have any members yet.<1></1>Members are sub-profiles that can have their own avatar, names, pronouns, and preferred terms.<3></3>You can create a new member with the \"Create member\" button above. <6>(only you can see this)</6>"
|
"no-members-blurb": "You don't have any members yet.<1></1>Members are sub-profiles that can have their own avatar, names, pronouns, and preferred terms.<3></3>You can create a new member with the \"Create member\" button above. <6>(only you can see this)</6>"
|
||||||
},
|
},
|
||||||
"member": {
|
"member": {
|
||||||
"avatar-alt": "Avatar for {{name}}",
|
"avatar-alt": "Avatar for {{name}}",
|
||||||
"own-profile-alert": "You are currently viewing the <1>public</1> profile of {{memberName}}.<5></5><6>Edit your profile</6>",
|
"own-profile-alert": "You are currently viewing the <1>public</1> profile of {{memberName}}.<5></5><6>Edit profile</6>",
|
||||||
"back": "Back to {{name}}"
|
"back": "Back to {{name}}"
|
||||||
},
|
},
|
||||||
"log-in": {
|
"log-in": {
|
||||||
"callback": {
|
"callback": {
|
||||||
"title": {
|
"title": {
|
||||||
"discord-success": "Log in with Discord",
|
"discord-success": "Log in with Discord",
|
||||||
"discord-register": "Register with Discord"
|
"discord-register": "Register with Discord"
|
||||||
},
|
},
|
||||||
"success": "Successfully logged in!",
|
"success": "Successfully logged in!",
|
||||||
"success-link": "Welcome back, <1>@{{username}}</1>!",
|
"success-link": "Welcome back, <1>@{{username}}</1>!",
|
||||||
"redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.",
|
"redirect-hint": "If you're not redirected to your profile in a few seconds, press the link above.",
|
||||||
"remote-username": {
|
"remote-username": {
|
||||||
"discord": "Your discord username"
|
"discord": "Your discord username"
|
||||||
},
|
},
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"sign-up-button": "Sign up",
|
"sign-up-button": "Sign up",
|
||||||
"invalid-ticket": "Invalid ticket (it might have been too long since you logged in with Discord), please <2>try again</2>.",
|
"invalid-ticket": "Invalid ticket (it might have been too long since you logged in with Discord), please <2>try again</2>.",
|
||||||
"invalid-username": "Invalid username",
|
"invalid-username": "Invalid username",
|
||||||
"username-taken": "That username is already taken, please try something else."
|
"username-taken": "That username is already taken, please try something else."
|
||||||
},
|
},
|
||||||
"title": "Log in",
|
"title": "Log in",
|
||||||
"form-title": "Log in with email",
|
"form-title": "Log in with email",
|
||||||
"email": "Email address",
|
"email": "Email address",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
"log-in-button": "Log in",
|
"log-in-button": "Log in",
|
||||||
"register-with-email": "Register with email",
|
"register-with-email": "Register with email",
|
||||||
"3rd-party": {
|
"3rd-party": {
|
||||||
"title": "Log in with another service",
|
"title": "Log in with another service",
|
||||||
"desc": "If you prefer, you can also log in with one of these services:",
|
"desc": "If you prefer, you can also log in with one of these services:",
|
||||||
"discord": "Log in with Discord",
|
"discord": "Log in with Discord",
|
||||||
"google": "Log in with Google",
|
"google": "Log in with Google",
|
||||||
"tumblr": "Log in with Tumblr"
|
"tumblr": "Log in with Tumblr"
|
||||||
},
|
},
|
||||||
"invalid-credentials": "Invalid email address or password, please check your spelling and try again."
|
"invalid-credentials": "Invalid email address or password, please check your spelling and try again."
|
||||||
},
|
},
|
||||||
"welcome": {
|
"welcome": {
|
||||||
"title": "Welcome",
|
"title": "Welcome",
|
||||||
"header": "Welcome to pronouns.cc!",
|
"header": "Welcome to pronouns.cc!",
|
||||||
"blurb": "{welcome.blurb}",
|
"blurb": "{welcome.blurb}",
|
||||||
"customize-profile": "Customize your profile",
|
"customize-profile": "Customize your profile",
|
||||||
"customize-profile-blurb": "{welcome.customize-profile-blurb}",
|
"customize-profile-blurb": "{welcome.customize-profile-blurb}",
|
||||||
"create-members": "Create members",
|
"create-members": "Create members",
|
||||||
"create-members-blurb": "{welcome.create-members-blurb}",
|
"create-members-blurb": "{welcome.create-members-blurb}",
|
||||||
"custom-preferences": "Customize your preferences",
|
"custom-preferences": "Customize your preferences",
|
||||||
"custom-preferences-blurb": "{welcome.custom-preferences-blurb}",
|
"custom-preferences-blurb": "{welcome.custom-preferences-blurb}",
|
||||||
"profile-button": "Go to your profile"
|
"profile-button": "Go to your profile"
|
||||||
}
|
},
|
||||||
|
"settings": {
|
||||||
|
"general": {
|
||||||
|
"id": "Your user ID",
|
||||||
|
"created": "Account created at",
|
||||||
|
"member-count": "Members"
|
||||||
|
},
|
||||||
|
"title": "Settings",
|
||||||
|
"nav": {
|
||||||
|
"general-information": "General information",
|
||||||
|
"profile": "Base profile",
|
||||||
|
"members": "Members",
|
||||||
|
"authentication": "Authentication",
|
||||||
|
"export": "Export your data"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1341,6 +1341,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
|
resolved "https://registry.yarnpkg.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76"
|
||||||
integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
|
integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==
|
||||||
|
|
||||||
|
"@types/luxon@^3.4.2":
|
||||||
|
version "3.4.2"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7"
|
||||||
|
integrity sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==
|
||||||
|
|
||||||
"@types/markdown-it@^14.1.2":
|
"@types/markdown-it@^14.1.2":
|
||||||
version "14.1.2"
|
version "14.1.2"
|
||||||
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
|
resolved "https://registry.yarnpkg.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61"
|
||||||
|
@ -4499,6 +4504,11 @@ lru-cache@^7.4.4, lru-cache@^7.5.1, lru-cache@^7.7.1:
|
||||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
|
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89"
|
||||||
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
|
integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==
|
||||||
|
|
||||||
|
luxon@^3.5.0:
|
||||||
|
version "3.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.5.0.tgz#6b6f65c5cd1d61d1fd19dbf07ee87a50bf4b8e20"
|
||||||
|
integrity sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==
|
||||||
|
|
||||||
markdown-extensions@^1.0.0:
|
markdown-extensions@^1.0.0:
|
||||||
version "1.1.1"
|
version "1.1.1"
|
||||||
resolved "https://registry.yarnpkg.com/markdown-extensions/-/markdown-extensions-1.1.1.tgz#fea03b539faeaee9b4ef02a3769b455b189f7fc3"
|
resolved "https://registry.yarnpkg.com/markdown-extensions/-/markdown-extensions-1.1.1.tgz#fea03b539faeaee9b4ef02a3769b455b189f7fc3"
|
||||||
|
|
Loading…
Reference in a new issue