diff --git a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs index a634eb2..abb403c 100644 --- a/Foxnouns.Backend/Controllers/Authentication/AuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/AuthController.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Web; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; @@ -15,7 +16,7 @@ namespace Foxnouns.Backend.Controllers.Authentication; public class AuthController( Config config, DatabaseContext db, - KeyCacheService keyCache, + KeyCacheService keyCacheService, ILogger logger ) : ApiControllerBase { @@ -31,7 +32,7 @@ public class AuthController( config.GoogleAuth.Enabled, config.TumblrAuth.Enabled ); - var state = HttpUtility.UrlEncode(await keyCache.GenerateAuthStateAsync(ct)); + var state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct)); string? discord = null; if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) discord = @@ -75,6 +76,52 @@ public class AuthController( return NoContent(); } + + [HttpGet("methods/{id}")] + [Authorize("*")] + [ProducesResponseType( + statusCode: StatusCodes.Status200OK + )] + public async Task GetAuthMethodAsync(Snowflake id) + { + var authMethod = await db + .AuthMethods.Include(a => a.FediverseApplication) + .FirstOrDefaultAsync(a => a.UserId == CurrentUser!.Id && a.Id == id); + if (authMethod == null) + throw new ApiError.NotFound("No authentication method with that ID found."); + + return Ok(UserRendererService.RenderAuthMethod(authMethod)); + } + + [HttpDelete("methods/{id}")] + [Authorize("*")] + public async Task DeleteAuthMethodAsync(Snowflake id) + { + var authMethods = await db + .AuthMethods.Where(a => a.UserId == CurrentUser!.Id) + .ToListAsync(); + if (authMethods.Count < 2) + throw new ApiError( + "You cannot remove your last authentication method.", + HttpStatusCode.BadRequest, + ErrorCode.LastAuthMethod + ); + + var authMethod = authMethods.FirstOrDefault(a => a.Id == id); + if (authMethod == null) + throw new ApiError.NotFound("No authentication method with that ID found."); + + _logger.Debug( + "Deleting auth method {AuthMethodId} for user {UserId}", + authMethod.Id, + CurrentUser!.Id + ); + + db.Remove(authMethod); + await db.SaveChangesAsync(); + + return NoContent(); + } } public record CallbackResponse( diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs index e185d4e..9d30a43 100644 --- a/Foxnouns.Backend/ExpectedError.cs +++ b/Foxnouns.Backend/ExpectedError.cs @@ -148,6 +148,7 @@ public enum ErrorCode UserNotFound, MemberNotFound, AccountAlreadyLinked, + LastAuthMethod, } public class ValidationError diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index 145de1a..c6f9e5b 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -67,22 +67,23 @@ public class UserRendererService( renderMembers ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) : null, - renderAuthMethods - ? authMethods.Select(a => new AuthenticationMethodResponse( - a.Id, - a.AuthType, - a.RemoteId, - a.FediverseApplication != null - ? $"@{a.RemoteUsername}@{a.FediverseApplication.Domain}" - : a.RemoteUsername - )) - : null, + renderAuthMethods ? authMethods.Select(RenderAuthMethod) : null, tokenHidden ? user.ListHidden : null, tokenHidden ? user.LastActive : null, tokenHidden ? user.LastSidReroll : null ); } + public static AuthMethodResponse RenderAuthMethod(AuthMethod a) => + new( + a.Id, + a.AuthType, + a.RemoteId, + a.FediverseApplication != null + ? $"@{a.RemoteUsername}@{a.FediverseApplication.Domain}" + : a.RemoteUsername + ); + public PartialUser RenderPartialUser(User user) => new( user.Id, @@ -118,7 +119,7 @@ public class UserRendererService( [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] IEnumerable? Members, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - IEnumerable? AuthMethods, + IEnumerable? AuthMethods, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? MemberListHidden, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, @@ -126,7 +127,7 @@ public class UserRendererService( Instant? LastSidReroll ); - public record AuthenticationMethodResponse( + public record AuthMethodResponse( Snowflake Id, [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, string RemoteId, diff --git a/Foxnouns.Frontend/app/components/ErrorAlert.tsx b/Foxnouns.Frontend/app/components/ErrorAlert.tsx index be470a4..68cfc75 100644 --- a/Foxnouns.Frontend/app/components/ErrorAlert.tsx +++ b/Foxnouns.Frontend/app/components/ErrorAlert.tsx @@ -142,6 +142,8 @@ export const errorCodeDesc = (t: TFunction, code: ErrorCode) => { return t("error.errors.user-not-found"); case ErrorCode.AccountAlreadyLinked: return t("error.errors.account-already-linked"); + case ErrorCode.LastAuthMetod: + return t("error.errors.last-auth-method"); } return t("error.errors.generic-error"); diff --git a/Foxnouns.Frontend/app/lib/api/error.ts b/Foxnouns.Frontend/app/lib/api/error.ts index 3f33566..1f0ee6b 100644 --- a/Foxnouns.Frontend/app/lib/api/error.ts +++ b/Foxnouns.Frontend/app/lib/api/error.ts @@ -17,6 +17,7 @@ export enum ErrorCode { UserNotFound = "USER_NOT_FOUND", MemberNotFound = "MEMBER_NOT_FOUND", AccountAlreadyLinked = "ACCOUNT_ALREADY_LINKED", + LastAuthMetod = "LAST_AUTH_METHOD", } export type ValidationError = { diff --git a/Foxnouns.Frontend/app/routes/settings.auth_.remove-method.$id/route.tsx b/Foxnouns.Frontend/app/routes/settings.auth_.remove-method.$id/route.tsx new file mode 100644 index 0000000..922f28d --- /dev/null +++ b/Foxnouns.Frontend/app/routes/settings.auth_.remove-method.$id/route.tsx @@ -0,0 +1,73 @@ +import { ActionFunctionArgs, json, LoaderFunctionArgs, redirect } from "@remix-run/node"; +import i18n from "~/i18next.server"; +import serverRequest, { fastRequest, getToken } from "~/lib/request.server"; +import { AuthMethod } from "~/lib/api/user"; +import { useTranslation } from "react-i18next"; +import { useLoaderData, Form } from "@remix-run/react"; +import { Button } from "react-bootstrap"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const data = await request.formData(); + const token = getToken(request); + + const id = data.get("remove-id") as string; + + await fastRequest("DELETE", `/auth/methods/${id}`, { token, isInternal: true }); + + return redirect("/settings/auth", 303); +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const t = await i18n.getFixedT(request); + const token = getToken(request); + + const method = await serverRequest("GET", `/auth/methods/${params.id}`, { + token, + isInternal: true, + }); + return json({ method, meta: { title: t("settings.auth.remove-auth-method-title") } }); +}; + +export default function RemoveAuthMethodPage() { + const { t } = useTranslation(); + const { method } = useLoaderData(); + + let methodName; + switch (method.type) { + case "EMAIL": + methodName = "email"; + break; + case "DISCORD": + methodName = "Discord"; + break; + case "FEDIVERSE": + methodName = "Fediverse"; + break; + case "GOOGLE": + methodName = "Google"; + break; + case "TUMBLR": + methodName = "Tumblr"; + break; + } + + return ( + <> +

{t("settings.auth.remove-auth-method-title")}

+

+ {t("settings.auth.remove-auth-method-hint", { + username: method.remote_username || method.remote_id, + methodName, + })} +

+

+

+ + +
+

+ + ); +} diff --git a/Foxnouns.Frontend/public/locales/en.json b/Foxnouns.Frontend/public/locales/en.json index c7c1779..a9aa060 100644 --- a/Foxnouns.Frontend/public/locales/en.json +++ b/Foxnouns.Frontend/public/locales/en.json @@ -17,7 +17,8 @@ "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.", "user-not-found": "User not found, please check your spelling and try again.", - "account-already-linked": "This account is already linked with a pronouns.cc account." + "account-already-linked": "This account is already linked with a pronouns.cc account.", + "last-auth-method": "You cannot remove your last authentication method." }, "title": "An error occurred", "more-info": "Click here for a more detailed error" @@ -141,8 +142,10 @@ "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-title": "Remove authentication method", + "remove-auth-method-hint": "Are you sure you want to remove {{username}} ({{methodName}}) from your account? You will no longer be able to log in using it.", "remove-auth-method": "Remove", + "email-addresses": "Email addresses", "discord-accounts": "Linked Discord accounts", "fediverse-accounts": "Linked Fediverse accounts" },