diff --git a/.idea/.idea.Foxnouns.NET/.idea/CSharpierPlugin.xml b/.idea/.idea.Foxnouns.NET/.idea/CSharpierPlugin.xml new file mode 100644 index 0000000..5e24061 --- /dev/null +++ b/.idea/.idea.Foxnouns.NET/.idea/CSharpierPlugin.xml @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs index 593e20f..937ab3a 100644 --- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs @@ -197,10 +197,18 @@ public class EmailAuthController( ); } - var validPassword = await authService.ValidatePasswordAsync(CurrentUser!, req.Password); - if (!validPassword) + if (emails.Count != 0) { - throw new ApiError.Forbidden("Invalid password"); + var validPassword = await authService.ValidatePasswordAsync(CurrentUser!, req.Password); + if (!validPassword) + { + throw new ApiError.Forbidden("Invalid password"); + } + } + else + { + await authService.SetUserPasswordAsync(CurrentUser!, req.Password); + await db.SaveChangesAsync(); } var state = await keyCacheService.GenerateRegisterEmailStateAsync( diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index 6226223..d03496c 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -129,6 +129,12 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s return (user, EmailAuthenticationResult.AuthSuccessful); } + public enum EmailAuthenticationResult + { + AuthSuccessful, + MfaRequired, + } + /// /// Validates a user's password outside an authentication context, for when a password is required for changing /// a setting, such as adding a new email address or changing passwords. @@ -153,10 +159,17 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s or PasswordVerificationResult.Success; } - public enum EmailAuthenticationResult + /// + /// Sets or updates a password for the given user. This method does not save the updated password automatically. + /// + public async Task SetUserPasswordAsync( + User user, + string password, + CancellationToken ct = default + ) { - AuthSuccessful, - MfaRequired, + user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); + db.Update(user); } /// diff --git a/Foxnouns.Frontend/app/lib/request.server.ts b/Foxnouns.Frontend/app/lib/request.server.ts index c92f67d..562666d 100644 --- a/Foxnouns.Frontend/app/lib/request.server.ts +++ b/Foxnouns.Frontend/app/lib/request.server.ts @@ -11,7 +11,7 @@ export type RequestParams = { isInternal?: boolean; }; -async function requestInternal( +export async function baseRequest( method: string, path: string, params: RequestParams = {}, @@ -44,7 +44,7 @@ async function requestInternal( } export async function fastRequest(method: string, path: string, params: RequestParams = {}) { - await requestInternal(method, path, params); + await baseRequest(method, path, params); } export default async function serverRequest( @@ -52,7 +52,7 @@ export default async function serverRequest( path: string, params: RequestParams = {}, ) { - const resp = await requestInternal(method, path, params); + const resp = await baseRequest(method, path, params); return (await resp.json()) as T; } diff --git a/Foxnouns.Frontend/app/routes/settings.auth/route.tsx b/Foxnouns.Frontend/app/routes/settings.auth/route.tsx new file mode 100644 index 0000000..22d2fcd --- /dev/null +++ b/Foxnouns.Frontend/app/routes/settings.auth/route.tsx @@ -0,0 +1,76 @@ +import i18n from "~/i18next.server"; +import { LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import { Link, 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"; + +export const meta: MetaFunction = ({ data }) => { + return [{ title: `${data?.meta.title || "Authentication"} • pronouns.cc` }]; +}; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const t = await i18n.getFixedT(request); + return { meta: { title: t("settings.auth.title") } }; +}; + +export default function AuthSettings() { + const { user } = useRouteLoaderData("routes/settings")!; + + return ( +
+ +
+ ); +} + +function EmailSettings({ user }: { user: MeUser }) { + const { t } = useTranslation(); + const oneAuthMethod = user.auth_methods.length === 1; + const emails = user.auth_methods.filter((m) => m.type === "EMAIL"); + + return ( + <> +

{t("settings.auth.email-addresses")}

+ {emails.length > 0 && ( + <> + + {emails.map((e) => ( + + ))} + + + )} + {emails.length < 3 && ( +

+ {/* @ts-expect-error using as=Link causes an error here, even though it runs completely fine */} + +

+ )} + + ); +} + +function EmailRow({ email, disabled }: { email: AuthMethod; disabled: boolean }) { + const { t } = useTranslation(); + + return ( + +
+
{email.remote_id}
+ {!disabled && ( +
+ + {t("settings.auth.remove-auth-method")} + +
+ )} +
+
+ ); +} diff --git a/Foxnouns.Frontend/app/routes/settings.auth_.add-email/route.tsx b/Foxnouns.Frontend/app/routes/settings.auth_.add-email/route.tsx new file mode 100644 index 0000000..40eed7f --- /dev/null +++ b/Foxnouns.Frontend/app/routes/settings.auth_.add-email/route.tsx @@ -0,0 +1,105 @@ +import { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from "@remix-run/node"; +import i18n from "~/i18next.server"; +import { json, useActionData, useNavigate, useRouteLoaderData } from "@remix-run/react"; +import { loader as settingsLoader } from "~/routes/settings/route"; +import { useTranslation } from "react-i18next"; +import { useEffect } from "react"; +import { Button, Card, Form } from "react-bootstrap"; +import { Form as RemixForm } from "@remix-run/react/dist/components"; +import { ApiError, ErrorCode } from "~/lib/api/error"; +import { fastRequest, getToken } from "~/lib/request.server"; +import ErrorAlert from "~/components/ErrorAlert"; + +export const meta: MetaFunction = ({ data }) => { + return [{ title: `${data?.meta.title || "Authentication"} • pronouns.cc` }]; +}; + +export const loader = async ({ request }: LoaderFunctionArgs) => { + const t = await i18n.getFixedT(request); + return { meta: { title: t("settings.auth.title") } }; +}; + +export const action = async ({ request }: ActionFunctionArgs) => { + const token = getToken(request)!; + const body = await request.formData(); + const email = body.get("email") as string | null; + const password = body.get("password-1") as string | null; + const password2 = body.get("password-2") as string | null; + + if (!email || !password || !password2) { + return json({ + error: { + status: 400, + code: ErrorCode.BadRequest, + message: "One or more required fields are missing.", + } as ApiError, + ok: false, + }); + } + + if (password !== password2) { + return json({ + error: { + status: 400, + code: ErrorCode.BadRequest, + message: "Passwords do not match.", + } as ApiError, + ok: false, + }); + } + + await fastRequest("POST", "/auth/email/add", { + body: { email, password }, + token, + isInternal: true, + }); + + return json({ error: null, ok: true }); +}; + +export default function AddEmailPage() { + const { t } = useTranslation(); + const { user } = useRouteLoaderData("routes/settings")!; + const actionData = useActionData(); + const navigate = useNavigate(); + const emails = user.auth_methods.filter((m) => m.type === "EMAIL"); + + useEffect(() => { + if (emails.length >= 3) { + navigate("/settings/auth"); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + + + + {emails.length === 0 + ? t("settings.auth.form.add-first-email") + : t("settings.auth.form.add-extra-email")} + + {emails.length === 0 && !actionData?.ok &&

{t("settings.auth.no-email")}

} + {actionData?.ok &&

{t("settings.auth.new-email-pending")}

} + {actionData?.error && } +
+ + {t("settings.auth.form.email-address")} + + + + {t("settings.auth.form.password-1")} + + + + {t("settings.auth.form.password-2")} + + + +
+
+
+ ); +} diff --git a/Foxnouns.Frontend/app/routes/settings.auth_.confirm-email.$code/route.tsx b/Foxnouns.Frontend/app/routes/settings.auth_.confirm-email.$code/route.tsx new file mode 100644 index 0000000..d7fa86e --- /dev/null +++ b/Foxnouns.Frontend/app/routes/settings.auth_.confirm-email.$code/route.tsx @@ -0,0 +1,38 @@ +import { LoaderFunctionArgs, json } from "@remix-run/node"; +import { baseRequest } from "~/lib/request.server"; +import { useTranslation } from "react-i18next"; +import { useEffect } from "react"; +import { useNavigate } from "@remix-run/react"; + +export const loader = async ({ params }: LoaderFunctionArgs) => { + const state = params.code!; + + const resp = await baseRequest("POST", "/auth/email/callback", { + body: { state }, + isInternal: true, + }); + if (resp.status !== 204) { + // TODO: handle non-204 status (this indicates that the email was not linked to an account) + } + + return json({ ok: true }); +}; + +export default function ConfirmEmailPage() { + const { t } = useTranslation(); + const navigate = useNavigate(); + + useEffect(() => { + setTimeout(() => { + navigate("/settings/auth"); + }, 2000); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( + <> +

{t("settings.auth.email-link-success")}

+

{t("settings.auth.redirect-to-auth-hint")}

+ + ); +} diff --git a/Foxnouns.Frontend/public/locales/en.json b/Foxnouns.Frontend/public/locales/en.json index c35a1d7..5a25098 100644 --- a/Foxnouns.Frontend/public/locales/en.json +++ b/Foxnouns.Frontend/public/locales/en.json @@ -107,6 +107,23 @@ "role": "Account role", "username-update-error": "Could not update your username as the new username is invalid:\n{{message}}" }, + "auth": { + "title": "Authentication", + "form": { + "add-first-email": "Set an email address", + "add-extra-email": "Add another email address", + "email-address": "Email address", + "password-1": "Password", + "password-2": "Confirm password", + "add-email-button": "Add email address" + }, + "no-email": "You haven't linked any email addresses yet. You can add one using this form.", + "new-email-pending": "Email address added! Click the link in your inbox to confirm.", + "email-link-success": "Email successfully linked", + "redirect-to-auth-hint": "You will be redirected back to the authentication page in a few seconds.", + "email-addresses": "Email addresses", + "remove-auth-method": "Remove" + }, "title": "Settings", "nav": { "general-information": "General information",