feat(frontend): register/log in with email
This commit is contained in:
parent
57e1ec09c0
commit
bc7fd6d804
19 changed files with 598 additions and 380 deletions
|
@ -119,6 +119,20 @@ public class AuthController(
|
|||
CurrentUser!.Id
|
||||
);
|
||||
|
||||
// If this is the user's last email, we should also clear the user's password.
|
||||
if (
|
||||
authMethod.AuthType == AuthType.Email
|
||||
&& authMethods.Count(a => a.AuthType == AuthType.Email) == 1
|
||||
)
|
||||
{
|
||||
_logger.Debug(
|
||||
"Deleted last email address for user {UserId}, resetting their password",
|
||||
CurrentUser.Id
|
||||
);
|
||||
CurrentUser.Password = null;
|
||||
db.Update(CurrentUser);
|
||||
}
|
||||
|
||||
db.Remove(authMethod);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
using System.Net;
|
||||
using EntityFramework.Exceptions.Common;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
|
@ -26,8 +28,8 @@ public class EmailAuthController(
|
|||
{
|
||||
private readonly ILogger _logger = logger.ForContext<EmailAuthController>();
|
||||
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> RegisterAsync(
|
||||
[HttpPost("register/init")]
|
||||
public async Task<IActionResult> RegisterInitAsync(
|
||||
[FromBody] RegisterRequest req,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
|
@ -62,25 +64,9 @@ public class EmailAuthController(
|
|||
CheckRequirements();
|
||||
|
||||
var state = await keyCacheService.GetRegisterEmailStateAsync(req.State);
|
||||
if (state == null)
|
||||
if (state is not { ExistingUserId: null })
|
||||
throw new ApiError.BadRequest("Invalid state", "state", req.State);
|
||||
|
||||
// If this callback is for an existing user, add the email address to their auth methods
|
||||
if (state.ExistingUserId != null)
|
||||
{
|
||||
var authMethod = await authService.AddAuthMethodAsync(
|
||||
state.ExistingUserId.Value,
|
||||
AuthType.Email,
|
||||
state.Email
|
||||
);
|
||||
_logger.Debug(
|
||||
"Added email auth {AuthId} for user {UserId}",
|
||||
authMethod.Id,
|
||||
state.ExistingUserId
|
||||
);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
var ticket = AuthUtils.RandomToken();
|
||||
await keyCacheService.SetKeyAsync($"email:{ticket}", state.Email, Duration.FromMinutes(20));
|
||||
|
||||
|
@ -96,7 +82,7 @@ public class EmailAuthController(
|
|||
);
|
||||
}
|
||||
|
||||
[HttpPost("complete-registration")]
|
||||
[HttpPost("register")]
|
||||
public async Task<IActionResult> CompleteRegistrationAsync(
|
||||
[FromBody] CompleteRegistrationRequest req
|
||||
)
|
||||
|
@ -107,12 +93,6 @@ public class EmailAuthController(
|
|||
if (email == null)
|
||||
throw new ApiError.BadRequest("Unknown ticket", "ticket", req.Ticket);
|
||||
|
||||
// Check if username is valid at all
|
||||
ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(req.Username))]);
|
||||
// Check if username is already taken
|
||||
if (await db.Users.AnyAsync(u => u.Username == req.Username))
|
||||
throw new ApiError.BadRequest("Username is already taken", "username", req.Username);
|
||||
|
||||
var user = await authService.CreateUserWithPasswordAsync(req.Username, email, req.Password);
|
||||
var frontendApp = await db.GetFrontendApplicationAsync();
|
||||
|
||||
|
@ -184,7 +164,21 @@ public class EmailAuthController(
|
|||
);
|
||||
}
|
||||
|
||||
[HttpPost("add")]
|
||||
[HttpPost("change-password")]
|
||||
[Authorize("*")]
|
||||
public async Task<IActionResult> UpdatePasswordAsync([FromBody] ChangePasswordRequest req)
|
||||
{
|
||||
if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Current))
|
||||
throw new ApiError.Forbidden("Invalid password");
|
||||
|
||||
ValidationUtils.Validate([("new", ValidationUtils.ValidatePassword(req.New))]);
|
||||
|
||||
await authService.SetUserPasswordAsync(CurrentUser!, req.New);
|
||||
await db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("add-email")]
|
||||
[Authorize("*")]
|
||||
public async Task<IActionResult> AddEmailAddressAsync([FromBody] AddEmailAddressRequest req)
|
||||
{
|
||||
|
@ -204,11 +198,8 @@ public class EmailAuthController(
|
|||
|
||||
if (emails.Count != 0)
|
||||
{
|
||||
var validPassword = await authService.ValidatePasswordAsync(CurrentUser!, req.Password);
|
||||
if (!validPassword)
|
||||
{
|
||||
if (!await authService.ValidatePasswordAsync(CurrentUser!, req.Password))
|
||||
throw new ApiError.Forbidden("Invalid password");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -233,6 +224,48 @@ public class EmailAuthController(
|
|||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPost("add-email/callback")]
|
||||
[Authorize("*")]
|
||||
public async Task<IActionResult> AddEmailCallbackAsync([FromBody] CallbackRequest req)
|
||||
{
|
||||
CheckRequirements();
|
||||
|
||||
var state = await keyCacheService.GetRegisterEmailStateAsync(req.State);
|
||||
if (state?.ExistingUserId != CurrentUser!.Id)
|
||||
throw new ApiError.BadRequest("Invalid state", "state", req.State);
|
||||
|
||||
try
|
||||
{
|
||||
var authMethod = await authService.AddAuthMethodAsync(
|
||||
CurrentUser.Id,
|
||||
AuthType.Email,
|
||||
state.Email
|
||||
);
|
||||
_logger.Debug(
|
||||
"Added email auth {AuthId} for user {UserId}",
|
||||
authMethod.Id,
|
||||
CurrentUser.Id
|
||||
);
|
||||
|
||||
return Ok(
|
||||
new AuthController.AddOauthAccountResponse(
|
||||
authMethod.Id,
|
||||
AuthType.Email,
|
||||
authMethod.RemoteId,
|
||||
RemoteUsername: null
|
||||
)
|
||||
);
|
||||
}
|
||||
catch (UniqueConstraintException)
|
||||
{
|
||||
throw new ApiError(
|
||||
"That email address is already linked.",
|
||||
HttpStatusCode.BadRequest,
|
||||
ErrorCode.AccountAlreadyLinked
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public record AddEmailAddressRequest(string Email, string Password);
|
||||
|
||||
private void CheckRequirements()
|
||||
|
@ -248,4 +281,6 @@ public class EmailAuthController(
|
|||
public record CompleteRegistrationRequest(string Ticket, string Username, string Password);
|
||||
|
||||
public record CallbackRequest(string State);
|
||||
|
||||
public record ChangePasswordRequest(string Current, string New);
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ public static class KeyCacheExtensions
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", delete: true, ct);
|
||||
var val = await keyCacheService.GetKeyAsync($"oauth_state:{state}", ct: ct);
|
||||
if (val == null)
|
||||
throw new ApiError.BadRequest("Invalid OAuth state");
|
||||
}
|
||||
|
@ -52,12 +52,7 @@ public static class KeyCacheExtensions
|
|||
this KeyCacheService keyCacheService,
|
||||
string state,
|
||||
CancellationToken ct = default
|
||||
) =>
|
||||
await keyCacheService.GetKeyAsync<RegisterEmailState>(
|
||||
$"email_state:{state}",
|
||||
delete: true,
|
||||
ct
|
||||
);
|
||||
) => await keyCacheService.GetKeyAsync<RegisterEmailState>($"email_state:{state}", ct: ct);
|
||||
|
||||
public static async Task<string> GenerateAddExtraAccountStateAsync(
|
||||
this KeyCacheService keyCacheService,
|
||||
|
|
|
@ -31,6 +31,16 @@ public class AuthService(
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
// Validate username and whether it's not taken
|
||||
ValidationUtils.Validate(
|
||||
[
|
||||
("username", ValidationUtils.ValidateUsername(username)),
|
||||
("password", ValidationUtils.ValidatePassword(password)),
|
||||
]
|
||||
);
|
||||
if (await db.Users.AnyAsync(u => u.Username == username, ct))
|
||||
throw new ApiError.BadRequest("Username is already taken", "username", username);
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||
|
@ -49,7 +59,7 @@ public class AuthService(
|
|||
};
|
||||
|
||||
db.Add(user);
|
||||
user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct);
|
||||
user.Password = await HashPasswordAsync(user, password, ct);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
@ -70,6 +80,8 @@ public class AuthService(
|
|||
{
|
||||
AssertValidAuthType(authType, instance);
|
||||
|
||||
// Validate username and whether it's not taken
|
||||
ValidationUtils.Validate([("username", ValidationUtils.ValidateUsername(username))]);
|
||||
if (await db.Users.AnyAsync(u => u.Username == username, ct))
|
||||
throw new ApiError.BadRequest("Username is already taken", "username", username);
|
||||
|
||||
|
@ -121,10 +133,7 @@ public class AuthService(
|
|||
ErrorCode.UserNotFound
|
||||
);
|
||||
|
||||
var pwResult = await Task.Run(
|
||||
() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password),
|
||||
ct
|
||||
);
|
||||
var pwResult = await VerifyHashedPasswordAsync(user, password, ct);
|
||||
if (pwResult == PasswordVerificationResult.Failed) // TODO: this seems to fail on some valid passwords?
|
||||
throw new ApiError.NotFound(
|
||||
"No user with that email address found, or password is incorrect",
|
||||
|
@ -132,7 +141,7 @@ public class AuthService(
|
|||
);
|
||||
if (pwResult == PasswordVerificationResult.SuccessRehashNeeded)
|
||||
{
|
||||
user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct);
|
||||
user.Password = await HashPasswordAsync(user, password, ct);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
|
@ -160,10 +169,7 @@ public class AuthService(
|
|||
throw new FoxnounsError("Password for user supplied to ValidatePasswordAsync was null");
|
||||
}
|
||||
|
||||
var pwResult = await Task.Run(
|
||||
() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password),
|
||||
ct
|
||||
);
|
||||
var pwResult = await VerifyHashedPasswordAsync(user, password, ct);
|
||||
return pwResult
|
||||
is PasswordVerificationResult.SuccessRehashNeeded
|
||||
or PasswordVerificationResult.Success;
|
||||
|
@ -178,7 +184,7 @@ public class AuthService(
|
|||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct);
|
||||
user.Password = await HashPasswordAsync(user, password, ct);
|
||||
db.Update(user);
|
||||
}
|
||||
|
||||
|
@ -316,6 +322,22 @@ public class AuthService(
|
|||
);
|
||||
}
|
||||
|
||||
private Task<string> HashPasswordAsync(
|
||||
User user,
|
||||
string password,
|
||||
CancellationToken ct = default
|
||||
) => Task.Run(() => _passwordHasher.HashPassword(user, password), ct);
|
||||
|
||||
private Task<PasswordVerificationResult> VerifyHashedPasswordAsync(
|
||||
User user,
|
||||
string providedPassword,
|
||||
CancellationToken ct = default
|
||||
) =>
|
||||
Task.Run(
|
||||
() => _passwordHasher.VerifyHashedPassword(user, user.Password!, providedPassword),
|
||||
ct
|
||||
);
|
||||
|
||||
private static (string, byte[]) GenerateToken()
|
||||
{
|
||||
var token = AuthUtils.RandomToken();
|
||||
|
|
|
@ -185,6 +185,27 @@ public static partial class ValidationUtils
|
|||
};
|
||||
}
|
||||
|
||||
public const int MinimumPasswordLength = 12;
|
||||
public const int MaximumPasswordLength = 1024;
|
||||
|
||||
public static ValidationError? ValidatePassword(string password) =>
|
||||
password.Length switch
|
||||
{
|
||||
< MinimumPasswordLength => ValidationError.LengthError(
|
||||
"Password is too short",
|
||||
MinimumPasswordLength,
|
||||
MaximumPasswordLength,
|
||||
password.Length
|
||||
),
|
||||
> MaximumPasswordLength => ValidationError.LengthError(
|
||||
"Password is too long",
|
||||
MinimumPasswordLength,
|
||||
MaximumPasswordLength,
|
||||
password.Length
|
||||
),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
[GeneratedRegex(@"^[a-zA-Z_0-9\-\.]{2,40}$", RegexOptions.IgnoreCase, "en-NL")]
|
||||
private static partial Regex UsernameRegex();
|
||||
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
<p>
|
||||
Please continue creating a new pronouns.cc account by using the following link:
|
||||
<br/>
|
||||
<a href="@Model.BaseUrl/auth/signup/confirm/@Model.Code">Confirm your email address</a>
|
||||
<br/>
|
||||
<br />
|
||||
<a href="@Model.BaseUrl/auth/callback/email/@Model.Code">Confirm your email address</a>
|
||||
<br />
|
||||
Note that this link will expire in one hour.
|
||||
</p>
|
||||
<p>
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
<p>
|
||||
Hello @@@Model.Username, please confirm adding this email address to your account by using the following link:
|
||||
<br/>
|
||||
<a href="@Model.BaseUrl/settings/auth/confirm-email/@Model.Code">Confirm your email address</a>
|
||||
<br/>
|
||||
<br />
|
||||
<a href="@Model.BaseUrl/auth/callback/email/@Model.Code">Confirm your email address</a>
|
||||
<br />
|
||||
Note that this link will expire in one hour.
|
||||
</p>
|
||||
<p>
|
||||
|
|
69
Foxnouns.Frontend/src/lib/actions/callback.ts
Normal file
69
Foxnouns.Frontend/src/lib/actions/callback.ts
Normal file
|
@ -0,0 +1,69 @@
|
|||
import { apiRequest } from "$api";
|
||||
import ApiError, { ErrorCode } from "$api/error";
|
||||
import type { AddAccountResponse, CallbackResponse } from "$api/models";
|
||||
import { setToken } from "$lib";
|
||||
import log from "$lib/log";
|
||||
import { isRedirect, redirect, type ServerLoadEvent } from "@sveltejs/kit";
|
||||
|
||||
export default function createCallbackLoader(
|
||||
callbackType: string,
|
||||
bodyFn?: (event: ServerLoadEvent) => Promise<unknown>,
|
||||
) {
|
||||
return async (event: ServerLoadEvent) => {
|
||||
const { url, parent, fetch, cookies } = event;
|
||||
|
||||
bodyFn ??= async ({ url }) => {
|
||||
const code = url.searchParams.get("code") as string | null;
|
||||
const state = url.searchParams.get("state") as string | null;
|
||||
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
||||
return { code, state };
|
||||
};
|
||||
|
||||
const { meUser } = await parent();
|
||||
if (meUser) {
|
||||
try {
|
||||
const resp = await apiRequest<AddAccountResponse>(
|
||||
"POST",
|
||||
`/auth/${callbackType}/add-account/callback`,
|
||||
{
|
||||
isInternal: true,
|
||||
body: await bodyFn(event),
|
||||
fetch,
|
||||
cookies,
|
||||
},
|
||||
);
|
||||
|
||||
return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj };
|
||||
log.error("error linking new %s account to user %s:", callbackType, meUser.id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await apiRequest<CallbackResponse>("POST", `/auth/${callbackType}/callback`, {
|
||||
body: await bodyFn(event),
|
||||
isInternal: true,
|
||||
fetch,
|
||||
});
|
||||
|
||||
if (resp.has_account) {
|
||||
setToken(cookies, resp.token!);
|
||||
redirect(303, `/@${resp.user!.username}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccount: false,
|
||||
isLinkRequest: false,
|
||||
ticket: resp.ticket!,
|
||||
remoteUser: resp.remote_username!,
|
||||
};
|
||||
} catch (e) {
|
||||
if (isRedirect(e)) throw e;
|
||||
if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj };
|
||||
log.error("error while requesting %s callback:", callbackType, e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
|
@ -4,8 +4,8 @@
|
|||
import type { RawApiError } from "$api/error";
|
||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||
|
||||
type Props = { form: { error: RawApiError | null; ok: boolean } | null };
|
||||
let { form }: Props = $props();
|
||||
type Props = { form: { error: RawApiError | null; ok: boolean } | null; successMessage?: string };
|
||||
let { form, successMessage }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if form?.error}
|
||||
|
@ -13,6 +13,6 @@
|
|||
{:else if form?.ok}
|
||||
<p class="text-success-emphasis">
|
||||
<Icon name="check-circle-fill" />
|
||||
{$t("edit-profile.saved-changes")}
|
||||
{successMessage ?? $t("edit-profile.saved-changes")}
|
||||
</p>
|
||||
{/if}
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
<script lang="ts">
|
||||
import type { RawApiError } from "$api/error";
|
||||
import { enhance } from "$app/forms";
|
||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import { Button, Input, Label } from "@sveltestrap/sveltestrap";
|
||||
|
@ -21,7 +20,7 @@
|
|||
<ErrorAlert {error} />
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance>
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<Label>{remoteLabel}</Label>
|
||||
<Input type="text" readonly value={remoteUser} />
|
||||
|
|
|
@ -1,184 +1,188 @@
|
|||
{
|
||||
"hello": "Hello, {{name}}!",
|
||||
"nav": {
|
||||
"log-in": "Log in or sign up",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"avatar-tooltip": "Avatar for {{name}}",
|
||||
"profile": {
|
||||
"edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.",
|
||||
"edit-user-profile-notice": "You are currently viewing your public profile.",
|
||||
"edit-profile-link": "Edit profile",
|
||||
"names-header": "Names",
|
||||
"pronouns-header": "Pronouns",
|
||||
"default-members-header": "Members",
|
||||
"create-member-button": "Create member",
|
||||
"back-to-user": "Back to {{name}}"
|
||||
},
|
||||
"title": {
|
||||
"log-in": "Log in",
|
||||
"welcome": "Welcome",
|
||||
"settings": "Settings",
|
||||
"an-error-occurred": "An error occurred"
|
||||
},
|
||||
"auth": {
|
||||
"log-in-form-title": "Log in with email",
|
||||
"log-in-form-email-label": "Email address",
|
||||
"log-in-form-password-label": "Password",
|
||||
"register-with-email-button": "Register with email",
|
||||
"log-in-button": "Log in",
|
||||
"log-in-3rd-party-header": "Log in with another service",
|
||||
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
|
||||
"log-in-with-discord": "Log in with Discord",
|
||||
"log-in-with-google": "Log in with Google",
|
||||
"log-in-with-tumblr": "Log in with Tumblr",
|
||||
"log-in-with-the-fediverse": "Log in with the Fediverse",
|
||||
"remote-fediverse-account-label": "Your Fediverse account",
|
||||
"register-username-label": "Username",
|
||||
"register-button": "Register account",
|
||||
"register-with-mastodon": "Register with a Fediverse account",
|
||||
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
|
||||
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end",
|
||||
"register-with-discord": "Register with a Discord account",
|
||||
"new-auth-method-added": "Successfully added authentication method!",
|
||||
"successful-link-discord": "Your account has successfully been linked to the following Discord account:",
|
||||
"successful-link-google": "Your account has successfully been linked to the following Google account:",
|
||||
"successful-link-tumblr": "Your account has successfully been linked to the following Tumblr 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-link": "Go to your profile",
|
||||
"remote-discord-account-label": "Your Discord account",
|
||||
"log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)"
|
||||
},
|
||||
"error": {
|
||||
"bad-request-header": "Something was wrong with your input",
|
||||
"generic-header": "Something went wrong",
|
||||
"raw-header": "Raw error",
|
||||
"authentication-error": "Something went wrong when logging you in.",
|
||||
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
|
||||
"forbidden": "You are not allowed to perform that action.",
|
||||
"internal-server-error": "Server experienced an internal error, please try again later.",
|
||||
"authentication-required": "You need to log in first.",
|
||||
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
|
||||
"generic-error": "An unknown error occurred.",
|
||||
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
|
||||
"member-not-found": "Member not found, please check your spelling and try again.",
|
||||
"account-already-linked": "This account is already linked with a pronouns.cc account.",
|
||||
"last-auth-method": "You cannot remove your last authentication method.",
|
||||
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
|
||||
"validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.",
|
||||
"validation-disallowed-value-1": "The following value is not allowed here",
|
||||
"validation-disallowed-value-2": "Allowed values are",
|
||||
"validation-reason": "Reason",
|
||||
"validation-generic": "The value you entered is not allowed here. Reason",
|
||||
"extra-info-header": "Extra error information",
|
||||
"noscript-title": "This page requires JavaScript",
|
||||
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
|
||||
"noscript-short": "Requires JavaScript",
|
||||
"404-description": "The page you were trying to visit was not found. If you're sure the page should exist, check your address bar for any typos.",
|
||||
"back-to-profile-button": "Go back to your profile",
|
||||
"back-to-main-page-button": "Go back to the main page",
|
||||
"back-to-prev-page-button": "Go back to the previous page",
|
||||
"400-description": "Something went wrong with your request. This error should never land you on this page, so it's probably a bug.",
|
||||
"500-description": "Something went wrong on the server. Please try again later.",
|
||||
"unknown-status-description": "Something went wrong, but we're not sure what. Please try again."
|
||||
},
|
||||
"settings": {
|
||||
"general-information-tab": "General information",
|
||||
"your-profile-tab": "Your profile",
|
||||
"members-tab": "Members",
|
||||
"authentication-tab": "Authentication",
|
||||
"export-tab": "Export your data",
|
||||
"change-username-button": "Change username",
|
||||
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
|
||||
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
|
||||
"change-avatar-link": "Change your avatar here",
|
||||
"new-username": "New username",
|
||||
"table-role": "Role",
|
||||
"table-custom-preferences": "Custom preferences",
|
||||
"table-member-list-hidden": "Member list hidden?",
|
||||
"table-member-count": "Member count",
|
||||
"table-created-at": "Account created at",
|
||||
"table-id": "Your ID",
|
||||
"table-title": "Account information",
|
||||
"force-log-out-title": "Log out everywhere",
|
||||
"force-log-out-button": "Force log out",
|
||||
"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
|
||||
"log-out-title": "Log out",
|
||||
"log-out-hint": "Use this button to log out on this device only.",
|
||||
"log-out-button": "Log out",
|
||||
"avatar": "Avatar",
|
||||
"username-update-success": "Successfully changed your username!",
|
||||
"create-member-title": "Create a new member",
|
||||
"create-member-name-label": "Member name",
|
||||
"auth-remove-method": "Remove",
|
||||
"force-log-out-warning": "Make sure you're still able to log in before using this!",
|
||||
"force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page.",
|
||||
"export-request-success": "Successfully requested a new export! Please note that it may take a few minutes to complete, especially if you have a lot of members.",
|
||||
"export-title": "Request a copy of your data",
|
||||
"export-info": "You can request a copy of your data once every 24 hours. Exports are stored for 15 days (a little over two weeks) and then deleted.",
|
||||
"export-expires-at": "(expires {{expiresAt}})",
|
||||
"export-download": "Download export",
|
||||
"export-request-button": "Request a new export"
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"edit-profile": {
|
||||
"user-header": "Editing your profile",
|
||||
"general-tab": "General",
|
||||
"names-pronouns-tab": "Names & pronouns",
|
||||
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
|
||||
"sid-current": "Current short ID:",
|
||||
"sid": "Short ID",
|
||||
"sid-reroll": "Reroll short ID",
|
||||
"sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.",
|
||||
"sid-copy": "Copy short link",
|
||||
"update-avatar": "Update avatar",
|
||||
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
|
||||
"member-header-label": "\"Members\" header text",
|
||||
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
|
||||
"hide-member-list-label": "Hide member list",
|
||||
"timezone-label": "Timezone",
|
||||
"timezone-preview": "This will show up on your profile like this:",
|
||||
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
|
||||
"hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.",
|
||||
"profile-options-header": "Profile options",
|
||||
"bio-tab": "Bio",
|
||||
"saved-changes": "Successfully saved changes!",
|
||||
"bio-length-hint": "Using {{length}}/{{maxLength}} characters",
|
||||
"preview": "Preview",
|
||||
"fields-tab": "Fields",
|
||||
"flags-links-tab": "Flags & links",
|
||||
"back-to-settings-tab": "Back to settings",
|
||||
"member-header": "Editing profile of {{name}}",
|
||||
"username": "Username",
|
||||
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.",
|
||||
"change-username-link": "Go to settings",
|
||||
"member-name": "Name",
|
||||
"change-member-name": "Change name",
|
||||
"display-name": "Display name",
|
||||
"unlisted-label": "Hide from member list",
|
||||
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
|
||||
"edit-names-pronouns-header": "Edit names and pronouns",
|
||||
"back-to-profile-tab": "Back to profile",
|
||||
"editing-fields-header": "Editing fields"
|
||||
},
|
||||
"save-changes": "Save changes",
|
||||
"change": "Change",
|
||||
"editor": {
|
||||
"remove-entry": "Remove entry",
|
||||
"move-entry-down": "Move entry down",
|
||||
"move-entry-up": "Move entry up",
|
||||
"add-entry": "Add entry",
|
||||
"change-display-text": "Change display text",
|
||||
"display-text-example": "Optional display text (e.g. it/its)",
|
||||
"display-text-label": "Display text",
|
||||
"display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set.",
|
||||
"move-field-up": "Move field up",
|
||||
"move-field-down": "Move field down",
|
||||
"remove-field": "Remove field",
|
||||
"field-name": "Field name",
|
||||
"add-field": "Add field",
|
||||
"new-entry": "New entry"
|
||||
}
|
||||
"hello": "Hello, {{name}}!",
|
||||
"nav": {
|
||||
"log-in": "Log in or sign up",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"avatar-tooltip": "Avatar for {{name}}",
|
||||
"profile": {
|
||||
"edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.",
|
||||
"edit-user-profile-notice": "You are currently viewing your public profile.",
|
||||
"edit-profile-link": "Edit profile",
|
||||
"names-header": "Names",
|
||||
"pronouns-header": "Pronouns",
|
||||
"default-members-header": "Members",
|
||||
"create-member-button": "Create member",
|
||||
"back-to-user": "Back to {{name}}"
|
||||
},
|
||||
"title": {
|
||||
"log-in": "Log in",
|
||||
"welcome": "Welcome",
|
||||
"settings": "Settings",
|
||||
"an-error-occurred": "An error occurred"
|
||||
},
|
||||
"auth": {
|
||||
"log-in-form-title": "Log in with email",
|
||||
"log-in-form-email-label": "Email address",
|
||||
"log-in-form-password-label": "Password",
|
||||
"register-with-email-button": "Register with email",
|
||||
"log-in-button": "Log in",
|
||||
"log-in-3rd-party-header": "Log in with another service",
|
||||
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
|
||||
"log-in-with-discord": "Log in with Discord",
|
||||
"log-in-with-google": "Log in with Google",
|
||||
"log-in-with-tumblr": "Log in with Tumblr",
|
||||
"log-in-with-the-fediverse": "Log in with the Fediverse",
|
||||
"remote-fediverse-account-label": "Your Fediverse account",
|
||||
"register-username-label": "Username",
|
||||
"register-button": "Register account",
|
||||
"register-with-mastodon": "Register with a Fediverse account",
|
||||
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?",
|
||||
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end",
|
||||
"register-with-discord": "Register with a Discord account",
|
||||
"new-auth-method-added": "Successfully added authentication method!",
|
||||
"successful-link-discord": "Your account has successfully been linked to the following Discord account:",
|
||||
"successful-link-google": "Your account has successfully been linked to the following Google account:",
|
||||
"successful-link-tumblr": "Your account has successfully been linked to the following Tumblr 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-link": "Go to your profile",
|
||||
"remote-discord-account-label": "Your Discord account",
|
||||
"log-in-with-fediverse-instance-placeholder": "Your instance (i.e. mastodon.social)",
|
||||
"register-with-email": "Register with an email address",
|
||||
"email-label": "Your email address",
|
||||
"confirm-password-label": "Confirm password",
|
||||
"register-with-email-init-success": "Success! An email has been sent to your inbox, please press the link there to continue."
|
||||
},
|
||||
"error": {
|
||||
"bad-request-header": "Something was wrong with your input",
|
||||
"generic-header": "Something went wrong",
|
||||
"raw-header": "Raw error",
|
||||
"authentication-error": "Something went wrong when logging you in.",
|
||||
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
|
||||
"forbidden": "You are not allowed to perform that action.",
|
||||
"internal-server-error": "Server experienced an internal error, please try again later.",
|
||||
"authentication-required": "You need to log in first.",
|
||||
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
|
||||
"generic-error": "An unknown error occurred.",
|
||||
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
|
||||
"member-not-found": "Member not found, please check your spelling and try again.",
|
||||
"account-already-linked": "This account is already linked with a pronouns.cc account.",
|
||||
"last-auth-method": "You cannot remove your last authentication method.",
|
||||
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.",
|
||||
"validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.",
|
||||
"validation-disallowed-value-1": "The following value is not allowed here",
|
||||
"validation-disallowed-value-2": "Allowed values are",
|
||||
"validation-reason": "Reason",
|
||||
"validation-generic": "The value you entered is not allowed here. Reason",
|
||||
"extra-info-header": "Extra error information",
|
||||
"noscript-title": "This page requires JavaScript",
|
||||
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
|
||||
"noscript-short": "Requires JavaScript",
|
||||
"404-description": "The page you were trying to visit was not found. If you're sure the page should exist, check your address bar for any typos.",
|
||||
"back-to-profile-button": "Go back to your profile",
|
||||
"back-to-main-page-button": "Go back to the main page",
|
||||
"back-to-prev-page-button": "Go back to the previous page",
|
||||
"400-description": "Something went wrong with your request. This error should never land you on this page, so it's probably a bug.",
|
||||
"500-description": "Something went wrong on the server. Please try again later.",
|
||||
"unknown-status-description": "Something went wrong, but we're not sure what. Please try again."
|
||||
},
|
||||
"settings": {
|
||||
"general-information-tab": "General information",
|
||||
"your-profile-tab": "Your profile",
|
||||
"members-tab": "Members",
|
||||
"authentication-tab": "Authentication",
|
||||
"export-tab": "Export your data",
|
||||
"change-username-button": "Change username",
|
||||
"username-change-hint": "Changing your username will make any existing links to your or your members' profiles invalid.\nYour username must be unique, be at most 40 characters long, and only contain letters from the basic English alphabet, dashes, underscores, and periods. Your username is used as part of your profile link, you can set a separate display name.",
|
||||
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
|
||||
"change-avatar-link": "Change your avatar here",
|
||||
"new-username": "New username",
|
||||
"table-role": "Role",
|
||||
"table-custom-preferences": "Custom preferences",
|
||||
"table-member-list-hidden": "Member list hidden?",
|
||||
"table-member-count": "Member count",
|
||||
"table-created-at": "Account created at",
|
||||
"table-id": "Your ID",
|
||||
"table-title": "Account information",
|
||||
"force-log-out-title": "Log out everywhere",
|
||||
"force-log-out-button": "Force log out",
|
||||
"force-log-out-hint": "If you think one of your tokens might have been compromised, you can log out on all devices by clicking this button.",
|
||||
"log-out-title": "Log out",
|
||||
"log-out-hint": "Use this button to log out on this device only.",
|
||||
"log-out-button": "Log out",
|
||||
"avatar": "Avatar",
|
||||
"username-update-success": "Successfully changed your username!",
|
||||
"create-member-title": "Create a new member",
|
||||
"create-member-name-label": "Member name",
|
||||
"auth-remove-method": "Remove",
|
||||
"force-log-out-warning": "Make sure you're still able to log in before using this!",
|
||||
"force-log-out-confirmation": "Are you sure you want to log out from all devices? If you just want to log out from this device, click the \"Log out\" button on your settings page.",
|
||||
"export-request-success": "Successfully requested a new export! Please note that it may take a few minutes to complete, especially if you have a lot of members.",
|
||||
"export-title": "Request a copy of your data",
|
||||
"export-info": "You can request a copy of your data once every 24 hours. Exports are stored for 15 days (a little over two weeks) and then deleted.",
|
||||
"export-expires-at": "(expires {{expiresAt}})",
|
||||
"export-download": "Download export",
|
||||
"export-request-button": "Request a new export"
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"edit-profile": {
|
||||
"user-header": "Editing your profile",
|
||||
"general-tab": "General",
|
||||
"names-pronouns-tab": "Names & pronouns",
|
||||
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
|
||||
"sid-current": "Current short ID:",
|
||||
"sid": "Short ID",
|
||||
"sid-reroll": "Reroll short ID",
|
||||
"sid-hint": "This ID is used in prns.cc links. You can reroll one short ID every hour (shared between your main profile and all members) by pressing the button above.",
|
||||
"sid-copy": "Copy short link",
|
||||
"update-avatar": "Update avatar",
|
||||
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
|
||||
"member-header-label": "\"Members\" header text",
|
||||
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
|
||||
"hide-member-list-label": "Hide member list",
|
||||
"timezone-label": "Timezone",
|
||||
"timezone-preview": "This will show up on your profile like this:",
|
||||
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
|
||||
"hide-member-list-info": "This only hides your member list. Individual members will still be visible to anyone with a direct link to their pages.",
|
||||
"profile-options-header": "Profile options",
|
||||
"bio-tab": "Bio",
|
||||
"saved-changes": "Successfully saved changes!",
|
||||
"bio-length-hint": "Using {{length}}/{{maxLength}} characters",
|
||||
"preview": "Preview",
|
||||
"fields-tab": "Fields",
|
||||
"flags-links-tab": "Flags & links",
|
||||
"back-to-settings-tab": "Back to settings",
|
||||
"member-header": "Editing profile of {{name}}",
|
||||
"username": "Username",
|
||||
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.",
|
||||
"change-username-link": "Go to settings",
|
||||
"member-name": "Name",
|
||||
"change-member-name": "Change name",
|
||||
"display-name": "Display name",
|
||||
"unlisted-label": "Hide from member list",
|
||||
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
|
||||
"edit-names-pronouns-header": "Edit names and pronouns",
|
||||
"back-to-profile-tab": "Back to profile",
|
||||
"editing-fields-header": "Editing fields"
|
||||
},
|
||||
"save-changes": "Save changes",
|
||||
"change": "Change",
|
||||
"editor": {
|
||||
"remove-entry": "Remove entry",
|
||||
"move-entry-down": "Move entry down",
|
||||
"move-entry-up": "Move entry up",
|
||||
"add-entry": "Add entry",
|
||||
"change-display-text": "Change display text",
|
||||
"display-text-example": "Optional display text (e.g. it/its)",
|
||||
"display-text-label": "Display text",
|
||||
"display-text-info": "This is the short text shown on your profile page. If you leave it empty, it will default to the first two forms of the full set.",
|
||||
"move-field-up": "Move field up",
|
||||
"move-field-down": "Move field down",
|
||||
"remove-field": "Remove field",
|
||||
"field-name": "Field name",
|
||||
"add-field": "Add field",
|
||||
"new-entry": "New entry"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,63 +1,7 @@
|
|||
import { apiRequest } from "$api";
|
||||
import ApiError, { ErrorCode } from "$api/error";
|
||||
import type { AddAccountResponse, CallbackResponse } from "$api/models/auth";
|
||||
import { setToken } from "$lib";
|
||||
import createRegisterAction from "$lib/actions/register.js";
|
||||
import log from "$lib/log.js";
|
||||
import { isRedirect, redirect } from "@sveltejs/kit";
|
||||
import createCallbackLoader from "$lib/actions/callback";
|
||||
import createRegisterAction from "$lib/actions/register";
|
||||
|
||||
export const load = async ({ url, parent, fetch, cookies }) => {
|
||||
const code = url.searchParams.get("code") as string | null;
|
||||
const state = url.searchParams.get("state") as string | null;
|
||||
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
||||
|
||||
const { meUser } = await parent();
|
||||
if (meUser) {
|
||||
try {
|
||||
const resp = await apiRequest<AddAccountResponse>(
|
||||
"POST",
|
||||
"/auth/discord/add-account/callback",
|
||||
{
|
||||
isInternal: true,
|
||||
body: { code, state },
|
||||
fetch,
|
||||
cookies,
|
||||
},
|
||||
);
|
||||
|
||||
return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj };
|
||||
log.error("error linking new discord account to user %s:", meUser.id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await apiRequest<CallbackResponse>("POST", "/auth/discord/callback", {
|
||||
body: { code, state },
|
||||
isInternal: true,
|
||||
fetch,
|
||||
});
|
||||
|
||||
if (resp.has_account) {
|
||||
setToken(cookies, resp.token!);
|
||||
redirect(303, `/@${resp.user!.username}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccount: false,
|
||||
isLinkRequest: false,
|
||||
ticket: resp.ticket!,
|
||||
remoteUser: resp.remote_username!,
|
||||
};
|
||||
} catch (e) {
|
||||
if (isRedirect(e)) throw e;
|
||||
if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj };
|
||||
log.error("error while requesting discord callback:", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
export const load = createCallbackLoader("discord");
|
||||
|
||||
export const actions = {
|
||||
default: createRegisterAction("/auth/discord/register"),
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import { apiRequest } from "$api";
|
||||
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
|
||||
import type { AuthResponse } from "$api/models/auth";
|
||||
import { setToken } from "$lib";
|
||||
import createCallbackLoader from "$lib/actions/callback";
|
||||
import log from "$lib/log";
|
||||
import { redirect, isRedirect } from "@sveltejs/kit";
|
||||
|
||||
export const load = createCallbackLoader("email", async ({ params }) => {
|
||||
log.info("params:", params, "code:", params.code);
|
||||
|
||||
return { state: params.code! };
|
||||
});
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, fetch, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const username = data.get("username") as string | null;
|
||||
const ticket = data.get("ticket") as string | null;
|
||||
const password = data.get("password") as string | null;
|
||||
const password2 = data.get("confirm-password") as string | null;
|
||||
|
||||
if (!username || !ticket || !password || !password2)
|
||||
return {
|
||||
error: { message: "Bad request", code: ErrorCode.BadRequest, status: 400 } as RawApiError,
|
||||
};
|
||||
|
||||
if (password !== password2)
|
||||
return {
|
||||
error: {
|
||||
message: "Passwords do not match",
|
||||
code: ErrorCode.BadRequest,
|
||||
status: 400,
|
||||
} as RawApiError,
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await apiRequest<AuthResponse>("POST", "/auth/email/register", {
|
||||
body: { username, ticket, password },
|
||||
isInternal: true,
|
||||
fetch,
|
||||
});
|
||||
|
||||
setToken(cookies, resp.token);
|
||||
redirect(303, "/auth/welcome");
|
||||
} catch (e) {
|
||||
if (isRedirect(e)) throw e;
|
||||
log.error("Could not sign up user with username %s:", username, e);
|
||||
if (e instanceof ApiError) return { error: e.obj };
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
};
|
|
@ -0,0 +1,51 @@
|
|||
<script lang="ts">
|
||||
import Error from "$components/Error.svelte";
|
||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||
import NewAuthMethod from "$components/settings/NewAuthMethod.svelte";
|
||||
import { t } from "$lib/i18n";
|
||||
import { Label, Input, Button } from "@sveltestrap/sveltestrap";
|
||||
import type { ActionData, PageData } from "./$types";
|
||||
|
||||
type Props = { data: PageData; form: ActionData };
|
||||
let { data, form }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t("auth.register-with-email")} • pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
{#if data.error}
|
||||
<h1>{$t("auth.register-with-email")}</h1>
|
||||
<Error error={data.error} />
|
||||
{:else if data.isLinkRequest}
|
||||
<NewAuthMethod method={data.newAuthMethod!} user={data.meUser!} />
|
||||
{:else}
|
||||
<h1>{$t("auth.register-with-email")}</h1>
|
||||
|
||||
{#if form?.error}
|
||||
<ErrorAlert error={form.error} />
|
||||
{/if}
|
||||
|
||||
<form method="POST">
|
||||
<div class="mb-3">
|
||||
<Label>{$t("auth.email-label")}</Label>
|
||||
<Input type="text" readonly value={data.remoteUser} />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<Label>{$t("auth.register-username-label")}</Label>
|
||||
<Input type="text" name="username" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<Label>{$t("auth.log-in-form-password-label")}</Label>
|
||||
<Input type="password" name="password" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<Label>{$t("auth.confirm-password-label")}</Label>
|
||||
<Input type="password" name="confirm-password" required />
|
||||
</div>
|
||||
<input type="hidden" name="ticket" value={data.ticket!} />
|
||||
<Button color="primary" type="submit">{$t("auth.register-button")}</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
|
@ -1,63 +1,14 @@
|
|||
import { apiRequest } from "$api";
|
||||
import ApiError, { ErrorCode } from "$api/error";
|
||||
import type { AddAccountResponse, CallbackResponse } from "$api/models/auth.js";
|
||||
import { setToken } from "$lib";
|
||||
import createRegisterAction from "$lib/actions/register.js";
|
||||
import log from "$lib/log";
|
||||
import { isRedirect, redirect } from "@sveltejs/kit";
|
||||
import createCallbackLoader from "$lib/actions/callback";
|
||||
import createRegisterAction from "$lib/actions/register";
|
||||
|
||||
export const load = async ({ parent, params, url, fetch, cookies }) => {
|
||||
export const load = createCallbackLoader("fediverse", async ({ params, url }) => {
|
||||
const code = url.searchParams.get("code") as string | null;
|
||||
const state = url.searchParams.get("state") as string | null;
|
||||
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
||||
|
||||
const { meUser } = await parent();
|
||||
if (meUser) {
|
||||
try {
|
||||
const resp = await apiRequest<AddAccountResponse>(
|
||||
"POST",
|
||||
"/auth/fediverse/add-account/callback",
|
||||
{
|
||||
isInternal: true,
|
||||
body: { code, state, instance: params.instance },
|
||||
fetch,
|
||||
cookies,
|
||||
},
|
||||
);
|
||||
|
||||
return { hasAccount: true, isLinkRequest: true, newAuthMethod: resp };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { isLinkRequest: true, error: e.obj };
|
||||
log.error("error linking new fediverse account to user %s:", meUser.id, e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await apiRequest<CallbackResponse>("POST", "/auth/fediverse/callback", {
|
||||
body: { code, state, instance: params.instance },
|
||||
isInternal: true,
|
||||
fetch,
|
||||
});
|
||||
|
||||
if (resp.has_account) {
|
||||
setToken(cookies, resp.token!);
|
||||
redirect(303, `/@${resp.user!.username}`);
|
||||
}
|
||||
|
||||
return {
|
||||
hasAccount: false,
|
||||
isLinkRequest: false,
|
||||
ticket: resp.ticket!,
|
||||
remoteUser: resp.remote_username!,
|
||||
};
|
||||
} catch (e) {
|
||||
if (isRedirect(e)) throw e;
|
||||
if (e instanceof ApiError) return { isLinkRequest: false, error: e.obj };
|
||||
log.error("error while requesting fediverse callback:", e);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
return { code, state, instance: params.instance! };
|
||||
});
|
||||
|
||||
export const actions = {
|
||||
default: createRegisterAction("/auth/fediverse/register"),
|
||||
|
|
35
Foxnouns.Frontend/src/routes/auth/register/+page.server.ts
Normal file
35
Foxnouns.Frontend/src/routes/auth/register/+page.server.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { apiRequest, fastRequest } from "$api";
|
||||
import ApiError from "$api/error.js";
|
||||
import type { AuthUrls } from "$api/models/auth";
|
||||
import log from "$lib/log.js";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export const load = async ({ fetch, parent }) => {
|
||||
const parentData = await parent();
|
||||
if (parentData.meUser) redirect(303, `/@${parentData.meUser.username}`);
|
||||
|
||||
const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true });
|
||||
if (!urls.email_enabled) redirect(303, "/");
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, fetch, cookies }) => {
|
||||
const body = await request.formData();
|
||||
const email = body.get("email") as string;
|
||||
|
||||
try {
|
||||
await fastRequest("POST", `/auth/email/register/init`, {
|
||||
body: { email },
|
||||
isInternal: true,
|
||||
fetch,
|
||||
cookies,
|
||||
});
|
||||
|
||||
return { ok: true, error: null };
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError) return { ok: false, error: e.obj };
|
||||
log.error("error initiating registration for email %s:", email, e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
};
|
29
Foxnouns.Frontend/src/routes/auth/register/+page.svelte
Normal file
29
Foxnouns.Frontend/src/routes/auth/register/+page.svelte
Normal file
|
@ -0,0 +1,29 @@
|
|||
<script lang="ts">
|
||||
import type { ActionData, PageData } from "./$types";
|
||||
import { t } from "$lib/i18n";
|
||||
import { enhance } from "$app/forms";
|
||||
import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap";
|
||||
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||
|
||||
type Props = { data: PageData; form: ActionData };
|
||||
let { data, form }: Props = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t("auth.register-with-email")} • pronouns.cc</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<div class="mx-auto w-lg-75">
|
||||
<h2>{$t("auth.register-with-email")}</h2>
|
||||
|
||||
<FormStatusMarker {form} successMessage={$t("auth.register-with-email-init-success")} />
|
||||
|
||||
<form method="POST" use:enhance>
|
||||
<InputGroup>
|
||||
<Input name="email" type="email" placeholder={$t("auth.email-label")} />
|
||||
<Button type="submit" color="secondary">{$t("auth.register-with-email-button")}</Button>
|
||||
</InputGroup>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
|
@ -3,21 +3,23 @@
|
|||
import { Button, Input, InputGroup } from "@sveltestrap/sveltestrap";
|
||||
</script>
|
||||
|
||||
<h3>Link a new Fediverse account</h3>
|
||||
<div class="mx-auto w-lg-75">
|
||||
<h3>Link a new Fediverse account</h3>
|
||||
|
||||
<form method="POST" action="?/add">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
import { Icon } from "@sveltestrap/sveltestrap";
|
||||
import { t } from "$lib/i18n";
|
||||
import { enhance } from "$app/forms";
|
||||
import FormStatusMarker from "$components/editor/FormStatusMarker.svelte";
|
||||
|
||||
type Props = { data: PageData; form: ActionData };
|
||||
let { data, form }: Props = $props();
|
||||
|
@ -18,14 +19,7 @@
|
|||
<div class="mx-auto w-lg-75">
|
||||
<h3>{$t("settings.export-title")}</h3>
|
||||
|
||||
{#if form?.ok}
|
||||
<p class="text-success-emphasis">
|
||||
<Icon name="check-circle-fill" />
|
||||
{$t("settings.export-request-success")}
|
||||
</p>
|
||||
{:else if form?.error}
|
||||
<ErrorAlert error={form.error} />
|
||||
{/if}
|
||||
<FormStatusMarker {form} successMessage={$t("settings.export-request-success")} />
|
||||
|
||||
<p>
|
||||
{$t("settings.export-info")}
|
||||
|
|
Loading…
Reference in a new issue