feat: remove auth method

This commit is contained in:
sam 2024-11-04 22:04:04 +01:00
parent 201c56c3dd
commit 9160281ea2
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
7 changed files with 144 additions and 16 deletions

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Web; using System.Web;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
@ -15,7 +16,7 @@ namespace Foxnouns.Backend.Controllers.Authentication;
public class AuthController( public class AuthController(
Config config, Config config,
DatabaseContext db, DatabaseContext db,
KeyCacheService keyCache, KeyCacheService keyCacheService,
ILogger logger ILogger logger
) : ApiControllerBase ) : ApiControllerBase
{ {
@ -31,7 +32,7 @@ public class AuthController(
config.GoogleAuth.Enabled, config.GoogleAuth.Enabled,
config.TumblrAuth.Enabled config.TumblrAuth.Enabled
); );
var state = HttpUtility.UrlEncode(await keyCache.GenerateAuthStateAsync(ct)); var state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct));
string? discord = null; string? discord = null;
if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null })
discord = discord =
@ -75,6 +76,52 @@ public class AuthController(
return NoContent(); return NoContent();
} }
[HttpGet("methods/{id}")]
[Authorize("*")]
[ProducesResponseType<UserRendererService.AuthMethodResponse>(
statusCode: StatusCodes.Status200OK
)]
public async Task<IActionResult> 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<IActionResult> 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( public record CallbackResponse(

View file

@ -148,6 +148,7 @@ public enum ErrorCode
UserNotFound, UserNotFound,
MemberNotFound, MemberNotFound,
AccountAlreadyLinked, AccountAlreadyLinked,
LastAuthMethod,
} }
public class ValidationError public class ValidationError

View file

@ -67,22 +67,23 @@ public class UserRendererService(
renderMembers renderMembers
? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden))
: null, : null,
renderAuthMethods renderAuthMethods ? authMethods.Select(RenderAuthMethod) : null,
? authMethods.Select(a => new AuthenticationMethodResponse(
a.Id,
a.AuthType,
a.RemoteId,
a.FediverseApplication != null
? $"@{a.RemoteUsername}@{a.FediverseApplication.Domain}"
: a.RemoteUsername
))
: null,
tokenHidden ? user.ListHidden : null, tokenHidden ? user.ListHidden : null,
tokenHidden ? user.LastActive : null, tokenHidden ? user.LastActive : null,
tokenHidden ? user.LastSidReroll : 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) => public PartialUser RenderPartialUser(User user) =>
new( new(
user.Id, user.Id,
@ -118,7 +119,7 @@ public class UserRendererService(
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
IEnumerable<MemberRendererService.PartialMember>? Members, IEnumerable<MemberRendererService.PartialMember>? Members,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
IEnumerable<AuthenticationMethodResponse>? AuthMethods, IEnumerable<AuthMethodResponse>? AuthMethods,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
bool? MemberListHidden, bool? MemberListHidden,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive,
@ -126,7 +127,7 @@ public class UserRendererService(
Instant? LastSidReroll Instant? LastSidReroll
); );
public record AuthenticationMethodResponse( public record AuthMethodResponse(
Snowflake Id, Snowflake Id,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
string RemoteId, string RemoteId,

View file

@ -142,6 +142,8 @@ export const errorCodeDesc = (t: TFunction, code: ErrorCode) => {
return t("error.errors.user-not-found"); return t("error.errors.user-not-found");
case ErrorCode.AccountAlreadyLinked: case ErrorCode.AccountAlreadyLinked:
return t("error.errors.account-already-linked"); return t("error.errors.account-already-linked");
case ErrorCode.LastAuthMetod:
return t("error.errors.last-auth-method");
} }
return t("error.errors.generic-error"); return t("error.errors.generic-error");

View file

@ -17,6 +17,7 @@ export enum ErrorCode {
UserNotFound = "USER_NOT_FOUND", UserNotFound = "USER_NOT_FOUND",
MemberNotFound = "MEMBER_NOT_FOUND", MemberNotFound = "MEMBER_NOT_FOUND",
AccountAlreadyLinked = "ACCOUNT_ALREADY_LINKED", AccountAlreadyLinked = "ACCOUNT_ALREADY_LINKED",
LastAuthMetod = "LAST_AUTH_METHOD",
} }
export type ValidationError = { export type ValidationError = {

View file

@ -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<AuthMethod>("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<typeof loader>();
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 (
<>
<h3>{t("settings.auth.remove-auth-method-title")}</h3>
<p>
{t("settings.auth.remove-auth-method-hint", {
username: method.remote_username || method.remote_id,
methodName,
})}
</p>
<p>
<Form method="POST">
<input type="hidden" name="remove-id" value={method.id} />
<Button type="submit" color="primary">
{t("settings.auth.remove-auth-method")}
</Button>
</Form>
</p>
</>
);
}

View file

@ -17,7 +17,8 @@
"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.",
"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", "title": "An error occurred",
"more-info": "Click here for a more detailed error" "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.", "new-email-pending": "Email address added! Click the link in your inbox to confirm.",
"email-link-success": "Email successfully linked", "email-link-success": "Email successfully linked",
"redirect-to-auth-hint": "You will be redirected back to the authentication page in a few seconds.", "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", "remove-auth-method": "Remove",
"email-addresses": "Email addresses",
"discord-accounts": "Linked Discord accounts", "discord-accounts": "Linked Discord accounts",
"fediverse-accounts": "Linked Fediverse accounts" "fediverse-accounts": "Linked Fediverse accounts"
}, },