feat(frontend): register/log in with email
This commit is contained in:
		
							parent
							
								
									57e1ec09c0
								
							
						
					
					
						commit
						bc7fd6d804
					
				
					 19 changed files with 598 additions and 380 deletions
				
			
		|  | @ -119,6 +119,20 @@ public class AuthController( | |||
|             CurrentUser!.Id | ||||
|         ); | ||||
| 
 | ||||
|         // If this is the user's last email, we should also clear the user's password. | ||||
|         if ( | ||||
|             authMethod.AuthType == AuthType.Email | ||||
|             && authMethods.Count(a => a.AuthType == AuthType.Email) == 1 | ||||
|         ) | ||||
|         { | ||||
|             _logger.Debug( | ||||
|                 "Deleted last email address for user {UserId}, resetting their password", | ||||
|                 CurrentUser.Id | ||||
|             ); | ||||
|             CurrentUser.Password = null; | ||||
|             db.Update(CurrentUser); | ||||
|         } | ||||
| 
 | ||||
|         db.Remove(authMethod); | ||||
|         await db.SaveChangesAsync(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -1,3 +1,5 @@ | |||
| using System.Net; | ||||
| using EntityFramework.Exceptions.Common; | ||||
| using Foxnouns.Backend.Database; | ||||
| using Foxnouns.Backend.Database.Models; | ||||
| using Foxnouns.Backend.Extensions; | ||||
|  | @ -26,8 +28,8 @@ public class EmailAuthController( | |||
| { | ||||
|     private readonly ILogger _logger = logger.ForContext<EmailAuthController>(); | ||||
| 
 | ||||
|     [HttpPost("register")] | ||||
|     public async Task<IActionResult> RegisterAsync( | ||||
|     [HttpPost("register/init")] | ||||
|     public async Task<IActionResult> RegisterInitAsync( | ||||
|         [FromBody] RegisterRequest req, | ||||
|         CancellationToken ct = default | ||||
|     ) | ||||
|  | @ -62,25 +64,9 @@ public class EmailAuthController( | |||
|         CheckRequirements(); | ||||
| 
 | ||||
|         var state = await keyCacheService.GetRegisterEmailStateAsync(req.State); | ||||
|         if (state == null) | ||||
|         if (state is not { ExistingUserId: null }) | ||||
|             throw new ApiError.BadRequest("Invalid state", "state", req.State); | ||||
| 
 | ||||
|         // If this callback is for an existing user, add the email address to their auth methods | ||||
|         if (state.ExistingUserId != null) | ||||
|         { | ||||
|             var authMethod = await authService.AddAuthMethodAsync( | ||||
|                 state.ExistingUserId.Value, | ||||
|                 AuthType.Email, | ||||
|                 state.Email | ||||
|             ); | ||||
|             _logger.Debug( | ||||
|                 "Added email auth {AuthId} for user {UserId}", | ||||
|                 authMethod.Id, | ||||
|                 state.ExistingUserId | ||||
|             ); | ||||
|             return NoContent(); | ||||
|         } | ||||
| 
 | ||||
|         var ticket = AuthUtils.RandomToken(); | ||||
|         await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); | ||||
| 
 | ||||
|  | @ -96,7 +82,7 @@ public class EmailAuthController( | |||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     [HttpPost("complete-registration")] | ||||
|     [HttpPost("register")] | ||||
|     public async Task<IActionResult> CompleteRegistrationAsync( | ||||
|         [FromBody] CompleteRegistrationRequest req | ||||
|     ) | ||||
|  | @ -107,12 +93,6 @@ public class EmailAuthController( | |||
|         if (email == null) | ||||
|             throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket); | ||||
| 
 | ||||
|         // Check if username is valid at all | ||||
|         ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(req.Username))]); | ||||
|         // Check if username is already taken | ||||
|         if (await db.Users.AnyAsync(u => u.Username == req.Username)) | ||||
|             throw new ApiError.BadRequest("Username is already taken", "username", req.Username); | ||||
| 
 | ||||
|         var user = await authService.CreateUserWithPasswordAsync(req.Username, email, req.Password); | ||||
|         var frontendApp = await db.GetFrontendApplicationAsync(); | ||||
| 
 | ||||
|  | @ -184,7 +164,21 @@ public class EmailAuthController( | |||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     [HttpPost("add")] | ||||
|     [HttpPost("change-password")] | ||||
|     [Authorize("*")] | ||||
|     public async Task<IActionResult> UpdatePasswordAsync([FromBody] ChangePasswordRequest req) | ||||
|     { | ||||
|         if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Current)) | ||||
|             throw new ApiError.Forbidden("Invalid password"); | ||||
| 
 | ||||
|         ValidationUtils.Validate([("new", ValidationUtils.ValidatePassword(req.New))]); | ||||
| 
 | ||||
|         await authService.SetUserPasswordAsync(CurrentUser!, req.New); | ||||
|         await db.SaveChangesAsync(); | ||||
|         return NoContent(); | ||||
|     } | ||||
| 
 | ||||
|     [HttpPost("add-email")] | ||||
|     [Authorize("*")] | ||||
|     public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req) | ||||
|     { | ||||
|  | @ -204,12 +198,9 @@ public class EmailAuthController( | |||
| 
 | ||||
|         if (emails.Count != 0) | ||||
|         { | ||||
|             var validPassword = await authService.ValidatePasswordAsync(CurrentUser!, req.Password); | ||||
|             if (!validPassword) | ||||
|             { | ||||
|             if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Password)) | ||||
|                 throw new ApiError.Forbidden("Invalid password"); | ||||
|         } | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             await authService.SetUserPasswordAsync(CurrentUser!, req.Password); | ||||
|  | @ -233,6 +224,48 @@ public class EmailAuthController( | |||
|         return NoContent(); | ||||
|     } | ||||
| 
 | ||||
|     [HttpPost("add-email/callback")] | ||||
|     [Authorize("*")] | ||||
|     public async Task<IActionResult> AddEmailCallbackAsync([FromBody] CallbackRequest req) | ||||
|     { | ||||
|         CheckRequirements(); | ||||
| 
 | ||||
|         var state = await keyCacheService.GetRegisterEmailStateAsync(req.State); | ||||
|         if (state?.ExistingUserId != CurrentUser!.Id) | ||||
|             throw new ApiError.BadRequest("Invalid state", "state", req.State); | ||||
| 
 | ||||
|         try | ||||
|         { | ||||
|             var authMethod = await authService.AddAuthMethodAsync( | ||||
|                 CurrentUser.Id, | ||||
|                 AuthType.Email, | ||||
|                 state.Email | ||||
|             ); | ||||
|             _logger.Debug( | ||||
|                 "Added email auth {AuthId} for user {UserId}", | ||||
|                 authMethod.Id, | ||||
|                 CurrentUser.Id | ||||
|             ); | ||||
| 
 | ||||
|             return Ok( | ||||
|                 new AuthController.AddOauthAccountResponse( | ||||
|                     authMethod.Id, | ||||
|                     AuthType.Email, | ||||
|                     authMethod.RemoteId, | ||||
|                     RemoteUsername: null | ||||
|                 ) | ||||
|             ); | ||||
|         } | ||||
|         catch (UniqueConstraintException) | ||||
|         { | ||||
|             throw new ApiError( | ||||
|                 "That email address is already linked.", | ||||
|                 HttpStatusCode.BadRequest, | ||||
|                 ErrorCode.AccountAlreadyLinked | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     public record AddEmailAddressRequest(string Email, string Password); | ||||
| 
 | ||||
|     private void CheckRequirements() | ||||
|  | @ -248,4 +281,6 @@ public class EmailAuthController( | |||
|     public record CompleteRegistrationRequest(string Ticket, string Username, string Password); | ||||
| 
 | ||||
|     public record CallbackRequest(string State); | ||||
| 
 | ||||
|     public record ChangePasswordRequest(string Current, string New); | ||||
| } | ||||
|  |  | |||
|  | @ -25,7 +25,7 @@ public static class KeyCacheExtensions | |||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     { | ||||
|         var val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", delete: true, ct); | ||||
|         var val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", ct: ct); | ||||
|         if (val == null) | ||||
|             throw new ApiError.BadRequest("Invalid OAuth state"); | ||||
|     } | ||||
|  | @ -52,12 +52,7 @@ public static class KeyCacheExtensions | |||
|         this KeyCacheService keyCacheService, | ||||
|         string state, | ||||
|         CancellationToken ct = default | ||||
|     ) => | ||||
|         await keyCacheService.GetKeyAsync<RegisterEmailState>( | ||||
|             $"email_state:{state}", | ||||
|             delete: true, | ||||
|             ct | ||||
|         ); | ||||
|     ) => await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}", ct: ct); | ||||
| 
 | ||||
|     public static async Task<string> GenerateAddExtraAccountStateAsync( | ||||
|         this KeyCacheService keyCacheService, | ||||
|  |  | |||
|  | @ -31,6 +31,16 @@ public class AuthService( | |||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     { | ||||
|         // Validate username and whether it's not taken | ||||
|         ValidationUtils.Validate( | ||||
|             [ | ||||
|                 ("username", ValidationUtils.ValidateUsername(username)), | ||||
|                 ("password", ValidationUtils.ValidatePassword(password)), | ||||
|             ] | ||||
|         ); | ||||
|         if (await db.Users.AnyAsync(u => u.Username == username, ct)) | ||||
|             throw new ApiError.BadRequest("Username is already taken", "username", username); | ||||
| 
 | ||||
|         var user = new User | ||||
|         { | ||||
|             Id = snowflakeGenerator.GenerateSnowflake(), | ||||
|  | @ -49,7 +59,7 @@ public class AuthService( | |||
|         }; | ||||
| 
 | ||||
|         db.Add(user); | ||||
|         user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); | ||||
|         user.Password = await HashPasswordAsync(user, password, ct); | ||||
| 
 | ||||
|         return user; | ||||
|     } | ||||
|  | @ -70,6 +80,8 @@ public class AuthService( | |||
|     { | ||||
|         AssertValidAuthType(authType, instance); | ||||
| 
 | ||||
|         // Validate username and whether it's not taken | ||||
|         ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(username))]); | ||||
|         if (await db.Users.AnyAsync(u => u.Username == username, ct)) | ||||
|             throw new ApiError.BadRequest("Username is already taken", "username", username); | ||||
| 
 | ||||
|  | @ -121,10 +133,7 @@ public class AuthService( | |||
|                 ErrorCode.UserNotFound | ||||
|             ); | ||||
| 
 | ||||
|         var pwResult = await Task.Run( | ||||
|             () => _passwordHasher.VerifyHashedPassword(user, user.Password!, password), | ||||
|             ct | ||||
|         ); | ||||
|         var pwResult = await VerifyHashedPasswordAsync(user, password, ct); | ||||
|         if (pwResult == PasswordVerificationResult.Failed) // TODO: this seems to fail on some valid passwords? | ||||
|             throw new ApiError.NotFound( | ||||
|                 "No user with that email address found, or password is incorrect", | ||||
|  | @ -132,7 +141,7 @@ public class AuthService( | |||
|             ); | ||||
|         if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) | ||||
|         { | ||||
|             user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); | ||||
|             user.Password = await HashPasswordAsync(user, password, ct); | ||||
|             await db.SaveChangesAsync(ct); | ||||
|         } | ||||
| 
 | ||||
|  | @ -160,10 +169,7 @@ public class AuthService( | |||
|             throw new FoxnounsError("Password for user supplied to ValidatePasswordAsync was null"); | ||||
|         } | ||||
| 
 | ||||
|         var pwResult = await Task.Run( | ||||
|             () => _passwordHasher.VerifyHashedPassword(user, user.Password!, password), | ||||
|             ct | ||||
|         ); | ||||
|         var pwResult = await VerifyHashedPasswordAsync(user, password, ct); | ||||
|         return pwResult | ||||
|             is PasswordVerificationResult.SuccessRehashNeeded | ||||
|                 or PasswordVerificationResult.Success; | ||||
|  | @ -178,7 +184,7 @@ public class AuthService( | |||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     { | ||||
|         user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); | ||||
|         user.Password = await HashPasswordAsync(user, password, ct); | ||||
|         db.Update(user); | ||||
|     } | ||||
| 
 | ||||
|  | @ -316,6 +322,22 @@ public class AuthService( | |||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     private Task<string> HashPasswordAsync( | ||||
|         User user, | ||||
|         string password, | ||||
|         CancellationToken ct = default | ||||
|     ) => Task.Run(() => _passwordHasher.HashPassword(user, password), ct); | ||||
| 
 | ||||
|     private Task<PasswordVerificationResult> VerifyHashedPasswordAsync( | ||||
|         User user, | ||||
|         string providedPassword, | ||||
|         CancellationToken ct = default | ||||
|     ) => | ||||
|         Task.Run( | ||||
|             () => _passwordHasher.VerifyHashedPassword(user, user.Password!, providedPassword), | ||||
|             ct | ||||
|         ); | ||||
| 
 | ||||
|     private static (string, byte[]) GenerateToken() | ||||
|     { | ||||
|         var token = AuthUtils.RandomToken(); | ||||
|  |  | |||
|  | @ -185,6 +185,27 @@ public static partial class ValidationUtils | |||
|         }; | ||||
|     } | ||||
| 
 | ||||
|     public const int MinimumPasswordLength = 12; | ||||
|     public const int MaximumPasswordLength = 1024; | ||||
| 
 | ||||
|     public static ValidationError? ValidatePassword(string password) => | ||||
|         password.Length switch | ||||
|         { | ||||
|             < MinimumPasswordLength => ValidationError.LengthError( | ||||
|                 "Password is too short", | ||||
|                 MinimumPasswordLength, | ||||
|                 MaximumPasswordLength, | ||||
|                 password.Length | ||||
|             ), | ||||
|             > MaximumPasswordLength => ValidationError.LengthError( | ||||
|                 "Password is too long", | ||||
|                 MinimumPasswordLength, | ||||
|                 MaximumPasswordLength, | ||||
|                 password.Length | ||||
|             ), | ||||
|             _ => null, | ||||
|         }; | ||||
| 
 | ||||
|     [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
 | ||||
|     private static partial Regex UsernameRegex(); | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,9 +2,9 @@ | |||
| 
 | ||||
| <p> | ||||
|     Please continue creating a new pronouns.cc account by using the following link: | ||||
|     <br/> | ||||
|     <a href="@Model.BaseUrl/auth/signup/confirm/@Model.Code">Confirm your email address</a> | ||||
|     <br/> | ||||
|     <br /> | ||||
|     <a href="@Model.BaseUrl/auth/callback/email/@Model.Code">Confirm your email address</a> | ||||
|     <br /> | ||||
|     Note that this link will expire in one hour. | ||||
| </p> | ||||
| <p> | ||||
|  |  | |||
|  | @ -2,9 +2,9 @@ | |||
| 
 | ||||
| <p> | ||||
|     Hello @@@Model.Username, please confirm adding this email address to your account by using the following link: | ||||
|     <br/> | ||||
|     <a href="@Model.BaseUrl/settings/auth/confirm-email/@Model.Code">Confirm your email address</a> | ||||
|     <br/> | ||||
|     <br /> | ||||
|     <a href="@Model.BaseUrl/auth/callback/email/@Model.Code">Confirm your email address</a> | ||||
|     <br /> | ||||
|     Note that this link will expire in one hour. | ||||
| </p> | ||||
| <p> | ||||
|  |  | |||
							
								
								
									
										69
									
								
								Foxnouns.Frontend/src/lib/actions/callback.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										69
									
								
								Foxnouns.Frontend/src/lib/actions/callback.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,69 @@ | |||
| import { apiRequest } from "$api"; | ||||
| import ApiError, { ErrorCode } from "$api/error"; | ||||
| import type { AddAccountResponse, CallbackResponse } from "$api/models"; | ||||
| import { setToken } from "$lib"; | ||||
| import log from "$lib/log"; | ||||
| import { isRedirect, redirect, type ServerLoadEvent } from "@sveltejs/kit"; | ||||
| 
 | ||||
| export default function createCallbackLoader( | ||||
| 	callbackType: string, | ||||
| 	bodyFn?: (event: ServerLoadEvent) => Promise<unknown>, | ||||
| ) { | ||||
| 	return async (event: ServerLoadEvent) => { | ||||
| 		const { url, parent, fetch, cookies } = event; | ||||
| 
 | ||||
| 		bodyFn ??= async ({ url }) => { | ||||
| 			const code = url.searchParams.get("code") as string | null; | ||||
| 			const state = url.searchParams.get("state") as string | null; | ||||
| 			if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj; | ||||
| 			return { code, state }; | ||||
| 		}; | ||||
| 
 | ||||
| 		const { meUser } = await parent(); | ||||
| 		if (meUser) { | ||||
| 			try { | ||||
| 				const resp = await apiRequest<AddAccountResponse>( | ||||
| 					"POST", | ||||
| 					`/auth/${callbackType}/add-account/callback`, | ||||
| 					{ | ||||
| 						isInternal: true, | ||||
| 						body: await bodyFn(event), | ||||
| 						fetch, | ||||
| 						cookies, | ||||
| 					}, | ||||
| 				); | ||||
| 
 | ||||
| 				return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp }; | ||||
| 			} catch (e) { | ||||
| 				if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj }; | ||||
| 				log.error("error linking new %s account to user %s:", callbackType, meUser.id, e); | ||||
| 				throw e; | ||||
| 			} | ||||
| 		} | ||||
| 
 | ||||
| 		try { | ||||
| 			const resp = await apiRequest<CallbackResponse>("POST", `/auth/${callbackType}/callback`, { | ||||
| 				body: await bodyFn(event), | ||||
| 				isInternal: true, | ||||
| 				fetch, | ||||
| 			}); | ||||
| 
 | ||||
| 			if (resp.has_account) { | ||||
| 				setToken(cookies, resp.token!); | ||||
| 				redirect(303, `/@${resp.user!.username}`); | ||||
| 			} | ||||
| 
 | ||||
| 			return { | ||||
| 				hasAccount: false, | ||||
| 				isLinkRequest: false, | ||||
| 				ticket: resp.ticket!, | ||||
| 				remoteUser: resp.remote_username!, | ||||
| 			}; | ||||
| 		} catch (e) { | ||||
| 			if (isRedirect(e)) throw e; | ||||
| 			if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj }; | ||||
| 			log.error("error while requesting %s callback:", callbackType, e); | ||||
| 			throw e; | ||||
| 		} | ||||
| 	}; | ||||
| } | ||||
|  | @ -4,8 +4,8 @@ | |||
| 	import type { RawApiError } from "$api/error"; | ||||
| 	import ErrorAlert from "$components/ErrorAlert.svelte"; | ||||
| 
 | ||||
| 	type Props = { form: { error: RawApiError | null; ok: boolean } | null }; | ||||
| 	let { form }: Props = $props(); | ||||
| 	type Props = { form: { error: RawApiError | null; ok: boolean } | null; successMessage?: string }; | ||||
| 	let { form, successMessage }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| {#if form?.error} | ||||
|  | @ -13,6 +13,6 @@ | |||
| {:else if form?.ok} | ||||
| 	<p class="text-success-emphasis"> | ||||
| 		<Icon name="check-circle-fill" /> | ||||
| 		{$t("edit-profile.saved-changes")} | ||||
| 		{successMessage ?? $t("edit-profile.saved-changes")} | ||||
| 	</p> | ||||
| {/if} | ||||
|  |  | |||
|  | @ -1,6 +1,5 @@ | |||
| <script lang="ts"> | ||||
| 	import type { RawApiError } from "$api/error"; | ||||
| 	import { enhance } from "$app/forms"; | ||||
| 	import ErrorAlert from "$components/ErrorAlert.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import { Button, Input, Label } from "@sveltestrap/sveltestrap"; | ||||
|  | @ -21,7 +20,7 @@ | |||
| 	<ErrorAlert {error} /> | ||||
| {/if} | ||||
| 
 | ||||
| <form method="POST" use:enhance> | ||||
| <form method="POST"> | ||||
| 	<div class="mb-3"> | ||||
| 		<Label>{remoteLabel}</Label> | ||||
| 		<Input type="text" readonly value={remoteUser} /> | ||||
|  |  | |||
|  | @ -48,7 +48,11 @@ | |||
|     "successful-link-profile-hint": "You now can close this page, or go back to your profile:", | ||||
|     "successful-link-profile-link": "Go to your profile", | ||||
|     "remote-discord-account-label": "Your Discord account", | ||||
| 		"log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)" | ||||
|     "log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)", | ||||
|     "register-with-email": "Register with an email address", | ||||
|     "email-label": "Your email address", | ||||
|     "confirm-password-label": "Confirm password", | ||||
|     "register-with-email-init-success": "Success! An email has been sent to your inbox, please press the link there to continue." | ||||
|   }, | ||||
|   "error": { | ||||
|     "bad-request-header": "Something was wrong with your input", | ||||
|  |  | |||
|  | @ -1,63 +1,7 @@ | |||
| import { apiRequest } from "$api"; | ||||
| import ApiError, { ErrorCode } from "$api/error"; | ||||
| import type { AddAccountResponse, CallbackResponse } from "$api/models/auth"; | ||||
| import { setToken } from "$lib"; | ||||
| import createRegisterAction from "$lib/actions/register.js"; | ||||
| import log from "$lib/log.js"; | ||||
| import { isRedirect, redirect } from "@sveltejs/kit"; | ||||
| import createCallbackLoader from "$lib/actions/callback"; | ||||
| import createRegisterAction from "$lib/actions/register"; | ||||
| 
 | ||||
| export const load = async ({ url, parent, fetch, cookies }) => { | ||||
| 	const code = url.searchParams.get("code") as string | null; | ||||
| 	const state = url.searchParams.get("state") as string | null; | ||||
| 	if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj; | ||||
| 
 | ||||
| 	const { meUser } = await parent(); | ||||
| 	if (meUser) { | ||||
| 		try { | ||||
| 			const resp = await apiRequest<AddAccountResponse>( | ||||
| 				"POST", | ||||
| 				"/auth/discord/add-account/callback", | ||||
| 				{ | ||||
| 					isInternal: true, | ||||
| 					body: { code, state }, | ||||
| 					fetch, | ||||
| 					cookies, | ||||
| 				}, | ||||
| 			); | ||||
| 
 | ||||
| 			return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp }; | ||||
| 		} catch (e) { | ||||
| 			if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj }; | ||||
| 			log.error("error linking new discord account to user %s:", meUser.id, e); | ||||
| 			throw e; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	try { | ||||
| 		const resp = await apiRequest<CallbackResponse>("POST", "/auth/discord/callback", { | ||||
| 			body: { code, state }, | ||||
| 			isInternal: true, | ||||
| 			fetch, | ||||
| 		}); | ||||
| 
 | ||||
| 		if (resp.has_account) { | ||||
| 			setToken(cookies, resp.token!); | ||||
| 			redirect(303, `/@${resp.user!.username}`); | ||||
| 		} | ||||
| 
 | ||||
| 		return { | ||||
| 			hasAccount: false, | ||||
| 			isLinkRequest: false, | ||||
| 			ticket: resp.ticket!, | ||||
| 			remoteUser: resp.remote_username!, | ||||
| 		}; | ||||
| 	} catch (e) { | ||||
| 		if (isRedirect(e)) throw e; | ||||
| 		if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj }; | ||||
| 		log.error("error while requesting discord callback:", e); | ||||
| 		throw e; | ||||
| 	} | ||||
| }; | ||||
| export const load = createCallbackLoader("discord"); | ||||
| 
 | ||||
| export const actions = { | ||||
| 	default: createRegisterAction("/auth/discord/register"), | ||||
|  |  | |||
|  | @ -0,0 +1,53 @@ | |||
| import { apiRequest } from "$api"; | ||||
| import ApiError, { ErrorCode, type RawApiError } from "$api/error"; | ||||
| import type { AuthResponse } from "$api/models/auth"; | ||||
| import { setToken } from "$lib"; | ||||
| import createCallbackLoader from "$lib/actions/callback"; | ||||
| import log from "$lib/log"; | ||||
| import { redirect, isRedirect } from "@sveltejs/kit"; | ||||
| 
 | ||||
| export const load = createCallbackLoader("email", async ({ params }) => { | ||||
| 	log.info("params:", params, "code:", params.code); | ||||
| 
 | ||||
| 	return { state: params.code! }; | ||||
| }); | ||||
| 
 | ||||
| export const actions = { | ||||
| 	default: async ({ request, fetch, cookies }) => { | ||||
| 		const data = await request.formData(); | ||||
| 		const username = data.get("username") as string | null; | ||||
| 		const ticket = data.get("ticket") as string | null; | ||||
| 		const password = data.get("password") as string | null; | ||||
| 		const password2 = data.get("confirm-password") as string | null; | ||||
| 
 | ||||
| 		if (!username || !ticket || !password || !password2) | ||||
| 			return { | ||||
| 				error: { message: "Bad request", code: ErrorCode.BadRequest, status: 400 } as RawApiError, | ||||
| 			}; | ||||
| 
 | ||||
| 		if (password !== password2) | ||||
| 			return { | ||||
| 				error: { | ||||
| 					message: "Passwords do not match", | ||||
| 					code: ErrorCode.BadRequest, | ||||
| 					status: 400, | ||||
| 				} as RawApiError, | ||||
| 			}; | ||||
| 
 | ||||
| 		try { | ||||
| 			const resp = await apiRequest<AuthResponse>("POST", "/auth/email/register", { | ||||
| 				body: { username, ticket, password }, | ||||
| 				isInternal: true, | ||||
| 				fetch, | ||||
| 			}); | ||||
| 
 | ||||
| 			setToken(cookies, resp.token); | ||||
| 			redirect(303, "/auth/welcome"); | ||||
| 		} catch (e) { | ||||
| 			if (isRedirect(e)) throw e; | ||||
| 			log.error("Could not sign up user with username %s:", username, e); | ||||
| 			if (e instanceof ApiError) return { error: e.obj }; | ||||
| 			throw e; | ||||
| 		} | ||||
| 	}, | ||||
| }; | ||||
|  | @ -0,0 +1,51 @@ | |||
| <script lang="ts"> | ||||
| 	import Error from "$components/Error.svelte"; | ||||
| 	import ErrorAlert from "$components/ErrorAlert.svelte"; | ||||
| 	import NewAuthMethod from "$components/settings/NewAuthMethod.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import { Label, Input, Button } from "@sveltestrap/sveltestrap"; | ||||
| 	import type { ActionData, PageData } from "./$types"; | ||||
| 
 | ||||
| 	type Props = { data: PageData; form: ActionData }; | ||||
| 	let { data, form }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
| 	<title>{$t("auth.register-with-email")} • pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <div class="container"> | ||||
| 	{#if data.error} | ||||
| 		<h1>{$t("auth.register-with-email")}</h1> | ||||
| 		<Error error={data.error} /> | ||||
| 	{:else if data.isLinkRequest} | ||||
| 		<NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} /> | ||||
| 	{:else} | ||||
| 		<h1>{$t("auth.register-with-email")}</h1> | ||||
| 
 | ||||
| 		{#if form?.error} | ||||
| 			<ErrorAlert error={form.error} /> | ||||
| 		{/if} | ||||
| 
 | ||||
| 		<form method="POST"> | ||||
| 			<div class="mb-3"> | ||||
| 				<Label>{$t("auth.email-label")}</Label> | ||||
| 				<Input type="text" readonly value={data.remoteUser} /> | ||||
| 			</div> | ||||
| 			<div class="mb-3"> | ||||
| 				<Label>{$t("auth.register-username-label")}</Label> | ||||
| 				<Input type="text" name="username" required /> | ||||
| 			</div> | ||||
| 			<div class="mb-3"> | ||||
| 				<Label>{$t("auth.log-in-form-password-label")}</Label> | ||||
| 				<Input type="password" name="password" required /> | ||||
| 			</div> | ||||
| 			<div class="mb-3"> | ||||
| 				<Label>{$t("auth.confirm-password-label")}</Label> | ||||
| 				<Input type="password" name="confirm-password" required /> | ||||
| 			</div> | ||||
| 			<input type="hidden" name="ticket" value={data.ticket!} /> | ||||
| 			<Button color="primary" type="submit">{$t("auth.register-button")}</Button> | ||||
| 		</form> | ||||
| 	{/if} | ||||
| </div> | ||||
|  | @ -1,63 +1,14 @@ | |||
| import { apiRequest } from "$api"; | ||||
| import ApiError, { ErrorCode } from "$api/error"; | ||||
| import type { AddAccountResponse, CallbackResponse } from "$api/models/auth.js"; | ||||
| import { setToken } from "$lib"; | ||||
| import createRegisterAction from "$lib/actions/register.js"; | ||||
| import log from "$lib/log"; | ||||
| import { isRedirect, redirect } from "@sveltejs/kit"; | ||||
| import createCallbackLoader from "$lib/actions/callback"; | ||||
| import createRegisterAction from "$lib/actions/register"; | ||||
| 
 | ||||
| export const load = async ({ parent, params, url, fetch, cookies }) => { | ||||
| export const load = createCallbackLoader("fediverse", async ({ params, url }) => { | ||||
| 	const code = url.searchParams.get("code") as string | null; | ||||
| 	const state = url.searchParams.get("state") as string | null; | ||||
| 	if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj; | ||||
| 
 | ||||
| 	const { meUser } = await parent(); | ||||
| 	if (meUser) { | ||||
| 		try { | ||||
| 			const resp = await apiRequest<AddAccountResponse>( | ||||
| 				"POST", | ||||
| 				"/auth/fediverse/add-account/callback", | ||||
| 				{ | ||||
| 					isInternal: true, | ||||
| 					body: { code, state, instance: params.instance }, | ||||
| 					fetch, | ||||
| 					cookies, | ||||
| 				}, | ||||
| 			); | ||||
| 
 | ||||
| 			return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp }; | ||||
| 		} catch (e) { | ||||
| 			if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj }; | ||||
| 			log.error("error linking new fediverse account to user %s:", meUser.id, e); | ||||
| 			throw e; | ||||
| 		} | ||||
| 	} | ||||
| 
 | ||||
| 	try { | ||||
| 		const resp = await apiRequest<CallbackResponse>("POST", "/auth/fediverse/callback", { | ||||
| 			body: { code, state, instance: params.instance }, | ||||
| 			isInternal: true, | ||||
| 			fetch, | ||||
| 		}); | ||||
| 
 | ||||
| 		if (resp.has_account) { | ||||
| 			setToken(cookies, resp.token!); | ||||
| 			redirect(303, `/@${resp.user!.username}`); | ||||
| 		} | ||||
| 
 | ||||
| 		return { | ||||
| 			hasAccount: false, | ||||
| 			isLinkRequest: false, | ||||
| 			ticket: resp.ticket!, | ||||
| 			remoteUser: resp.remote_username!, | ||||
| 		}; | ||||
| 	} catch (e) { | ||||
| 		if (isRedirect(e)) throw e; | ||||
| 		if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj }; | ||||
| 		log.error("error while requesting fediverse callback:", e); | ||||
| 		throw e; | ||||
| 	} | ||||
| }; | ||||
| 	return { code, state, instance: params.instance! }; | ||||
| }); | ||||
| 
 | ||||
| export const actions = { | ||||
| 	default: createRegisterAction("/auth/fediverse/register"), | ||||
|  |  | |||
							
								
								
									
										35
									
								
								Foxnouns.Frontend/src/routes/auth/register/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								Foxnouns.Frontend/src/routes/auth/register/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| import { apiRequest, fastRequest } from "$api"; | ||||
| import ApiError from "$api/error.js"; | ||||
| import type { AuthUrls } from "$api/models/auth"; | ||||
| import log from "$lib/log.js"; | ||||
| import { redirect } from "@sveltejs/kit"; | ||||
| 
 | ||||
| export const load = async ({ fetch, parent }) => { | ||||
| 	const parentData = await parent(); | ||||
| 	if (parentData.meUser) redirect(303, `/@${parentData.meUser.username}`); | ||||
| 
 | ||||
| 	const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true }); | ||||
| 	if (!urls.email_enabled) redirect(303, "/"); | ||||
| }; | ||||
| 
 | ||||
| export const actions = { | ||||
| 	default: async ({ request, fetch, cookies }) => { | ||||
| 		const body = await request.formData(); | ||||
| 		const email = body.get("email") as string; | ||||
| 
 | ||||
| 		try { | ||||
| 			await fastRequest("POST", `/auth/email/register/init`, { | ||||
| 				body: { email }, | ||||
| 				isInternal: true, | ||||
| 				fetch, | ||||
| 				cookies, | ||||
| 			}); | ||||
| 
 | ||||
| 			return { ok: true, error: null }; | ||||
| 		} catch (e) { | ||||
| 			if (e instanceof ApiError) return { ok: false, error: e.obj }; | ||||
| 			log.error("error initiating registration for email %s:", email, e); | ||||
| 			throw e; | ||||
| 		} | ||||
| 	}, | ||||
| }; | ||||
							
								
								
									
										29
									
								
								Foxnouns.Frontend/src/routes/auth/register/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								Foxnouns.Frontend/src/routes/auth/register/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,29 @@ | |||
| <script lang="ts"> | ||||
| 	import type { ActionData, PageData } from "./$types"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import { enhance } from "$app/forms"; | ||||
| 	import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap"; | ||||
| 	import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; | ||||
| 
 | ||||
| 	type Props = { data: PageData; form: ActionData }; | ||||
| 	let { data, form }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
| 	<title>{$t("auth.register-with-email")} • pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <div class="container"> | ||||
| 	<div class="mx-auto w-lg-75"> | ||||
| 		<h2>{$t("auth.register-with-email")}</h2> | ||||
| 
 | ||||
| 		<FormStatusMarker {form} successMessage={$t("auth.register-with-email-init-success")} /> | ||||
| 
 | ||||
| 		<form method="POST" use:enhance> | ||||
| 			<InputGroup> | ||||
| 				<Input name="email" type="email" placeholder={$t("auth.email-label")} /> | ||||
| 				<Button type="submit" color="secondary">{$t("auth.register-with-email-button")}</Button> | ||||
| 			</InputGroup> | ||||
| 		</form> | ||||
| 	</div> | ||||
| </div> | ||||
|  | @ -3,9 +3,10 @@ | |||
| 	import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap"; | ||||
| </script> | ||||
| 
 | ||||
| <h3>Link a new Fediverse account</h3> | ||||
| <div class="mx-auto w-lg-75"> | ||||
| 	<h3>Link a new Fediverse account</h3> | ||||
| 
 | ||||
| <form method="POST" action="?/add"> | ||||
| 	<form method="POST" action="?/add"> | ||||
| 		<InputGroup> | ||||
| 			<Input | ||||
| 				name="instance" | ||||
|  | @ -20,4 +21,5 @@ | |||
| 				{$t("auth.log-in-with-fediverse-force-refresh-button")} | ||||
| 			</Button> | ||||
| 		</p> | ||||
| </form> | ||||
| 	</form> | ||||
| </div> | ||||
|  |  | |||
|  | @ -5,6 +5,7 @@ | |||
| 	import { Icon } from "@sveltestrap/sveltestrap"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import { enhance } from "$app/forms"; | ||||
| 	import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; | ||||
| 
 | ||||
| 	type Props = { data: PageData; form: ActionData }; | ||||
| 	let { data, form }: Props = $props(); | ||||
|  | @ -18,14 +19,7 @@ | |||
| <div class="mx-auto w-lg-75"> | ||||
| 	<h3>{$t("settings.export-title")}</h3> | ||||
| 
 | ||||
| 	{#if form?.ok} | ||||
| 		<p class="text-success-emphasis"> | ||||
| 			<Icon name="check-circle-fill" /> | ||||
| 			{$t("settings.export-request-success")} | ||||
| 		</p> | ||||
| 	{:else if form?.error} | ||||
| 		<ErrorAlert error={form.error} /> | ||||
| 	{/if} | ||||
| 	<FormStatusMarker {form} successMessage={$t("settings.export-request-success")} /> | ||||
| 
 | ||||
| 	<p> | ||||
| 		{$t("settings.export-info")} | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue