feat: remove auth method
This commit is contained in:
		
							parent
							
								
									201c56c3dd
								
							
						
					
					
						commit
						9160281ea2
					
				
					 7 changed files with 144 additions and 16 deletions
				
			
		|  | @ -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<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( | ||||
|  |  | |||
|  | @ -148,6 +148,7 @@ public enum ErrorCode | |||
|     UserNotFound, | ||||
|     MemberNotFound, | ||||
|     AccountAlreadyLinked, | ||||
|     LastAuthMethod, | ||||
| } | ||||
| 
 | ||||
| public class ValidationError | ||||
|  |  | |||
|  | @ -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<MemberRendererService.PartialMember>? Members, | ||||
|         [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] | ||||
|             IEnumerable<AuthenticationMethodResponse>? AuthMethods, | ||||
|             IEnumerable<AuthMethodResponse>? 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, | ||||
|  |  | |||
|  | @ -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"); | ||||
|  |  | |||
|  | @ -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 = { | ||||
|  |  | |||
|  | @ -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> | ||||
| 		</> | ||||
| 	); | ||||
| } | ||||
|  | @ -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" | ||||
| 		}, | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue