feat: forgot password/reset password
This commit is contained in:
		
							parent
							
								
									26b32b40e2
								
							
						
					
					
						commit
						9d33093339
					
				
					 17 changed files with 374 additions and 25 deletions
				
			
		|  | @ -183,6 +183,63 @@ public class EmailAuthController( | |||
|         return NoContent(); | ||||
|     } | ||||
| 
 | ||||
|     [HttpPost("forgot-password")] | ||||
|     public async Task<IActionResult> ForgotPasswordAsync([FromBody] EmailForgotPasswordRequest req) | ||||
|     { | ||||
|         CheckRequirements(); | ||||
| 
 | ||||
|         if (!req.Email.Contains('@')) | ||||
|             throw new ApiError.BadRequest("Email is invalid", "email", req.Email); | ||||
| 
 | ||||
|         AuthMethod? authMethod = await db | ||||
|             .AuthMethods.Where(m => m.AuthType == AuthType.Email && m.RemoteId == req.Email) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (authMethod == null) | ||||
|             return NoContent(); | ||||
| 
 | ||||
|         string state = await keyCacheService.GenerateForgotPasswordStateAsync( | ||||
|             req.Email, | ||||
|             authMethod.UserId | ||||
|         ); | ||||
| 
 | ||||
|         if (IsRateLimited()) | ||||
|             return NoContent(); | ||||
| 
 | ||||
|         mailService.QueueResetPasswordEmail(req.Email, state); | ||||
|         return NoContent(); | ||||
|     } | ||||
| 
 | ||||
|     [HttpPost("reset-password")] | ||||
|     public async Task<IActionResult> ResetPasswordAsync([FromBody] EmailResetPasswordRequest req) | ||||
|     { | ||||
|         ForgotPasswordState? state = await keyCacheService.GetForgotPasswordStateAsync(req.State); | ||||
|         if (state == null) | ||||
|             throw new ApiError.BadRequest("Unknown state", "state", req.State); | ||||
| 
 | ||||
|         if ( | ||||
|             !await db | ||||
|                 .AuthMethods.Where(m => | ||||
|                     m.AuthType == AuthType.Email | ||||
|                     && m.RemoteId == state.Email | ||||
|                     && m.UserId == state.UserId | ||||
|                 ) | ||||
|                 .AnyAsync() | ||||
|         ) | ||||
|         { | ||||
|             throw new ApiError.BadRequest("Invalid state"); | ||||
|         } | ||||
| 
 | ||||
|         ValidationUtils.Validate([("password", ValidationUtils.ValidatePassword(req.Password))]); | ||||
| 
 | ||||
|         User user = await db.Users.FirstAsync(u => u.Id == state.UserId); | ||||
|         await authService.SetUserPasswordAsync(user, req.Password); | ||||
|         await db.SaveChangesAsync(); | ||||
| 
 | ||||
|         mailService.QueuePasswordChangedEmail(state.Email); | ||||
| 
 | ||||
|         return NoContent(); | ||||
|     } | ||||
| 
 | ||||
|     [HttpPost("add-account")] | ||||
|     [Authorize("*")] | ||||
|     public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req) | ||||
|  |  | |||
|  | @ -59,4 +59,8 @@ public record EmailCallbackRequest(string State); | |||
| 
 | ||||
| public record EmailChangePasswordRequest(string Current, string New); | ||||
| 
 | ||||
| public record EmailForgotPasswordRequest(string Email); | ||||
| 
 | ||||
| public record EmailResetPasswordRequest(string State, string Password); | ||||
| 
 | ||||
| public record FediverseCallbackRequest(string Instance, string Code, string? State = null); | ||||
|  |  | |||
|  | @ -28,7 +28,7 @@ public static class KeyCacheExtensions | |||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     { | ||||
|         string state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); | ||||
|         string state = AuthUtils.RandomToken(); | ||||
|         await keyCacheService.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10), ct); | ||||
|         return state; | ||||
|     } | ||||
|  | @ -51,8 +51,7 @@ public static class KeyCacheExtensions | |||
|         CancellationToken ct = default | ||||
|     ) | ||||
|     { | ||||
|         // This state is used in links, not just as JSON values, so make it URL-safe | ||||
|         string state = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); | ||||
|         string state = AuthUtils.RandomToken(); | ||||
|         await keyCacheService.SetKeyAsync( | ||||
|             $"email_state:{state}", | ||||
|             new RegisterEmailState(email, userId), | ||||
|  | @ -112,11 +111,12 @@ public static class KeyCacheExtensions | |||
|     public static async Task<ForgotPasswordState?> GetForgotPasswordStateAsync( | ||||
|         this KeyCacheService keyCacheService, | ||||
|         string state, | ||||
|         bool delete = true, | ||||
|         CancellationToken ct = default | ||||
|     ) => | ||||
|         await keyCacheService.GetKeyAsync<ForgotPasswordState>( | ||||
|             $"forgot_password:{state}", | ||||
|             true, | ||||
|             delete, | ||||
|             ct | ||||
|         ); | ||||
| } | ||||
|  |  | |||
|  | @ -102,7 +102,7 @@ public class CreateDataExportInvocable( | |||
|         stream.Seek(0, SeekOrigin.Begin); | ||||
| 
 | ||||
|         // Upload the file! | ||||
|         string filename = AuthUtils.RandomToken().Replace('+', '-').Replace('/', '_'); | ||||
|         string filename = AuthUtils.RandomToken(); | ||||
|         await objectStorageService.PutObjectAsync( | ||||
|             ExportPath(user.Id, filename), | ||||
|             stream, | ||||
|  |  | |||
							
								
								
									
										25
									
								
								Foxnouns.Backend/Mailables/PasswordChangedMailable.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								Foxnouns.Backend/Mailables/PasswordChangedMailable.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,25 @@ | |||
| using Coravel.Mailer.Mail; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Mailables; | ||||
| 
 | ||||
| public class PasswordChangedMailable(Config config, PasswordChangedMailableView view) | ||||
|     : Mailable<PasswordChangedMailableView> | ||||
| { | ||||
|     private string PlainText() => | ||||
|         $"""
 | ||||
|             Your password has been changed using a "forgot password" link. | ||||
|             If this wasn't you, request a password reset immediately: | ||||
|             {view.BaseUrl}/auth/forgot-password | ||||
|             """;
 | ||||
| 
 | ||||
|     public override void Build() | ||||
|     { | ||||
|         To(view.To) | ||||
|             .From(config.EmailAuth.From!) | ||||
|             .Subject("Your password has been changed") | ||||
|             .View("~/Views/Mail/PasswordChanged.cshtml", view) | ||||
|             .Text(PlainText()); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| public class PasswordChangedMailableView : BaseView; | ||||
							
								
								
									
										32
									
								
								Foxnouns.Backend/Mailables/ResetPasswordMailable.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								Foxnouns.Backend/Mailables/ResetPasswordMailable.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,32 @@ | |||
| using Coravel.Mailer.Mail; | ||||
| 
 | ||||
| namespace Foxnouns.Backend.Mailables; | ||||
| 
 | ||||
| public class ResetPasswordMailable(Config config, ResetPasswordMailableView view) | ||||
|     : Mailable<ResetPasswordMailableView> | ||||
| { | ||||
|     private string PlainText() => | ||||
|         $"""
 | ||||
|             Somebody (hopefully you!) has requested a password reset. | ||||
|             You can use the following link to do this: | ||||
|             {view.BaseUrl}/auth/forgot-password/{view.Code} | ||||
|             Note that this link will expire in one hour. | ||||
| 
 | ||||
|             If you weren't expecting this email, you don't have to do anything. | ||||
|             Your password can't be changed without the above link. | ||||
|             """;
 | ||||
| 
 | ||||
|     public override void Build() | ||||
|     { | ||||
|         To(view.To) | ||||
|             .From(config.EmailAuth.From!) | ||||
|             .Subject("Reset your account's password") | ||||
|             .View("~/Views/Mail/ResetPassword.cshtml", view) | ||||
|             .Text(PlainText()); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| public class ResetPasswordMailableView : BaseView | ||||
| { | ||||
|     public required string Code { get; init; } | ||||
| } | ||||
|  | @ -63,6 +63,41 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co | |||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     public void QueueResetPasswordEmail(string to, string code) | ||||
|     { | ||||
|         _logger.Debug("Sending add email address email to {ToEmail}", to); | ||||
|         queue.QueueAsyncTask(async () => | ||||
|         { | ||||
|             await SendEmailAsync( | ||||
|                 to, | ||||
|                 new ResetPasswordMailable( | ||||
|                     config, | ||||
|                     new ResetPasswordMailableView | ||||
|                     { | ||||
|                         BaseUrl = config.BaseUrl, | ||||
|                         To = to, | ||||
|                         Code = code, | ||||
|                     } | ||||
|                 ) | ||||
|             ); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     public void QueuePasswordChangedEmail(string to) | ||||
|     { | ||||
|         _logger.Debug("Sending add email address email to {ToEmail}", to); | ||||
|         queue.QueueAsyncTask(async () => | ||||
|         { | ||||
|             await SendEmailAsync( | ||||
|                 to, | ||||
|                 new PasswordChangedMailable( | ||||
|                     config, | ||||
|                     new PasswordChangedMailableView { BaseUrl = config.BaseUrl, To = to } | ||||
|                 ) | ||||
|             ); | ||||
|         }); | ||||
|     } | ||||
| 
 | ||||
|     private async Task SendEmailAsync<T>(string to, Mailable<T> mailable) | ||||
|     { | ||||
|         try | ||||
|  |  | |||
|  | @ -131,7 +131,12 @@ public static class AuthUtils | |||
|     } | ||||
| 
 | ||||
|     public static string RandomToken(int bytes = 48) => | ||||
|         Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('='); | ||||
|         Convert | ||||
|             .ToBase64String(RandomNumberGenerator.GetBytes(bytes)) | ||||
|             .Trim('=') | ||||
|             // Make the token URL-safe | ||||
|             .Replace('+', '-') | ||||
|             .Replace('/', '_'); | ||||
| 
 | ||||
|     public const int MaxAuthMethodsPerType = 3; // Maximum of 3 Discord accounts, 3 emails, etc | ||||
| } | ||||
|  |  | |||
							
								
								
									
										8
									
								
								Foxnouns.Backend/Views/Mail/PasswordChanged.cshtml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								Foxnouns.Backend/Views/Mail/PasswordChanged.cshtml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,8 @@ | |||
| @model Foxnouns.Backend.Mailables.PasswordChangedMailableView | ||||
| 
 | ||||
| <p> | ||||
|     Your password has been changed using a "forgot password" link. | ||||
|     If this wasn't you, please a password reset immediately: | ||||
|     <br /> | ||||
|     <a href="@Model.BaseUrl/auth/forgot-password">@Model.BaseUrl/auth/forgot-password</a> | ||||
| </p> | ||||
							
								
								
									
										14
									
								
								Foxnouns.Backend/Views/Mail/ResetPassword.cshtml
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								Foxnouns.Backend/Views/Mail/ResetPassword.cshtml
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | |||
| @model Foxnouns.Backend.Mailables.ResetPasswordMailableView | ||||
| 
 | ||||
| <p> | ||||
|     Somebody (hopefully you!) has requested a password reset. | ||||
|     You can use the following link to do this: | ||||
|     <br /> | ||||
|     <a href="@Model.BaseUrl/auth/forgot-password/@Model.Code">@Model.BaseUrl/auth/forgot-password/@Model.Code</a> | ||||
|     <br /> | ||||
|     Note that this link will expire in one hour. | ||||
| </p> | ||||
| <p> | ||||
|     If you weren't expecting this email, you don't have to do anything. | ||||
|     Your password can't be changed without the above link. | ||||
| </p> | ||||
|  | @ -35,7 +35,7 @@ | |||
| 			</div> | ||||
| 		</div> | ||||
| 		<div class="col-md"> | ||||
| 			<FormStatusMarker {form} /> | ||||
| 			<FormStatusMarker {form} successMessage={$t("auth.password-changed-hint")} /> | ||||
| 			<h4>Change password</h4> | ||||
| 			<form method="POST" action="?/password"> | ||||
| 				<div class="mb-1"> | ||||
|  |  | |||
|  | @ -61,7 +61,13 @@ | |||
| 		"add-email-address": "Add email address", | ||||
| 		"no-email-addresses": "You haven't linked any email addresses yet.", | ||||
| 		"check-inbox-for-link-hint": "Check your inbox for a link!", | ||||
| 		"successful-link-email": "Your account has successfully been linked to the following email address:" | ||||
| 		"successful-link-email": "Your account has successfully been linked to the following email address:", | ||||
| 		"reset-password-button": "Reset password", | ||||
| 		"log-in-forgot-password-link": "Forgot your password?", | ||||
| 		"log-in-sign-up-link": "Sign up with email", | ||||
| 		"forgot-password-title": "Forgot password", | ||||
| 		"reset-password-title": "Reset password", | ||||
| 		"password-changed-hint": "Your password has been changed!" | ||||
| 	}, | ||||
| 	"error": { | ||||
| 		"bad-request-header": "Something was wrong with your input", | ||||
|  |  | |||
|  | @ -0,0 +1,34 @@ | |||
| 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 ({ parent, fetch }) => { | ||||
| 	const { meUser } = await parent(); | ||||
| 	if (meUser) redirect(303, `/@${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 }) => { | ||||
| 		const data = await request.formData(); | ||||
| 		const email = data.get("email") as string; | ||||
| 
 | ||||
| 		try { | ||||
| 			await fastRequest("POST", "/auth/email/forgot-password", { | ||||
| 				body: { email }, | ||||
| 				isInternal: true, | ||||
| 				fetch, | ||||
| 			}); | ||||
| 
 | ||||
| 			return { ok: true, error: null }; | ||||
| 		} catch (e) { | ||||
| 			if (e instanceof ApiError) return { ok: false, error: e.obj }; | ||||
| 			log.error("error sending forget password email:", e); | ||||
| 			throw e; | ||||
| 		} | ||||
| 	}, | ||||
| }; | ||||
|  | @ -0,0 +1,35 @@ | |||
| <script lang="ts"> | ||||
| 	import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import type { ActionData } from "./$types"; | ||||
| 
 | ||||
| 	type Props = { form: ActionData }; | ||||
| 	let { form }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
| 	<title>{$t("auth.forgot-password-title")} • pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <div class="container"> | ||||
| 	<div class="mx-auto w-lg-50"> | ||||
| 		<h3>{$t("auth.forgot-password-title")}</h3> | ||||
| 
 | ||||
| 		<FormStatusMarker {form} successMessage={$t("auth.check-inbox-for-link-hint")} /> | ||||
| 
 | ||||
| 		<form method="POST"> | ||||
| 			<label for="email" class="form-label">{$t("auth.log-in-form-email-label")}</label> | ||||
| 			<input | ||||
| 				required | ||||
| 				type="email" | ||||
| 				id="email" | ||||
| 				name="email" | ||||
| 				placeholder="me@example.com" | ||||
| 				class="form-control mb-2" | ||||
| 			/> | ||||
| 			<div class="d-grid"> | ||||
| 				<button type="submit" class="btn btn-primary">{$t("auth.reset-password-button")}</button> | ||||
| 			</div> | ||||
| 		</form> | ||||
| 	</div> | ||||
| </div> | ||||
|  | @ -0,0 +1,48 @@ | |||
| import { apiRequest, fastRequest } from "$api"; | ||||
| import ApiError, { ErrorCode, type RawApiError } from "$api/error"; | ||||
| import type { AuthUrls } from "$api/models"; | ||||
| import log from "$lib/log"; | ||||
| import { redirect } from "@sveltejs/kit"; | ||||
| 
 | ||||
| export const load = async ({ params, parent, fetch }) => { | ||||
| 	const { meUser } = await parent(); | ||||
| 	if (meUser) redirect(303, `/@${meUser.username}`); | ||||
| 
 | ||||
| 	const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true }); | ||||
| 	if (!urls.email_enabled) redirect(303, "/"); | ||||
| 
 | ||||
| 	return { state: params.code }; | ||||
| }; | ||||
| 
 | ||||
| export const actions = { | ||||
| 	default: async ({ request, fetch }) => { | ||||
| 		const data = await request.formData(); | ||||
| 		const state = data.get("state") as string; | ||||
| 		const password = data.get("password") as string; | ||||
| 		const password2 = data.get("confirm-password") as string; | ||||
| 		if (password !== password2) { | ||||
| 			return { | ||||
| 				ok: false, | ||||
| 				error: { | ||||
| 					status: 400, | ||||
| 					message: "Passwords don't match", | ||||
| 					code: ErrorCode.BadRequest, | ||||
| 				} as RawApiError, | ||||
| 			}; | ||||
| 		} | ||||
| 
 | ||||
| 		try { | ||||
| 			await fastRequest("POST", "/auth/email/reset-password", { | ||||
| 				body: { state, password }, | ||||
| 				isInternal: true, | ||||
| 				fetch, | ||||
| 			}); | ||||
| 
 | ||||
| 			return { ok: true, error: null }; | ||||
| 		} catch (e) { | ||||
| 			if (e instanceof ApiError) return { ok: false, error: e.obj }; | ||||
| 			log.error("error resetting password:", e); | ||||
| 			throw e; | ||||
| 		} | ||||
| 	}, | ||||
| }; | ||||
|  | @ -0,0 +1,41 @@ | |||
| <script lang="ts"> | ||||
| 	import FormStatusMarker from "$components/editor/FormStatusMarker.svelte"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import type { ActionData, PageData } from "./$types"; | ||||
| 
 | ||||
| 	type Props = { data: PageData; form: ActionData }; | ||||
| 	let { data, form }: Props = $props(); | ||||
| </script> | ||||
| 
 | ||||
| <svelte:head> | ||||
| 	<title>{$t("auth.reset-password-title")} • pronouns.cc</title> | ||||
| </svelte:head> | ||||
| 
 | ||||
| <div class="container"> | ||||
| 	<div class="mx-auto w-lg-50"> | ||||
| 		<h3>{$t("auth.reset-password-title")}</h3> | ||||
| 
 | ||||
| 		<FormStatusMarker {form} successMessage={$t("auth.password-changed-hint")} /> | ||||
| 
 | ||||
| 		<form method="POST"> | ||||
| 			<input type="hidden" name="state" readonly value={data.state} /> | ||||
| 			<div class="mb-2"> | ||||
| 				<label for="password" class="form-label">{$t("auth.log-in-form-password-label")}</label> | ||||
| 				<input required type="password" id="password" name="password" class="form-control" /> | ||||
| 			</div> | ||||
| 			<div class="mb-2"> | ||||
| 				<label for="confirm-password" class="form-label">{$t("auth.confirm-password-label")}</label> | ||||
| 				<input | ||||
| 					required | ||||
| 					type="password" | ||||
| 					id="confirm-password" | ||||
| 					name="confirm-password" | ||||
| 					class="form-control" | ||||
| 				/> | ||||
| 			</div> | ||||
| 			<div class="d-grid"> | ||||
| 				<button type="submit" class="btn btn-primary">{$t("auth.reset-password-button")}</button> | ||||
| 			</div> | ||||
| 		</form> | ||||
| 	</div> | ||||
| </div> | ||||
|  | @ -2,7 +2,6 @@ | |||
| 	import type { ActionData, PageData } from "./$types"; | ||||
| 	import { t } from "$lib/i18n"; | ||||
| 	import { enhance } from "$app/forms"; | ||||
| 	import { Button, ButtonGroup, Input, InputGroup } from "@sveltestrap/sveltestrap"; | ||||
| 	import ErrorAlert from "$components/ErrorAlert.svelte"; | ||||
| 
 | ||||
| 	type Props = { data: PageData; form: ActionData }; | ||||
|  | @ -21,29 +20,34 @@ | |||
| 	</div> | ||||
| 	<div class="row"> | ||||
| 		{#if data.urls.email_enabled} | ||||
| 			<div class="col col-md mb-4"> | ||||
| 			<div class="col-md mb-4"> | ||||
| 				<h2>{$t("auth.log-in-form-title")}</h2> | ||||
| 				<form method="POST" action="?/login" use:enhance> | ||||
| 					<div class="mb-2"> | ||||
| 						<label class="form-label" for="email">{$t("auth.log-in-form-email-label")}</label> | ||||
| 						<Input type="email" id="email" name="email" placeholder="me@example.com" /> | ||||
| 						<input | ||||
| 							class="form-control" | ||||
| 							type="email" | ||||
| 							id="email" | ||||
| 							name="email" | ||||
| 							placeholder="me@example.com" | ||||
| 						/> | ||||
| 					</div> | ||||
| 					<div class="mb-2"> | ||||
| 						<label class="form-label" for="password">{$t("auth.log-in-form-password-label")}</label> | ||||
| 						<Input type="password" id="password" name="password" /> | ||||
| 						<input class="form-control" type="password" id="password" name="password" /> | ||||
| 					</div> | ||||
| 					<ButtonGroup> | ||||
| 						<Button type="submit" color="primary">{$t("auth.log-in-button")}</Button> | ||||
| 						<a class="btn btn-secondary" href="/auth/register"> | ||||
| 							{$t("auth.register-with-email-button")} | ||||
| 						</a> | ||||
| 					</ButtonGroup> | ||||
| 					<button class="btn btn-primary" type="submit">{$t("auth.log-in-button")}</button> | ||||
| 				</form> | ||||
| 				<p class="mt-2"> | ||||
| 					<a href="/auth/register">{$t("auth.log-in-sign-up-link")}</a> • | ||||
| 					<a href="/auth/forgot-password">{$t("auth.log-in-forgot-password-link")}</a> | ||||
| 				</p> | ||||
| 			</div> | ||||
| 		{:else} | ||||
| 			<div class="col-lg-3"></div> | ||||
| 		{/if} | ||||
| 		<div class="col col-md"> | ||||
| 		<div class="col-md"> | ||||
| 			<h3>{$t("auth.log-in-3rd-party-header")}</h3> | ||||
| 			<p>{$t("auth.log-in-3rd-party-desc")}</p> | ||||
| 			<form method="POST" action="?/fediToggle" use:enhance> | ||||
|  | @ -71,19 +75,20 @@ | |||
| 			{#if form?.showFediBox} | ||||
| 				<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4> | ||||
| 				<form method="POST" action="?/fedi" use:enhance> | ||||
| 					<InputGroup> | ||||
| 						<Input | ||||
| 					<div class="input-group"> | ||||
| 						<input | ||||
| 							class="form-control" | ||||
| 							name="instance" | ||||
| 							type="text" | ||||
| 							placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")} | ||||
| 						/> | ||||
| 						<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button> | ||||
| 					</InputGroup> | ||||
| 						<button class="btn btn-secondary" type="submit">{$t("auth.log-in-button")}</button> | ||||
| 					</div> | ||||
| 					<p> | ||||
| 						{$t("auth.log-in-with-fediverse-error-blurb")} | ||||
| 						<Button formaction="?/fediForceRefresh" type="submit" color="link"> | ||||
| 						<button class="btn btn-link" formaction="?/fediForceRefresh" type="submit"> | ||||
| 							{$t("auth.log-in-with-fediverse-force-refresh-button")} | ||||
| 						</Button> | ||||
| 						</button> | ||||
| 					</p> | ||||
| 				</form> | ||||
| 			{/if} | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue