feat: link fediverse account to existing user

This commit is contained in:
sam 2024-12-04 01:48:52 +01:00
parent 03209e4028
commit 57e1ec09c0
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
17 changed files with 335 additions and 95 deletions

View file

@ -104,21 +104,9 @@ public class DiscordAuthController(
{ {
CheckRequirements(); CheckRequirements();
var existingAccounts = await db var state = await remoteAuthService.ValidateAddAccountRequestAsync(
.AuthMethods.Where(m => m.UserId == CurrentUser!.Id && m.AuthType == AuthType.Discord) CurrentUser!.Id,
.CountAsync(); AuthType.Discord
if (existingAccounts > AuthUtils.MaxAuthMethodsPerType)
{
throw new ApiError.BadRequest(
"Too many linked Discord accounts, maximum of 3 per account."
);
}
var state = HttpUtility.UrlEncode(
await keyCacheService.GenerateAddExtraAccountStateAsync(
AuthType.Discord,
CurrentUser!.Id
)
); );
var url = var url =
@ -138,12 +126,11 @@ public class DiscordAuthController(
{ {
CheckRequirements(); CheckRequirements();
var accountState = await keyCacheService.GetAddExtraAccountStateAsync(req.State); await remoteAuthService.ValidateAddAccountStateAsync(
if ( req.State,
accountState is not { AuthType: AuthType.Discord } CurrentUser!.Id,
|| accountState.UserId != CurrentUser!.Id AuthType.Discord
) );
throw new ApiError.BadRequest("Invalid state", "state", req.State);
var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code); var remoteUser = await remoteAuthService.RequestDiscordTokenAsync(req.Code);
try try

View file

@ -1,5 +1,8 @@
using System.Net;
using EntityFramework.Exceptions.Common;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Services.Auth; using Foxnouns.Backend.Services.Auth;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
@ -15,13 +18,14 @@ public class FediverseAuthController(
DatabaseContext db, DatabaseContext db,
FediverseAuthService fediverseAuthService, FediverseAuthService fediverseAuthService,
AuthService authService, AuthService authService,
RemoteAuthService remoteAuthService,
KeyCacheService keyCacheService KeyCacheService keyCacheService
) : ApiControllerBase ) : ApiControllerBase
{ {
private readonly ILogger _logger = logger.ForContext<FediverseAuthController>(); private readonly ILogger _logger = logger.ForContext<FediverseAuthController>();
[HttpGet] [HttpGet]
[ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<AuthController.SingleUrlResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFediverseUrlAsync( public async Task<IActionResult> GetFediverseUrlAsync(
[FromQuery] string instance, [FromQuery] string instance,
[FromQuery] bool forceRefresh = false [FromQuery] bool forceRefresh = false
@ -31,7 +35,7 @@ public class FediverseAuthController(
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance); throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh); var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
return Ok(new FediverseUrlResponse(url)); return Ok(new AuthController.SingleUrlResponse(url));
} }
[HttpPost("callback")] [HttpPost("callback")]
@ -118,9 +122,74 @@ public class FediverseAuthController(
return Ok(await authService.GenerateUserTokenAsync(user)); return Ok(await authService.GenerateUserTokenAsync(user));
} }
public record CallbackRequest(string Instance, string Code, string State); [HttpGet("add-account")]
[Authorize("*")]
public async Task<IActionResult> AddFediverseAccountAsync(
[FromQuery] string instance,
[FromQuery] bool forceRefresh = false
)
{
if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
private record FediverseUrlResponse(string Url); var state = await remoteAuthService.ValidateAddAccountRequestAsync(
CurrentUser!.Id,
AuthType.Fediverse,
instance
);
var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh, state);
return Ok(new AuthController.SingleUrlResponse(url));
}
[HttpPost("add-account/callback")]
[Authorize("*")]
public async Task<IActionResult> AddAccountCallbackAsync([FromBody] CallbackRequest req)
{
await remoteAuthService.ValidateAddAccountStateAsync(
req.State,
CurrentUser!.Id,
AuthType.Fediverse,
req.Instance
);
var app = await fediverseAuthService.GetApplicationAsync(req.Instance);
var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
try
{
var authMethod = await authService.AddAuthMethodAsync(
CurrentUser.Id,
AuthType.Fediverse,
remoteUser.Id,
remoteUser.Username,
app
);
_logger.Debug(
"Added new Fediverse auth method {AuthMethodId} to user {UserId}",
authMethod.Id,
CurrentUser.Id
);
return Ok(
new AuthController.AddOauthAccountResponse(
authMethod.Id,
AuthType.Fediverse,
authMethod.RemoteId,
$"{authMethod.RemoteUsername}@{app.Domain}"
)
);
}
catch (UniqueConstraintException)
{
throw new ApiError(
"That account is already linked.",
HttpStatusCode.BadRequest,
ErrorCode.AccountAlreadyLinked
);
}
}
public record CallbackRequest(string Instance, string Code, string State);
private record FediverseTicketData( private record FediverseTicketData(
Snowflake ApplicationId, Snowflake ApplicationId,

View file

@ -63,13 +63,14 @@ public static class KeyCacheExtensions
this KeyCacheService keyCacheService, this KeyCacheService keyCacheService,
AuthType authType, AuthType authType,
Snowflake userId, Snowflake userId,
string? instance = null,
CancellationToken ct = default CancellationToken ct = default
) )
{ {
var state = AuthUtils.RandomToken(); var state = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync( await keyCacheService.SetKeyAsync(
$"add_account:{state}", $"add_account:{state}",
new AddExtraAccountState(authType, userId), new AddExtraAccountState(authType, userId, instance),
Duration.FromDays(1), Duration.FromDays(1),
ct ct
); );
@ -93,4 +94,4 @@ public record RegisterEmailState(
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId
); );
public record AddExtraAccountState(AuthType AuthType, Snowflake UserId); public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null);

View file

@ -12,6 +12,7 @@
<PackageReference Include="Coravel.Mailer" Version="5.0.1"/> <PackageReference Include="Coravel.Mailer" Version="5.0.1"/>
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/> <PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
<PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/> <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="8.1.2"/>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="JetBrains.Annotations" Version="2024.2.0"/> <PackageReference Include="JetBrains.Annotations" Version="2024.2.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.7"/>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.7"/>

View file

@ -218,10 +218,11 @@ public class AuthService(
AuthType authType, AuthType authType,
string remoteId, string remoteId,
string? remoteUsername = null, string? remoteUsername = null,
FediverseApplication? app = null,
CancellationToken ct = default CancellationToken ct = default
) )
{ {
AssertValidAuthType(authType, null); AssertValidAuthType(authType, app);
// This is already checked when // This is already checked when
var currentCount = await db var currentCount = await db
@ -237,6 +238,7 @@ public class AuthService(
Id = snowflakeGenerator.GenerateSnowflake(), Id = snowflakeGenerator.GenerateSnowflake(),
AuthType = authType, AuthType = authType,
RemoteId = remoteId, RemoteId = remoteId,
FediverseApplicationId = app?.Id,
RemoteUsername = remoteUsername, RemoteUsername = remoteUsername,
UserId = userId, UserId = userId,
}; };

View file

@ -69,9 +69,10 @@ public partial class FediverseAuthService
private async Task<FediverseUser> GetMastodonUserAsync( private async Task<FediverseUser> GetMastodonUserAsync(
FediverseApplication app, FediverseApplication app,
string code, string code,
string state string? state = null
) )
{ {
if (state != null)
await _keyCacheService.ValidateAuthStateAsync(state); await _keyCacheService.ValidateAuthStateAsync(state);
var tokenResp = await _client.PostAsync( var tokenResp = await _client.PostAsync(
@ -120,7 +121,8 @@ public partial class FediverseAuthService
private async Task<string> GenerateMastodonAuthUrlAsync( private async Task<string> GenerateMastodonAuthUrlAsync(
FediverseApplication app, FediverseApplication app,
bool forceRefresh bool forceRefresh,
string? state = null
) )
{ {
if (forceRefresh) if (forceRefresh)
@ -132,7 +134,7 @@ public partial class FediverseAuthService
app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id); app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id);
} }
var state = HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync()); state ??= HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync());
return $"https://{app.Domain}/oauth/authorize?response_type=code" return $"https://{app.Domain}/oauth/authorize?response_type=code"
+ $"&client_id={app.ClientId}" + $"&client_id={app.ClientId}"

View file

@ -37,10 +37,14 @@ public partial class FediverseAuthService
_client.DefaultRequestHeaders.Add("Accept", "application/json"); _client.DefaultRequestHeaders.Add("Accept", "application/json");
} }
public async Task<string> GenerateAuthUrlAsync(string instance, bool forceRefresh) public async Task<string> GenerateAuthUrlAsync(
string instance,
bool forceRefresh,
string? state = null
)
{ {
var app = await GetApplicationAsync(instance); var app = await GetApplicationAsync(instance);
return await GenerateAuthUrlAsync(app, forceRefresh); return await GenerateAuthUrlAsync(app, forceRefresh, state);
} }
// thank you, gargron and syuilo, for agreeing on a name for *once* in your lives, // thank you, gargron and syuilo, for agreeing on a name for *once* in your lives,
@ -96,12 +100,17 @@ public partial class FediverseAuthService
); );
} }
private async Task<string> GenerateAuthUrlAsync(FediverseApplication app, bool forceRefresh) => private async Task<string> GenerateAuthUrlAsync(
FediverseApplication app,
bool forceRefresh,
string? state = null
) =>
app.InstanceType switch app.InstanceType switch
{ {
FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync( FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(
app, app,
forceRefresh forceRefresh,
state
), ),
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
@ -110,7 +119,7 @@ public partial class FediverseAuthService
public async Task<FediverseUser> GetRemoteFediverseUserAsync( public async Task<FediverseUser> GetRemoteFediverseUserAsync(
FediverseApplication app, FediverseApplication app,
string code, string code,
string state string? state = null
) => ) =>
app.InstanceType switch app.InstanceType switch
{ {

View file

@ -1,9 +1,21 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Web;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Utils;
using Humanizer;
using JetBrains.Annotations; using JetBrains.Annotations;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Services; namespace Foxnouns.Backend.Services.Auth;
public class RemoteAuthService(Config config, ILogger logger) public class RemoteAuthService(
Config config,
ILogger logger,
DatabaseContext db,
KeyCacheService keyCacheService
)
{ {
private readonly ILogger _logger = logger.ForContext<RemoteAuthService>(); private readonly ILogger _logger = logger.ForContext<RemoteAuthService>();
private readonly HttpClient _httpClient = new(); private readonly HttpClient _httpClient = new();
@ -76,4 +88,56 @@ public class RemoteAuthService(Config config, ILogger logger)
private record DiscordUserResponse(string id, string username); private record DiscordUserResponse(string id, string username);
public record RemoteUser(string Id, string Username); public record RemoteUser(string Id, string Username);
/// <summary>
/// Validates whether a user can still add a new account of the given AuthType, and throws an error if they can't.
/// </summary>
/// <param name="userId">The user to check.</param>
/// <param name="authType">The auth type to check.</param>
/// <param name="instance">The optional fediverse instance to generate a state for.</param>
/// <returns>A state for the given auth type and user ID.</returns>
/// <exception cref="ApiError.BadRequest">The given user can't add another account of this type.
/// This exception should not be caught by controller code.</exception>
public async Task<string> ValidateAddAccountRequestAsync(
Snowflake userId,
AuthType authType,
string? instance = null
)
{
var existingAccounts = await db
.AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType)
.CountAsync();
if (existingAccounts > AuthUtils.MaxAuthMethodsPerType)
{
throw new ApiError.BadRequest(
$"Too many linked {authType.Humanize()} accounts, maximum of {AuthUtils.MaxAuthMethodsPerType} per account."
);
}
return HttpUtility.UrlEncode(
await keyCacheService.GenerateAddExtraAccountStateAsync(authType, userId, instance)
);
}
/// <summary>
/// Checks whether the given state is correct for the given user/auth type combination.
/// </summary>
/// <exception cref="ApiError.BadRequest">The state doesn't match.
/// This exception should not be caught by controller code.</exception>
public async Task ValidateAddAccountStateAsync(
string state,
Snowflake userId,
AuthType authType,
string? instance = null
)
{
var accountState = await keyCacheService.GetAddExtraAccountStateAsync(state);
if (
accountState == null
|| accountState.AuthType != authType
|| accountState.UserId != userId
|| (instance != null && accountState.Instance != instance)
)
throw new ApiError.BadRequest("Invalid state", "state", state);
}
} }

View file

@ -127,6 +127,9 @@ public static partial class ValidationUtils
if (entries.Length > Limits.FieldEntriesLimit + 50) if (entries.Length > Limits.FieldEntriesLimit + 50)
return errors; return errors;
var customPreferenceIds =
customPreferences?.Keys.Select(id => id.ToString()).ToArray() ?? [];
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
{ {
switch (entry.Value.Length) switch (entry.Value.Length)
@ -159,8 +162,6 @@ public static partial class ValidationUtils
break; break;
} }
var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? [];
if ( if (
!DefaultStatusOptions.Contains(entry.Status) !DefaultStatusOptions.Contains(entry.Status)
&& !customPreferenceIds.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status)
@ -203,6 +204,9 @@ public static partial class ValidationUtils
if (entries.Length > Limits.FieldEntriesLimit + 50) if (entries.Length > Limits.FieldEntriesLimit + 50)
return errors; return errors;
var customPreferenceIds =
customPreferences?.Keys.Select(id => id.ToString()).ToList() ?? [];
foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx)))
{ {
switch (entry.Value.Length) switch (entry.Value.Length)
@ -268,8 +272,6 @@ public static partial class ValidationUtils
} }
} }
var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? [];
if ( if (
!DefaultStatusOptions.Contains(entry.Status) !DefaultStatusOptions.Contains(entry.Status)
&& !customPreferenceIds.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status)

View file

@ -45,6 +45,12 @@
"Npgsql": "8.0.1" "Npgsql": "8.0.1"
} }
}, },
"Humanizer.Core": {
"type": "Direct",
"requested": "[2.14.1, )",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"JetBrains.Annotations": { "JetBrains.Annotations": {
"type": "Direct", "type": "Direct",
"requested": "[2024.2.0, )", "requested": "[2024.2.0, )",
@ -291,11 +297,6 @@
"Microsoft.EntityFrameworkCore.Relational": "8.0.0" "Microsoft.EntityFrameworkCore.Relational": "8.0.0"
} }
}, },
"Humanizer.Core": {
"type": "Transitive",
"resolved": "2.14.1",
"contentHash": "lQKvtaTDOXnoVJ20ibTuSIOf2i0uO0MPbDhd1jm238I+U/2ZnRENj0cktKZhtchBMtCUSRQ5v4xBCUbKNmyVMw=="
},
"MailKit": { "MailKit": {
"type": "Transitive", "type": "Transitive",
"resolved": "2.5.1", "resolved": "2.5.1",

View file

@ -47,7 +47,8 @@
"successful-link-fedi": "Your account has successfully been linked to the following fediverse account:", "successful-link-fedi": "Your account has successfully been linked to the following fediverse account:",
"successful-link-profile-hint": "You now can close this page, or go back to your profile:", "successful-link-profile-hint": "You now can close this page, or go back to your profile:",
"successful-link-profile-link": "Go to your profile", "successful-link-profile-link": "Go to your profile",
"remote-discord-account-label": "Your Discord account" "remote-discord-account-label": "Your Discord account",
"log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)"
}, },
"error": { "error": {
"bad-request-header": "Something was wrong with your input", "bad-request-header": "Something was wrong with your input",

View file

@ -1,18 +1,39 @@
import { apiRequest } from "$api"; import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error"; import ApiError, { ErrorCode } from "$api/error";
import type { CallbackResponse } from "$api/models/auth.js"; import type { AddAccountResponse, CallbackResponse } from "$api/models/auth.js";
import { setToken } from "$lib"; import { setToken } from "$lib";
import createRegisterAction from "$lib/actions/register.js"; import createRegisterAction from "$lib/actions/register.js";
import { redirect } from "@sveltejs/kit"; import log from "$lib/log";
import { isRedirect, redirect } from "@sveltejs/kit";
export const load = async ({ parent, params, url, fetch, cookies }) => { export const load = async ({ parent, params, url, fetch, cookies }) => {
const { meUser } = await parent();
if (meUser) redirect(303, `/@${meUser.username}`);
const code = url.searchParams.get("code") as string | null; const code = url.searchParams.get("code") as string | null;
const state = url.searchParams.get("state") as string | null; const state = url.searchParams.get("state") as string | null;
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj; 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", { const resp = await apiRequest<CallbackResponse>("POST", "/auth/fediverse/callback", {
body: { code, state, instance: params.instance }, body: { code, state, instance: params.instance },
isInternal: true, isInternal: true,
@ -26,10 +47,16 @@ export const load = async ({ parent, params, url, fetch, cookies }) => {
return { return {
hasAccount: false, hasAccount: false,
instance: params.instance, isLinkRequest: false,
ticket: resp.ticket!, ticket: resp.ticket!,
remoteUser: resp.remote_username!, 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;
}
}; };
export const actions = { export const actions = {

View file

@ -1,7 +1,9 @@
<script lang="ts"> <script lang="ts">
import type { ActionData, PageData } from "./$types"; import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import Error from "$components/Error.svelte";
import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte"; import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte";
import NewAuthMethod from "$components/settings/NewAuthMethod.svelte";
type Props = { data: PageData; form: ActionData }; type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props(); let { data, form }: Props = $props();
@ -12,11 +14,18 @@
</svelte:head> </svelte:head>
<div class="container"> <div class="container">
{#if data.error}
<h1>{$t("auth.register-with-mastodon")}</h1>
<Error error={data.error} />
{:else if data.isLinkRequest}
<NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} />
{:else}
<OauthRegistrationForm <OauthRegistrationForm
title={$t("auth.register-with-mastodon")} title={$t("auth.register-with-mastodon")}
remoteLabel={$t("auth.remote-fediverse-account-label")} remoteLabel={$t("auth.remote-fediverse-account-label")}
remoteUser={data.remoteUser} remoteUser={data.remoteUser!}
ticket={data.ticket} ticket={data.ticket!}
error={form?.error} error={form?.error}
/> />
{/if}
</div> </div>

View file

@ -72,7 +72,11 @@
<h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4> <h4 class="mt-4">{$t("auth.log-in-with-the-fediverse")}</h4>
<form method="POST" action="?/fedi" use:enhance> <form method="POST" action="?/fedi" use:enhance>
<InputGroup> <InputGroup>
<Input name="instance" type="text" placeholder="Your instance (i.e. mastodon.social)" /> <Input
name="instance"
type="text"
placeholder={$t("auth.log-in-with-fediverse-instance-placeholder")}
/>
<Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button> <Button type="submit" color="secondary">{$t("auth.log-in-button")}</Button>
</InputGroup> </InputGroup>
<p> <p>

View file

@ -0,0 +1,37 @@
import { apiRequest } from "$api";
import { redirect } from "@sveltejs/kit";
export const actions = {
add: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const instance = body.get("instance") as string;
const { url } = await apiRequest<{ url: string }>(
"GET",
`/auth/fediverse/add-account?instance=${encodeURIComponent(instance)}`,
{
isInternal: true,
fetch,
cookies,
},
);
redirect(303, url);
},
forceRefresh: async ({ request, fetch, cookies }) => {
const body = await request.formData();
const instance = body.get("instance") as string;
const { url } = await apiRequest<{ url: string }>(
"GET",
`/auth/fediverse/add-account?instance=${encodeURIComponent(instance)}&forceRefresh=true`,
{
isInternal: true,
fetch,
cookies,
},
);
redirect(303, url);
},
};

View file

@ -0,0 +1,23 @@
<script lang="ts">
import { t } from "$lib/i18n";
import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap";
</script>
<h3>Link a new Fediverse account</h3>
<form method="POST" action="?/add">
<InputGroup>
<Input
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>
<p>
{$t("auth.log-in-with-fediverse-error-blurb")}
<Button formaction="?/forceRefresh" type="submit" color="link">
{$t("auth.log-in-with-fediverse-force-refresh-button")}
</Button>
</p>
</form>

View file

@ -12,6 +12,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Humanizer.Core" Version="2.14.1"/>
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0"/> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.7"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.7">