feat(frontend): register/log in with email

This commit is contained in:
sam 2024-12-04 17:43:02 +01:00
parent 57e1ec09c0
commit bc7fd6d804
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
19 changed files with 598 additions and 380 deletions

View file

@ -119,6 +119,20 @@ public class AuthController(
CurrentUser!.Id 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); db.Remove(authMethod);
await db.SaveChangesAsync(); await db.SaveChangesAsync();

View file

@ -1,3 +1,5 @@
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.Extensions; using Foxnouns.Backend.Extensions;
@ -26,8 +28,8 @@ public class EmailAuthController(
{ {
private readonly ILogger _logger = logger.ForContext<EmailAuthController>(); private readonly ILogger _logger = logger.ForContext<EmailAuthController>();
[HttpPost("register")] [HttpPost("register/init")]
public async Task<IActionResult> RegisterAsync( public async Task<IActionResult> RegisterInitAsync(
[FromBody] RegisterRequest req, [FromBody] RegisterRequest req,
CancellationToken ct = default CancellationToken ct = default
) )
@ -62,25 +64,9 @@ public class EmailAuthController(
CheckRequirements(); CheckRequirements();
var state = await keyCacheService.GetRegisterEmailStateAsync(req.State); 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); 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(); var ticket = AuthUtils.RandomToken();
await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20)); 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( public async Task<IActionResult> CompleteRegistrationAsync(
[FromBody] CompleteRegistrationRequest req [FromBody] CompleteRegistrationRequest req
) )
@ -107,12 +93,6 @@ public class EmailAuthController(
if (email == null) if (email == null)
throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket); 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 user = await authService.CreateUserWithPasswordAsync(req.Username, email, req.Password);
var frontendApp = await db.GetFrontendApplicationAsync(); 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("*")] [Authorize("*")]
public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req) public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req)
{ {
@ -204,12 +198,9 @@ public class EmailAuthController(
if (emails.Count != 0) if (emails.Count != 0)
{ {
var validPassword = await authService.ValidatePasswordAsync(CurrentUser!, req.Password); if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Password))
if (!validPassword)
{
throw new ApiError.Forbidden("Invalid password"); throw new ApiError.Forbidden("Invalid password");
} }
}
else else
{ {
await authService.SetUserPasswordAsync(CurrentUser!, req.Password); await authService.SetUserPasswordAsync(CurrentUser!, req.Password);
@ -233,6 +224,48 @@ public class EmailAuthController(
return NoContent(); 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); public record AddEmailAddressRequest(string Email, string Password);
private void CheckRequirements() private void CheckRequirements()
@ -248,4 +281,6 @@ public class EmailAuthController(
public record CompleteRegistrationRequest(string Ticket, string Username, string Password); public record CompleteRegistrationRequest(string Ticket, string Username, string Password);
public record CallbackRequest(string State); public record CallbackRequest(string State);
public record ChangePasswordRequest(string Current, string New);
} }

View file

@ -25,7 +25,7 @@ public static class KeyCacheExtensions
CancellationToken ct = default 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) if (val == null)
throw new ApiError.BadRequest("Invalid OAuth state"); throw new ApiError.BadRequest("Invalid OAuth state");
} }
@ -52,12 +52,7 @@ public static class KeyCacheExtensions
this KeyCacheService keyCacheService, this KeyCacheService keyCacheService,
string state, string state,
CancellationToken ct = default CancellationToken ct = default
) => ) => await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}", ct: ct);
await keyCacheService.GetKeyAsync<RegisterEmailState>(
$"email_state:{state}",
delete: true,
ct
);
public static async Task<string> GenerateAddExtraAccountStateAsync( public static async Task<string> GenerateAddExtraAccountStateAsync(
this KeyCacheService keyCacheService, this KeyCacheService keyCacheService,

View file

@ -31,6 +31,16 @@ public class AuthService(
CancellationToken ct = default 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 var user = new User
{ {
Id = snowflakeGenerator.GenerateSnowflake(), Id = snowflakeGenerator.GenerateSnowflake(),
@ -49,7 +59,7 @@ public class AuthService(
}; };
db.Add(user); db.Add(user);
user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); user.Password = await HashPasswordAsync(user, password, ct);
return user; return user;
} }
@ -70,6 +80,8 @@ public class AuthService(
{ {
AssertValidAuthType(authType, instance); 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)) if (await db.Users.AnyAsync(u => u.Username == username, ct))
throw new ApiError.BadRequest("Username is already taken", "username", username); throw new ApiError.BadRequest("Username is already taken", "username", username);
@ -121,10 +133,7 @@ public class AuthService(
ErrorCode.UserNotFound ErrorCode.UserNotFound
); );
var pwResult = await Task.Run( var pwResult = await VerifyHashedPasswordAsync(user, password, ct);
() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password),
ct
);
if (pwResult == PasswordVerificationResult.Failed) // TODO: this seems to fail on some valid passwords? if (pwResult == PasswordVerificationResult.Failed) // TODO: this seems to fail on some valid passwords?
throw new ApiError.NotFound( throw new ApiError.NotFound(
"No user with that email address found, or password is incorrect", "No user with that email address found, or password is incorrect",
@ -132,7 +141,7 @@ public class AuthService(
); );
if (pwResult == PasswordVerificationResult.SuccessRehashNeeded) 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); await db.SaveChangesAsync(ct);
} }
@ -160,10 +169,7 @@ public class AuthService(
throw new FoxnounsError("Password for user supplied to ValidatePasswordAsync was null"); throw new FoxnounsError("Password for user supplied to ValidatePasswordAsync was null");
} }
var pwResult = await Task.Run( var pwResult = await VerifyHashedPasswordAsync(user, password, ct);
() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password),
ct
);
return pwResult return pwResult
is PasswordVerificationResult.SuccessRehashNeeded is PasswordVerificationResult.SuccessRehashNeeded
or PasswordVerificationResult.Success; or PasswordVerificationResult.Success;
@ -178,7 +184,7 @@ public class AuthService(
CancellationToken ct = default CancellationToken ct = default
) )
{ {
user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct); user.Password = await HashPasswordAsync(user, password, ct);
db.Update(user); 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() private static (string, byte[]) GenerateToken()
{ {
var token = AuthUtils.RandomToken(); var token = AuthUtils.RandomToken();

View file

@ -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")] [GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
private static partial Regex UsernameRegex(); private static partial Regex UsernameRegex();

View file

@ -3,7 +3,7 @@
<p> <p>
Please continue creating a new pronouns.cc account by using the following link: Please continue creating a new pronouns.cc account by using the following link:
<br /> <br />
<a href="@Model.BaseUrl/auth/signup/confirm/@Model.Code">Confirm your email address</a> <a href="@Model.BaseUrl/auth/callback/email/@Model.Code">Confirm your email address</a>
<br /> <br />
Note that this link will expire in one hour. Note that this link will expire in one hour.
</p> </p>

View file

@ -3,7 +3,7 @@
<p> <p>
Hello @@@Model.Username, please confirm adding this email address to your account by using the following link: Hello @@@Model.Username, please confirm adding this email address to your account by using the following link:
<br /> <br />
<a href="@Model.BaseUrl/settings/auth/confirm-email/@Model.Code">Confirm your email address</a> <a href="@Model.BaseUrl/auth/callback/email/@Model.Code">Confirm your email address</a>
<br /> <br />
Note that this link will expire in one hour. Note that this link will expire in one hour.
</p> </p>

View 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;
}
};
}

View file

@ -4,8 +4,8 @@
import type { RawApiError } from "$api/error"; import type { RawApiError } from "$api/error";
import ErrorAlert from "$components/ErrorAlert.svelte"; import ErrorAlert from "$components/ErrorAlert.svelte";
type Props = { form: { error: RawApiError | null; ok: boolean } | null }; type Props = { form: { error: RawApiError | null; ok: boolean } | null; successMessage?: string };
let { form }: Props = $props(); let { form, successMessage }: Props = $props();
</script> </script>
{#if form?.error} {#if form?.error}
@ -13,6 +13,6 @@
{:else if form?.ok} {:else if form?.ok}
<p class="text-success-emphasis"> <p class="text-success-emphasis">
<Icon name="check-circle-fill" /> <Icon name="check-circle-fill" />
{$t("edit-profile.saved-changes")} {successMessage ?? $t("edit-profile.saved-changes")}
</p> </p>
{/if} {/if}

View file

@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { RawApiError } from "$api/error"; import type { RawApiError } from "$api/error";
import { enhance } from "$app/forms";
import ErrorAlert from "$components/ErrorAlert.svelte"; import ErrorAlert from "$components/ErrorAlert.svelte";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { Button, Input, Label } from "@sveltestrap/sveltestrap"; import { Button, Input, Label } from "@sveltestrap/sveltestrap";
@ -21,7 +20,7 @@
<ErrorAlert {error} /> <ErrorAlert {error} />
{/if} {/if}
<form method="POST" use:enhance> <form method="POST">
<div class="mb-3"> <div class="mb-3">
<Label>{remoteLabel}</Label> <Label>{remoteLabel}</Label>
<Input type="text" readonly value={remoteUser} /> <Input type="text" readonly value={remoteUser} />

View file

@ -48,7 +48,11 @@
"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)" "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": { "error": {
"bad-request-header": "Something was wrong with your input", "bad-request-header": "Something was wrong with your input",

View file

@ -1,63 +1,7 @@
import { apiRequest } from "$api"; import createCallbackLoader from "$lib/actions/callback";
import ApiError, { ErrorCode } from "$api/error"; import createRegisterAction from "$lib/actions/register";
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";
export const load = async ({ url, parent, fetch, cookies }) => { export const load = createCallbackLoader("discord");
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 actions = { export const actions = {
default: createRegisterAction("/auth/discord/register"), default: createRegisterAction("/auth/discord/register"),

View file

@ -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;
}
},
};

View file

@ -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>

View file

@ -1,64 +1,15 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode } from "$api/error"; import ApiError, { ErrorCode } from "$api/error";
import type { AddAccountResponse, CallbackResponse } from "$api/models/auth.js"; import createCallbackLoader from "$lib/actions/callback";
import { setToken } from "$lib"; import createRegisterAction from "$lib/actions/register";
import createRegisterAction from "$lib/actions/register.js";
import log from "$lib/log";
import { isRedirect, redirect } from "@sveltejs/kit";
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 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(); return { code, state, instance: params.instance! };
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;
}
};
export const actions = { export const actions = {
default: createRegisterAction("/auth/fediverse/register"), default: createRegisterAction("/auth/fediverse/register"),
}; };

View 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;
}
},
};

View 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>

View file

@ -3,6 +3,7 @@
import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap"; import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap";
</script> </script>
<div class="mx-auto w-lg-75">
<h3>Link a new Fediverse account</h3> <h3>Link a new Fediverse account</h3>
<form method="POST" action="?/add"> <form method="POST" action="?/add">
@ -21,3 +22,4 @@
</Button> </Button>
</p> </p>
</form> </form>
</div>

View file

@ -5,6 +5,7 @@
import { Icon } from "@sveltestrap/sveltestrap"; import { Icon } from "@sveltestrap/sveltestrap";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { enhance } from "$app/forms"; import { enhance } from "$app/forms";
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
type Props = { data: PageData; form: ActionData }; type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props(); let { data, form }: Props = $props();
@ -18,14 +19,7 @@
<div class="mx-auto w-lg-75"> <div class="mx-auto w-lg-75">
<h3>{$t("settings.export-title")}</h3> <h3>{$t("settings.export-title")}</h3>
{#if form?.ok} <FormStatusMarker {form} successMessage={$t("settings.export-request-success")} />
<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}
<p> <p>
{$t("settings.export-info")} {$t("settings.export-info")}