feat: log in with tumblr
This commit is contained in:
		
							parent
							
								
									d30ebacc72
								
							
						
					
					
						commit
						3338243cea
					
				
					 10 changed files with 342 additions and 9 deletions
				
			
		|  | @ -48,6 +48,7 @@ public class AuthController( | ||||||
|         string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct)); |         string state = HttpUtility.UrlEncode(await keyCacheService.GenerateAuthStateAsync(ct)); | ||||||
|         string? discord = null; |         string? discord = null; | ||||||
|         string? google = null; |         string? google = null; | ||||||
|  |         string? tumblr = null; | ||||||
|         if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) |         if (config.DiscordAuth is { ClientId: not null, ClientSecret: not null }) | ||||||
|         { |         { | ||||||
|             discord = |             discord = | ||||||
|  | @ -67,7 +68,16 @@ public class AuthController( | ||||||
|                 + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}"; |                 + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/google")}"; | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, google, null)); |         if (config.TumblrAuth is { ClientId: not null, ClientSecret: not null }) | ||||||
|  |         { | ||||||
|  |             tumblr = | ||||||
|  |                 "https://www.tumblr.com/oauth2/authorize?response_type=code" | ||||||
|  |                 + $"&client_id={config.TumblrAuth.ClientId}" | ||||||
|  |                 + $"&scope=basic&state={state}" | ||||||
|  |                 + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/tumblr")}"; | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         return Ok(new UrlsResponse(config.EmailAuth.Enabled, discord, google, tumblr)); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     [HttpPost("force-log-out")] |     [HttpPost("force-log-out")] | ||||||
|  |  | ||||||
|  | @ -0,0 +1,163 @@ | ||||||
|  | using System.Net; | ||||||
|  | using System.Web; | ||||||
|  | using EntityFramework.Exceptions.Common; | ||||||
|  | using Foxnouns.Backend.Database; | ||||||
|  | using Foxnouns.Backend.Database.Models; | ||||||
|  | using Foxnouns.Backend.Dto; | ||||||
|  | using Foxnouns.Backend.Extensions; | ||||||
|  | using Foxnouns.Backend.Middleware; | ||||||
|  | using Foxnouns.Backend.Services; | ||||||
|  | using Foxnouns.Backend.Services.Auth; | ||||||
|  | using Foxnouns.Backend.Utils; | ||||||
|  | using JetBrains.Annotations; | ||||||
|  | using Microsoft.AspNetCore.Mvc; | ||||||
|  | using Microsoft.EntityFrameworkCore; | ||||||
|  | using NodaTime; | ||||||
|  | 
 | ||||||
|  | namespace Foxnouns.Backend.Controllers.Authentication; | ||||||
|  | 
 | ||||||
|  | [Route("/api/internal/auth/tumblr")] | ||||||
|  | public class TumblrAuthController( | ||||||
|  |     [UsedImplicitly] Config config, | ||||||
|  |     ILogger logger, | ||||||
|  |     DatabaseContext db, | ||||||
|  |     KeyCacheService keyCacheService, | ||||||
|  |     AuthService authService, | ||||||
|  |     RemoteAuthService remoteAuthService | ||||||
|  | ) : ApiControllerBase | ||||||
|  | { | ||||||
|  |     private readonly ILogger _logger = logger.ForContext<TumblrAuthController>(); | ||||||
|  | 
 | ||||||
|  |     [HttpPost("callback")] | ||||||
|  |     [ProducesResponseType<CallbackResponse>(StatusCodes.Status200OK)] | ||||||
|  |     public async Task<IActionResult> CallbackAsync([FromBody] CallbackRequest req) | ||||||
|  |     { | ||||||
|  |         CheckRequirements(); | ||||||
|  |         await keyCacheService.ValidateAuthStateAsync(req.State); | ||||||
|  | 
 | ||||||
|  |         RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestTumblrTokenAsync( | ||||||
|  |             req.Code | ||||||
|  |         ); | ||||||
|  |         User? user = await authService.AuthenticateUserAsync(AuthType.Tumblr, remoteUser.Id); | ||||||
|  |         if (user != null) | ||||||
|  |             return Ok(await authService.GenerateUserTokenAsync(user)); | ||||||
|  | 
 | ||||||
|  |         _logger.Debug( | ||||||
|  |             "Tumblr user {Username} ({Id}) authenticated with no local account", | ||||||
|  |             remoteUser.Username, | ||||||
|  |             remoteUser.Id | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         string ticket = AuthUtils.RandomToken(); | ||||||
|  |         await keyCacheService.SetKeyAsync($"tumblr:{ticket}", remoteUser, Duration.FromMinutes(20)); | ||||||
|  | 
 | ||||||
|  |         return Ok(new CallbackResponse(false, ticket, remoteUser.Username, null, null, null)); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     [HttpPost("register")] | ||||||
|  |     [ProducesResponseType<AuthResponse>(StatusCodes.Status200OK)] | ||||||
|  |     public async Task<IActionResult> RegisterAsync([FromBody] OauthRegisterRequest req) | ||||||
|  |     { | ||||||
|  |         RemoteAuthService.RemoteUser? remoteUser = | ||||||
|  |             await keyCacheService.GetKeyAsync<RemoteAuthService.RemoteUser>($"tumblr:{req.Ticket}"); | ||||||
|  |         if (remoteUser == null) | ||||||
|  |             throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); | ||||||
|  |         if ( | ||||||
|  |             await db.AuthMethods.AnyAsync(a => | ||||||
|  |                 a.AuthType == AuthType.Tumblr && a.RemoteId == remoteUser.Id | ||||||
|  |             ) | ||||||
|  |         ) | ||||||
|  |         { | ||||||
|  |             _logger.Error( | ||||||
|  |                 "Tumblr user {Id} has valid ticket but is already linked to an existing account", | ||||||
|  |                 remoteUser.Id | ||||||
|  |             ); | ||||||
|  |             throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         User user = await authService.CreateUserWithRemoteAuthAsync( | ||||||
|  |             req.Username, | ||||||
|  |             AuthType.Tumblr, | ||||||
|  |             remoteUser.Id, | ||||||
|  |             remoteUser.Username | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         return Ok(await authService.GenerateUserTokenAsync(user)); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     [HttpGet("add-account")] | ||||||
|  |     [Authorize("*")] | ||||||
|  |     public async Task<IActionResult> AddTumblrAccountAsync() | ||||||
|  |     { | ||||||
|  |         CheckRequirements(); | ||||||
|  | 
 | ||||||
|  |         string state = await remoteAuthService.ValidateAddAccountRequestAsync( | ||||||
|  |             CurrentUser!.Id, | ||||||
|  |             AuthType.Tumblr | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         string url = | ||||||
|  |             "https://www.tumblr.com/oauth2/authorize?response_type=code" | ||||||
|  |             + $"&client_id={config.TumblrAuth.ClientId}" | ||||||
|  |             + $"&scope=basic&state={state}" | ||||||
|  |             + $"&redirect_uri={HttpUtility.UrlEncode($"{config.BaseUrl}/auth/callback/tumblr")}"; | ||||||
|  | 
 | ||||||
|  |         return Ok(new SingleUrlResponse(url)); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     [HttpPost("add-account/callback")] | ||||||
|  |     [Authorize("*")] | ||||||
|  |     public async Task<IActionResult> AddAccountCallbackAsync([FromBody] CallbackRequest req) | ||||||
|  |     { | ||||||
|  |         CheckRequirements(); | ||||||
|  | 
 | ||||||
|  |         await remoteAuthService.ValidateAddAccountStateAsync( | ||||||
|  |             req.State, | ||||||
|  |             CurrentUser!.Id, | ||||||
|  |             AuthType.Tumblr | ||||||
|  |         ); | ||||||
|  | 
 | ||||||
|  |         RemoteAuthService.RemoteUser remoteUser = await remoteAuthService.RequestTumblrTokenAsync( | ||||||
|  |             req.Code | ||||||
|  |         ); | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             AuthMethod authMethod = await authService.AddAuthMethodAsync( | ||||||
|  |                 CurrentUser.Id, | ||||||
|  |                 AuthType.Tumblr, | ||||||
|  |                 remoteUser.Id, | ||||||
|  |                 remoteUser.Username | ||||||
|  |             ); | ||||||
|  |             _logger.Debug( | ||||||
|  |                 "Added new Tumblr auth method {AuthMethodId} to user {UserId}", | ||||||
|  |                 authMethod.Id, | ||||||
|  |                 CurrentUser.Id | ||||||
|  |             ); | ||||||
|  | 
 | ||||||
|  |             return Ok( | ||||||
|  |                 new AddOauthAccountResponse( | ||||||
|  |                     authMethod.Id, | ||||||
|  |                     AuthType.Tumblr, | ||||||
|  |                     authMethod.RemoteId, | ||||||
|  |                     authMethod.RemoteUsername | ||||||
|  |                 ) | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |         catch (UniqueConstraintException) | ||||||
|  |         { | ||||||
|  |             throw new ApiError( | ||||||
|  |                 "That account is already linked.", | ||||||
|  |                 HttpStatusCode.BadRequest, | ||||||
|  |                 ErrorCode.AccountAlreadyLinked | ||||||
|  |             ); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private void CheckRequirements() | ||||||
|  |     { | ||||||
|  |         if (!config.TumblrAuth.Enabled) | ||||||
|  |         { | ||||||
|  |             throw new ApiError.BadRequest("Tumblr authentication is not enabled on this instance."); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | @ -53,14 +53,12 @@ public partial class RemoteAuthService | ||||||
|             throw new FoxnounsError("Invalid Discord OAuth response"); |             throw new FoxnounsError("Invalid Discord OAuth response"); | ||||||
|         } |         } | ||||||
| 
 | 
 | ||||||
|         DiscordTokenResponse? token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>( |         OauthTokenResponse? token = await resp.Content.ReadFromJsonAsync<OauthTokenResponse>(ct); | ||||||
|             ct |  | ||||||
|         ); |  | ||||||
|         if (token == null) |         if (token == null) | ||||||
|             throw new FoxnounsError("Discord token response was null"); |             throw new FoxnounsError("Discord token response was null"); | ||||||
| 
 | 
 | ||||||
|         var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri); |         var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri); | ||||||
|         req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}"); |         req.Headers.Add("Authorization", $"{token.TokenType} {token.AccessToken}"); | ||||||
| 
 | 
 | ||||||
|         HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct); |         HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct); | ||||||
|         resp2.EnsureSuccessStatusCode(); |         resp2.EnsureSuccessStatusCode(); | ||||||
|  |  | ||||||
							
								
								
									
										111
									
								
								Foxnouns.Backend/Services/Auth/RemoteAuthService.Tumblr.cs
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								Foxnouns.Backend/Services/Auth/RemoteAuthService.Tumblr.cs
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,111 @@ | ||||||
|  | // Copyright (C) 2023-present sam/u1f320 (vulpine.solutions) | ||||||
|  | // | ||||||
|  | // This program is free software: you can redistribute it and/or modify | ||||||
|  | // it under the terms of the GNU Affero General Public License as published | ||||||
|  | // by the Free Software Foundation, either version 3 of the License, or | ||||||
|  | // (at your option) any later version. | ||||||
|  | // | ||||||
|  | // This program is distributed in the hope that it will be useful, | ||||||
|  | // but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||
|  | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||||
|  | // GNU Affero General Public License for more details. | ||||||
|  | // | ||||||
|  | // You should have received a copy of the GNU Affero General Public License | ||||||
|  | // along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||||
|  | using System.Text.Json.Serialization; | ||||||
|  | 
 | ||||||
|  | namespace Foxnouns.Backend.Services.Auth; | ||||||
|  | 
 | ||||||
|  | public partial class RemoteAuthService | ||||||
|  | { | ||||||
|  |     private readonly Uri _tumblrTokenUri = new("https://api.tumblr.com/v2/oauth2/token"); | ||||||
|  |     private readonly Uri _tumblrUserUri = new("https://api.tumblr.com/v2/user/info"); | ||||||
|  | 
 | ||||||
|  |     public async Task<RemoteUser> RequestTumblrTokenAsync( | ||||||
|  |         string code, | ||||||
|  |         CancellationToken ct = default | ||||||
|  |     ) | ||||||
|  |     { | ||||||
|  |         var redirectUri = $"{config.BaseUrl}/auth/callback/tumblr"; | ||||||
|  |         HttpResponseMessage resp = await _httpClient.PostAsync( | ||||||
|  |             _tumblrTokenUri, | ||||||
|  |             new FormUrlEncodedContent( | ||||||
|  |                 new Dictionary<string, string> | ||||||
|  |                 { | ||||||
|  |                     { "client_id", config.TumblrAuth.ClientId! }, | ||||||
|  |                     { "client_secret", config.TumblrAuth.ClientSecret! }, | ||||||
|  |                     { "grant_type", "authorization_code" }, | ||||||
|  |                     { "code", code }, | ||||||
|  |                     { "scope", "basic" }, | ||||||
|  |                     { "redirect_uri", redirectUri }, | ||||||
|  |                 } | ||||||
|  |             ), | ||||||
|  |             ct | ||||||
|  |         ); | ||||||
|  |         if (!resp.IsSuccessStatusCode) | ||||||
|  |         { | ||||||
|  |             string respBody = await resp.Content.ReadAsStringAsync(ct); | ||||||
|  |             _logger.Error( | ||||||
|  |                 "Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}", | ||||||
|  |                 (int)resp.StatusCode, | ||||||
|  |                 respBody | ||||||
|  |             ); | ||||||
|  |             throw new FoxnounsError("Invalid Tumblr OAuth response"); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         OauthTokenResponse? token = await resp.Content.ReadFromJsonAsync<OauthTokenResponse>(ct); | ||||||
|  |         if (token == null) | ||||||
|  |             throw new FoxnounsError("Tumblr token response was null"); | ||||||
|  | 
 | ||||||
|  |         var req = new HttpRequestMessage(HttpMethod.Get, _tumblrUserUri); | ||||||
|  |         req.Headers.Add("Authorization", $"Bearer {token.AccessToken}"); | ||||||
|  | 
 | ||||||
|  |         HttpResponseMessage resp2 = await _httpClient.SendAsync(req, ct); | ||||||
|  |         if (!resp2.IsSuccessStatusCode) | ||||||
|  |         { | ||||||
|  |             string respBody = await resp2.Content.ReadAsStringAsync(ct); | ||||||
|  |             _logger.Error( | ||||||
|  |                 "Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}", | ||||||
|  |                 (int)resp2.StatusCode, | ||||||
|  |                 respBody | ||||||
|  |             ); | ||||||
|  |             throw new FoxnounsError("Invalid Tumblr user response"); | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         TumblrData? data = await resp2.Content.ReadFromJsonAsync<TumblrData>(ct); | ||||||
|  |         if (data == null) | ||||||
|  |             throw new FoxnounsError("Tumblr user response was null"); | ||||||
|  | 
 | ||||||
|  |         TumblrBlog? blog = data.Response.User.Blogs.FirstOrDefault(b => b.Primary); | ||||||
|  |         if (blog == null) | ||||||
|  |             throw new FoxnounsError("Tumblr user doesn't have a primary blog"); | ||||||
|  | 
 | ||||||
|  |         return new RemoteUser(blog.Uuid, blog.Name); | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     private record OauthTokenResponse( | ||||||
|  |         [property: JsonPropertyName("access_token")] string AccessToken, | ||||||
|  |         [property: JsonPropertyName("token_type")] string TokenType | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     // tumblr why | ||||||
|  |     private record TumblrData( | ||||||
|  |         [property: JsonPropertyName("meta")] TumblrMeta Meta, | ||||||
|  |         [property: JsonPropertyName("response")] TumblrResponse Response | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     private record TumblrMeta( | ||||||
|  |         [property: JsonPropertyName("status")] int Status, | ||||||
|  |         [property: JsonPropertyName("msg")] string Message | ||||||
|  |     ); | ||||||
|  | 
 | ||||||
|  |     private record TumblrResponse([property: JsonPropertyName("user")] TumblrUser User); | ||||||
|  | 
 | ||||||
|  |     private record TumblrUser([property: JsonPropertyName("blogs")] TumblrBlog[] Blogs); | ||||||
|  | 
 | ||||||
|  |     private record TumblrBlog( | ||||||
|  |         [property: JsonPropertyName("name")] string Name, | ||||||
|  |         [property: JsonPropertyName("primary")] bool Primary, | ||||||
|  |         [property: JsonPropertyName("uuid")] string Uuid | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  | @ -19,7 +19,6 @@ using Foxnouns.Backend.Database.Models; | ||||||
| using Foxnouns.Backend.Extensions; | using Foxnouns.Backend.Extensions; | ||||||
| using Foxnouns.Backend.Utils; | using Foxnouns.Backend.Utils; | ||||||
| using Humanizer; | using Humanizer; | ||||||
| using JetBrains.Annotations; |  | ||||||
| using Microsoft.EntityFrameworkCore; | using Microsoft.EntityFrameworkCore; | ||||||
| 
 | 
 | ||||||
| namespace Foxnouns.Backend.Services.Auth; | namespace Foxnouns.Backend.Services.Auth; | ||||||
|  |  | ||||||
|  | @ -54,7 +54,9 @@ | ||||||
| 		"confirm-password-label": "Confirm password", | 		"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.", | 		"register-with-email-init-success": "Success! An email has been sent to your inbox, please press the link there to continue.", | ||||||
| 		"register-with-google": "Register with a Google account", | 		"register-with-google": "Register with a Google account", | ||||||
| 		"remote-google-account-label": "Your Google account" | 		"remote-google-account-label": "Your Google account", | ||||||
|  | 		"register-with-tumblr": "Register with a Tumblr account", | ||||||
|  | 		"remote-tumblr-account-label": "Your Tumblr account" | ||||||
| 	}, | 	}, | ||||||
| 	"error": { | 	"error": { | ||||||
| 		"bad-request-header": "Something was wrong with your input", | 		"bad-request-header": "Something was wrong with your input", | ||||||
|  |  | ||||||
|  | @ -3,8 +3,7 @@ | ||||||
| import type { Cookies } from "@sveltejs/kit"; | import type { Cookies } from "@sveltejs/kit"; | ||||||
| import { DateTime } from "luxon"; | import { DateTime } from "luxon"; | ||||||
| 
 | 
 | ||||||
| // export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token";
 | export const TOKEN_COOKIE_NAME = "__Host-pronounscc-token"; | ||||||
| export const TOKEN_COOKIE_NAME = "pronounscc-token"; |  | ||||||
| 
 | 
 | ||||||
| export const setToken = (cookies: Cookies, token: string) => | export const setToken = (cookies: Cookies, token: string) => | ||||||
| 	cookies.set(TOKEN_COOKIE_NAME, token, { path: "/" }); | 	cookies.set(TOKEN_COOKIE_NAME, token, { path: "/" }); | ||||||
|  |  | ||||||
|  | @ -0,0 +1,8 @@ | ||||||
|  | import createCallbackLoader from "$lib/actions/callback"; | ||||||
|  | import createRegisterAction from "$lib/actions/register"; | ||||||
|  | 
 | ||||||
|  | export const load = createCallbackLoader("tumblr"); | ||||||
|  | 
 | ||||||
|  | export const actions = { | ||||||
|  | 	default: createRegisterAction("/auth/tumblr/register"), | ||||||
|  | }; | ||||||
|  | @ -0,0 +1,31 @@ | ||||||
|  | <script lang="ts"> | ||||||
|  | 	import Error from "$components/Error.svelte"; | ||||||
|  | 	import NewAuthMethod from "$components/settings/NewAuthMethod.svelte"; | ||||||
|  | 	import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.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.register-with-tumblr")} • pronouns.cc</title> | ||||||
|  | </svelte:head> | ||||||
|  | 
 | ||||||
|  | <div class="container"> | ||||||
|  | 	{#if data.error} | ||||||
|  | 		<h1>{$t("auth.register-with-tumblr")}</h1> | ||||||
|  | 		<Error error={data.error} /> | ||||||
|  | 	{:else if data.isLinkRequest} | ||||||
|  | 		<NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} /> | ||||||
|  | 	{:else} | ||||||
|  | 		<OauthRegistrationForm | ||||||
|  | 			title={$t("auth.register-with-tumblr")} | ||||||
|  | 			remoteLabel={$t("auth.remote-tumblr-account-label")} | ||||||
|  | 			remoteUser={data.remoteUser!} | ||||||
|  | 			ticket={data.ticket!} | ||||||
|  | 			error={form?.error} | ||||||
|  | 		/> | ||||||
|  | 	{/if} | ||||||
|  | </div> | ||||||
|  | @ -0,0 +1,12 @@ | ||||||
|  | import { apiRequest } from "$api"; | ||||||
|  | import { redirect } from "@sveltejs/kit"; | ||||||
|  | 
 | ||||||
|  | export const load = async ({ fetch, cookies }) => { | ||||||
|  | 	const { url } = await apiRequest<{ url: string }>("GET", "/auth/tumblr/add-account", { | ||||||
|  | 		isInternal: true, | ||||||
|  | 		fetch, | ||||||
|  | 		cookies, | ||||||
|  | 	}); | ||||||
|  | 
 | ||||||
|  | 	redirect(303, url); | ||||||
|  | }; | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue