Compare commits
	
		
			2 commits
		
	
	
		
			e030342358
			...
			567e794154
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 567e794154 | |||
| 40da4865bc | 
					 10 changed files with 113 additions and 62 deletions
				
			
		| 
						 | 
				
			
			@ -39,10 +39,10 @@ public class AuthController(
 | 
			
		|||
                + $"&prompt=none&state={state}"
 | 
			
		||||
                + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/discord")}";
 | 
			
		||||
 | 
			
		||||
        return Ok(new UrlsResponse(discord, null, null));
 | 
			
		||||
        return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, null, null));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private record UrlsResponse(string? Discord, string? Google, string? Tumblr);
 | 
			
		||||
    private record UrlsResponse(bool EmailEnabled, string? Discord, string? Google, string? Tumblr);
 | 
			
		||||
 | 
			
		||||
    public record AuthResponse(
 | 
			
		||||
        UserRendererService.UserResponse User,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -100,6 +100,8 @@ public class EmailAuthController(
 | 
			
		|||
        [FromBody] CompleteRegistrationRequest req
 | 
			
		||||
    )
 | 
			
		||||
    {
 | 
			
		||||
        CheckRequirements();
 | 
			
		||||
 | 
			
		||||
        var email = await keyCacheService.GetKeyAsync($"email:{req.Ticket}");
 | 
			
		||||
        if (email == null)
 | 
			
		||||
            throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket);
 | 
			
		||||
| 
						 | 
				
			
			@ -185,6 +187,8 @@ public class EmailAuthController(
 | 
			
		|||
    [Authorize("*")]
 | 
			
		||||
    public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req)
 | 
			
		||||
    {
 | 
			
		||||
        CheckRequirements();
 | 
			
		||||
 | 
			
		||||
        var emails = await db
 | 
			
		||||
            .AuthMethods.Where(m => m.UserId == CurrentUser!.Id && m.AuthType == AuthType.Email)
 | 
			
		||||
            .ToListAsync();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -16,6 +16,7 @@ export type CallbackResponse = {
 | 
			
		|||
};
 | 
			
		||||
 | 
			
		||||
export type AuthUrls = {
 | 
			
		||||
	email_enabled: boolean;
 | 
			
		||||
	discord?: string;
 | 
			
		||||
	google?: string;
 | 
			
		||||
	tumblr?: string;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import { parse as parseCookie, serialize as serializeCookie } from "cookie";
 | 
			
		||||
import { API_BASE, INTERNAL_API_BASE } from "~/env.server";
 | 
			
		||||
import { INTERNAL_API_BASE } from "~/env.server";
 | 
			
		||||
import { ApiError, ErrorCode } from "./api/error";
 | 
			
		||||
import { tokenCookieName } from "~/lib/utils";
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -16,7 +16,7 @@ export async function baseRequest(
 | 
			
		|||
	path: string,
 | 
			
		||||
	params: RequestParams = {},
 | 
			
		||||
): Promise<Response> {
 | 
			
		||||
	const base = params.isInternal ? INTERNAL_API_BASE + "/internal" : API_BASE + "/v2";
 | 
			
		||||
	const base = params.isInternal ? INTERNAL_API_BASE + "/internal" : INTERNAL_API_BASE + "/v2";
 | 
			
		||||
 | 
			
		||||
	const url = `${base}${path}`;
 | 
			
		||||
	const resp = await fetch(url, {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -11,7 +11,7 @@ import {
 | 
			
		|||
	useActionData,
 | 
			
		||||
	useLoaderData,
 | 
			
		||||
} from "@remix-run/react";
 | 
			
		||||
import { Form, Button, ButtonGroup, ListGroup, Row, Col } from "react-bootstrap";
 | 
			
		||||
import { Form, Button, ButtonGroup, ListGroup } from "react-bootstrap";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import i18n from "~/i18next.server";
 | 
			
		||||
import serverRequest, { getToken, writeCookie } from "~/lib/request.server";
 | 
			
		||||
| 
						 | 
				
			
			@ -78,33 +78,36 @@ export default function LoginPage() {
 | 
			
		|||
 | 
			
		||||
	return (
 | 
			
		||||
		<>
 | 
			
		||||
			<Row>
 | 
			
		||||
				<Col md className="mb-4">
 | 
			
		||||
					<h2>{t("log-in.form-title")}</h2>
 | 
			
		||||
					{actionData?.error && <LoginError error={actionData.error} />}
 | 
			
		||||
					<RemixForm action="/auth/log-in" method="POST">
 | 
			
		||||
						<Form as="div">
 | 
			
		||||
							<Form.Group className="mb-3" controlId="email">
 | 
			
		||||
								<Form.Label>{t("log-in.email")}</Form.Label>
 | 
			
		||||
								<Form.Control name="email" type="email" required />
 | 
			
		||||
							</Form.Group>
 | 
			
		||||
							<Form.Group className="mb-3" controlId="password">
 | 
			
		||||
								<Form.Label>{t("log-in.password")}</Form.Label>
 | 
			
		||||
								<Form.Control name="password" type="password" required />
 | 
			
		||||
							</Form.Group>
 | 
			
		||||
			<div className="row">
 | 
			
		||||
				{!urls.email_enabled && <div className="col-lg-3"></div>}
 | 
			
		||||
				{urls.email_enabled && (
 | 
			
		||||
					<div className="col col-md mb-4">
 | 
			
		||||
						<h2>{t("log-in.form-title")}</h2>
 | 
			
		||||
						{actionData?.error && <LoginError error={actionData.error} />}
 | 
			
		||||
						<RemixForm action="/auth/log-in" method="POST">
 | 
			
		||||
							<Form as="div">
 | 
			
		||||
								<Form.Group className="mb-3" controlId="email">
 | 
			
		||||
									<Form.Label>{t("log-in.email")}</Form.Label>
 | 
			
		||||
									<Form.Control name="email" type="email" required />
 | 
			
		||||
								</Form.Group>
 | 
			
		||||
								<Form.Group className="mb-3" controlId="password">
 | 
			
		||||
									<Form.Label>{t("log-in.password")}</Form.Label>
 | 
			
		||||
									<Form.Control name="password" type="password" required />
 | 
			
		||||
								</Form.Group>
 | 
			
		||||
 | 
			
		||||
							<ButtonGroup>
 | 
			
		||||
								<Button variant="primary" type="submit">
 | 
			
		||||
									{t("log-in.log-in-button")}
 | 
			
		||||
								</Button>
 | 
			
		||||
								<Button as="a" href="/auth/register" variant="secondary">
 | 
			
		||||
									{t("log-in.register-with-email")}
 | 
			
		||||
								</Button>
 | 
			
		||||
							</ButtonGroup>
 | 
			
		||||
						</Form>
 | 
			
		||||
					</RemixForm>
 | 
			
		||||
				</Col>
 | 
			
		||||
				<Col md>
 | 
			
		||||
								<ButtonGroup>
 | 
			
		||||
									<Button variant="primary" type="submit">
 | 
			
		||||
										{t("log-in.log-in-button")}
 | 
			
		||||
									</Button>
 | 
			
		||||
									<Button as="a" href="/auth/register" variant="secondary">
 | 
			
		||||
										{t("log-in.register-with-email")}
 | 
			
		||||
									</Button>
 | 
			
		||||
								</ButtonGroup>
 | 
			
		||||
							</Form>
 | 
			
		||||
						</RemixForm>
 | 
			
		||||
					</div>
 | 
			
		||||
				)}
 | 
			
		||||
				<div className="col col-md">
 | 
			
		||||
					<h2>{t("log-in.3rd-party.title")}</h2>
 | 
			
		||||
					<p>{t("log-in.3rd-party.desc")}</p>
 | 
			
		||||
					<ListGroup>
 | 
			
		||||
| 
						 | 
				
			
			@ -124,8 +127,9 @@ export default function LoginPage() {
 | 
			
		|||
							</ListGroup.Item>
 | 
			
		||||
						)}
 | 
			
		||||
					</ListGroup>
 | 
			
		||||
				</Col>
 | 
			
		||||
			</Row>
 | 
			
		||||
				</div>
 | 
			
		||||
				{!urls.email_enabled && <div className="col-lg-3"></div>}
 | 
			
		||||
			</div>
 | 
			
		||||
		</>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,13 @@
 | 
			
		|||
import { Button, Form, InputGroup, Table } from "react-bootstrap";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { Form as RemixForm, useActionData, useFetcher, useRouteLoaderData } from "@remix-run/react";
 | 
			
		||||
import {
 | 
			
		||||
	Form as RemixForm,
 | 
			
		||||
	Link,
 | 
			
		||||
	Outlet,
 | 
			
		||||
	useActionData,
 | 
			
		||||
	useFetcher,
 | 
			
		||||
	useRouteLoaderData,
 | 
			
		||||
} from "@remix-run/react";
 | 
			
		||||
import { loader as settingsLoader } from "../settings/route";
 | 
			
		||||
import { loader as rootLoader } from "../../root";
 | 
			
		||||
import { DateTime } from "luxon";
 | 
			
		||||
| 
						 | 
				
			
			@ -43,12 +50,12 @@ export default function SettingsIndex() {
 | 
			
		|||
	const actionData = useActionData<typeof action>();
 | 
			
		||||
	const { meta } = useRouteLoaderData<typeof rootLoader>("root")!;
 | 
			
		||||
	const { t } = useTranslation();
 | 
			
		||||
	const fetcher = useFetcher();
 | 
			
		||||
 | 
			
		||||
	const createdAt = idTimestamp(user.id);
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<>
 | 
			
		||||
			<Outlet />
 | 
			
		||||
			<div className="row">
 | 
			
		||||
				<div className="col-md">
 | 
			
		||||
					<RemixForm method="POST">
 | 
			
		||||
| 
						 | 
				
			
			@ -81,11 +88,10 @@ export default function SettingsIndex() {
 | 
			
		|||
			<div>
 | 
			
		||||
				<h4>{t("settings.general.log-out-everywhere")}</h4>
 | 
			
		||||
				<p>{t("settings.general.log-out-everywhere-hint")}</p>
 | 
			
		||||
				<fetcher.Form method="POST" action="/settings/force-log-out">
 | 
			
		||||
					<Button type="submit" variant="danger">
 | 
			
		||||
						{t("settings.general.force-log-out-button")}
 | 
			
		||||
					</Button>
 | 
			
		||||
				</fetcher.Form>
 | 
			
		||||
				{/* @ts-expect-error as=Link */}
 | 
			
		||||
				<Button as={Link} variant="danger" to="/settings/force-log-out">
 | 
			
		||||
					{t("settings.general.force-log-out-button")}
 | 
			
		||||
				</Button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<h4 className="mt-2">{t("settings.general.table-header")}</h4>
 | 
			
		||||
			<Table striped bordered hover>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,10 +1,12 @@
 | 
			
		|||
import i18n from "~/i18next.server";
 | 
			
		||||
import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
 | 
			
		||||
import { Link, useRouteLoaderData } from "@remix-run/react";
 | 
			
		||||
import { Link, useLoaderData, useRouteLoaderData } from "@remix-run/react";
 | 
			
		||||
import { Button, ListGroup } from "react-bootstrap";
 | 
			
		||||
import { loader as settingsLoader } from "~/routes/settings/route";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { AuthMethod, MeUser } from "~/lib/api/user";
 | 
			
		||||
import serverRequest from "~/lib/request.server";
 | 
			
		||||
import { AuthUrls } from "~/lib/api/auth";
 | 
			
		||||
 | 
			
		||||
export const meta: MetaFunction<typeof loader> = ({ data }) => {
 | 
			
		||||
	return [{ title: `${data?.meta.title || "Authentication"} • pronouns.cc` }];
 | 
			
		||||
| 
						 | 
				
			
			@ -12,17 +14,16 @@ export const meta: MetaFunction<typeof loader> = ({ data }) => {
 | 
			
		|||
 | 
			
		||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
 | 
			
		||||
	const t = await i18n.getFixedT(request);
 | 
			
		||||
	return { meta: { title: t("settings.auth.title") } };
 | 
			
		||||
	const urls = await serverRequest<AuthUrls>("POST", "/auth/urls", { isInternal: true });
 | 
			
		||||
 | 
			
		||||
	return { urls, meta: { title: t("settings.auth.title") } };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function AuthSettings() {
 | 
			
		||||
	const { urls } = useLoaderData<typeof loader>();
 | 
			
		||||
	const { user } = useRouteLoaderData<typeof settingsLoader>("routes/settings")!;
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<div className="px-md-5">
 | 
			
		||||
			<EmailSettings user={user} />
 | 
			
		||||
		</div>
 | 
			
		||||
	);
 | 
			
		||||
	return <div className="px-md-5">{urls.email_enabled && <EmailSettings user={user} />}</div>;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function EmailSettings({ user }: { user: MeUser }) {
 | 
			
		||||
| 
						 | 
				
			
			@ -44,7 +45,7 @@ function EmailSettings({ user }: { user: MeUser }) {
 | 
			
		|||
			)}
 | 
			
		||||
			{emails.length < 3 && (
 | 
			
		||||
				<p>
 | 
			
		||||
					{/* @ts-expect-error using as=Link causes an error here, even though it runs completely fine */}
 | 
			
		||||
					{/* @ts-expect-error as=Link */}
 | 
			
		||||
					<Button variant="primary" as={Link} to="/settings/auth/add-email">
 | 
			
		||||
						{emails.length === 0
 | 
			
		||||
							? t("settings.auth.form.add-first-email")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,6 +1,9 @@
 | 
			
		|||
import { ActionFunction, redirect } from "@remix-run/node";
 | 
			
		||||
import { fastRequest, getToken, writeCookie } from "~/lib/request.server";
 | 
			
		||||
import { tokenCookieName } from "~/lib/utils";
 | 
			
		||||
import { Button, Form } from "react-bootstrap";
 | 
			
		||||
import { useTranslation } from "react-i18next";
 | 
			
		||||
import { Form as RemixForm, Link } from "@remix-run/react";
 | 
			
		||||
 | 
			
		||||
export const action: ActionFunction = async ({ request }) => {
 | 
			
		||||
	const token = getToken(request);
 | 
			
		||||
| 
						 | 
				
			
			@ -17,3 +20,29 @@ export const action: ActionFunction = async ({ request }) => {
 | 
			
		|||
		headers: { "Set-Cookie": writeCookie(tokenCookieName, "token", 0) },
 | 
			
		||||
	});
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const loader = () => {
 | 
			
		||||
	return null;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export default function ForceLogoutPage() {
 | 
			
		||||
	const { t } = useTranslation();
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<>
 | 
			
		||||
			<h4>{t("settings.general.log-out-everywhere")}</h4>
 | 
			
		||||
			<p className="text-has-newline">{t("settings.general.log-out-everywhere-confirm")}</p>
 | 
			
		||||
			<RemixForm method="POST">
 | 
			
		||||
				<Form as="div">
 | 
			
		||||
					<Button type="submit" variant="danger">
 | 
			
		||||
						{t("yes")}
 | 
			
		||||
					</Button>
 | 
			
		||||
					{/* @ts-expect-error as=Link */}
 | 
			
		||||
					<Button variant="link" as={Link} to="/settings">
 | 
			
		||||
						{t("no")}
 | 
			
		||||
					</Button>
 | 
			
		||||
				</Form>
 | 
			
		||||
			</RemixForm>
 | 
			
		||||
		</>
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -30,30 +30,35 @@ export default function SettingsLayout() {
 | 
			
		|||
	const { t } = useTranslation();
 | 
			
		||||
	const { pathname } = useLocation();
 | 
			
		||||
 | 
			
		||||
	const isActive = (matches: string[] | string, startsWith: boolean = false) =>
 | 
			
		||||
		startsWith
 | 
			
		||||
			? typeof matches === "string"
 | 
			
		||||
				? pathname.startsWith(matches)
 | 
			
		||||
				: matches.some((m) => pathname.startsWith(m))
 | 
			
		||||
			: typeof matches === "string"
 | 
			
		||||
				? matches === pathname
 | 
			
		||||
				: matches.includes(pathname);
 | 
			
		||||
 | 
			
		||||
	return (
 | 
			
		||||
		<>
 | 
			
		||||
			<Nav variant="pills" justify fill className="flex-column flex-md-row">
 | 
			
		||||
				<Nav.Link active={pathname === "/settings"} as={Link} to="/settings">
 | 
			
		||||
				<Nav.Link
 | 
			
		||||
					active={isActive(["/settings", "/settings/force-log-out"])}
 | 
			
		||||
					as={Link}
 | 
			
		||||
					to="/settings"
 | 
			
		||||
				>
 | 
			
		||||
					{t("settings.nav.general-information")}
 | 
			
		||||
				</Nav.Link>
 | 
			
		||||
				<Nav.Link
 | 
			
		||||
					active={pathname.startsWith("/settings/profile")}
 | 
			
		||||
					as={Link}
 | 
			
		||||
					to="/settings/profile"
 | 
			
		||||
				>
 | 
			
		||||
				<Nav.Link active={isActive("/settings/profile", true)} as={Link} to="/settings/profile">
 | 
			
		||||
					{t("settings.nav.profile")}
 | 
			
		||||
				</Nav.Link>
 | 
			
		||||
				<Nav.Link
 | 
			
		||||
					active={pathname.startsWith("/settings/members")}
 | 
			
		||||
					as={Link}
 | 
			
		||||
					to="/settings/members"
 | 
			
		||||
				>
 | 
			
		||||
				<Nav.Link active={isActive("/settings/members", true)} as={Link} to="/settings/members">
 | 
			
		||||
					{t("settings.nav.members")}
 | 
			
		||||
				</Nav.Link>
 | 
			
		||||
				<Nav.Link active={pathname.startsWith("/settings/auth")} as={Link} to="/settings/auth">
 | 
			
		||||
				<Nav.Link active={isActive("/settings/auth", true)} as={Link} to="/settings/auth">
 | 
			
		||||
					{t("settings.nav.authentication")}
 | 
			
		||||
				</Nav.Link>
 | 
			
		||||
				<Nav.Link active={pathname === "/settings/export"} as={Link} to="/settings/export">
 | 
			
		||||
				<Nav.Link active={isActive("/settings/export")} as={Link} to="/settings/export">
 | 
			
		||||
					{t("settings.nav.export")}
 | 
			
		||||
				</Nav.Link>
 | 
			
		||||
			</Nav>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -105,7 +105,8 @@
 | 
			
		|||
			"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}}"
 | 
			
		||||
			"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
 | 
			
		||||
			"log-out-everywhere-confirm": "Are you sure you want to log out everywhere?\nPlease double check your authentication methods before doing so, as it might lock you out of your account."
 | 
			
		||||
		},
 | 
			
		||||
		"auth": {
 | 
			
		||||
			"title": "Authentication",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue