Compare commits

..

2 commits

Author SHA1 Message Date
sam
de733a0682
feat(frontend): discord registration/login/linking
also moves the registration form found on the mastodon callback page
into a component so we're not repeating the same code for every auth method
2024-11-28 21:37:30 +01:00
sam
4780be3019
fix(backend): add unique index to auth methods 2024-11-28 21:29:25 +01:00
22 changed files with 618 additions and 212 deletions

View file

@ -5,6 +5,7 @@ using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;
using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Middleware;
using Foxnouns.Backend.Services; using Foxnouns.Backend.Services;
using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -56,9 +57,10 @@ public class AuthController(
public record AddOauthAccountResponse( public record AddOauthAccountResponse(
Snowflake Id, Snowflake Id,
AuthType Type, [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
string RemoteId, string RemoteId,
string? RemoteUsername [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
string? RemoteUsername
); );
public record OauthRegisterRequest(string Ticket, string Username); public record OauthRegisterRequest(string Ticket, string Username);

View file

@ -27,7 +27,8 @@ public class MetaController : ApiControllerBase
new Limits( new Limits(
MemberCount: MembersController.MaxMemberCount, MemberCount: MembersController.MaxMemberCount,
BioLength: ValidationUtils.MaxBioLength, BioLength: ValidationUtils.MaxBioLength,
CustomPreferences: ValidationUtils.MaxCustomPreferences CustomPreferences: ValidationUtils.MaxCustomPreferences,
MaxAuthMethods: AuthUtils.MaxAuthMethodsPerType
) )
) )
); );
@ -49,5 +50,10 @@ public class MetaController : ApiControllerBase
private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay); private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay);
// All limits that the frontend should know about (for UI purposes) // All limits that the frontend should know about (for UI purposes)
private record Limits(int MemberCount, int BioLength, int CustomPreferences); private record Limits(
int MemberCount,
int BioLength,
int CustomPreferences,
int MaxAuthMethods
);
} }

View file

@ -71,6 +71,22 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique(); modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique(); modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique();
modelBuilder
.Entity<AuthMethod>()
.HasIndex(m => new
{
m.AuthType,
m.RemoteId,
m.FediverseApplicationId,
})
.HasFilter("fediverse_application_id IS NOT NULL")
.IsUnique();
modelBuilder
.Entity<AuthMethod>()
.HasIndex(m => new { m.AuthType, m.RemoteId })
.HasFilter("fediverse_application_id IS NULL")
.IsUnique();
modelBuilder.Entity<User>().Property(u => u.Sid).HasDefaultValueSql("find_free_user_sid()"); modelBuilder.Entity<User>().Property(u => u.Sid).HasDefaultValueSql("find_free_user_sid()");
modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb"); modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb");

View file

@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241128202508_AddAuthMethodUniqueIndex")]
public partial class AddAuthMethodUniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "ix_auth_methods_auth_type_remote_id",
table: "auth_methods",
columns: new[] { "auth_type", "remote_id" },
unique: true,
filter: "fediverse_application_id IS NULL"
);
migrationBuilder.CreateIndex(
name: "ix_auth_methods_auth_type_remote_id_fediverse_application_id",
table: "auth_methods",
columns: new[] { "auth_type", "remote_id", "fediverse_application_id" },
unique: true,
filter: "fediverse_application_id IS NOT NULL"
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "ix_auth_methods_auth_type_remote_id",
table: "auth_methods"
);
migrationBuilder.DropIndex(
name: "ix_auth_methods_auth_type_remote_id_fediverse_application_id",
table: "auth_methods"
);
}
}
}

View file

@ -98,6 +98,16 @@ namespace Foxnouns.Backend.Database.Migrations
b.HasIndex("UserId") b.HasIndex("UserId")
.HasDatabaseName("ix_auth_methods_user_id"); .HasDatabaseName("ix_auth_methods_user_id");
b.HasIndex("AuthType", "RemoteId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id")
.HasFilter("fediverse_application_id IS NULL");
b.HasIndex("AuthType", "RemoteId", "FediverseApplicationId")
.IsUnique()
.HasDatabaseName("ix_auth_methods_auth_type_remote_id_fediverse_application_id")
.HasFilter("fediverse_application_id IS NOT NULL");
b.ToTable("auth_methods", (string)null); b.ToTable("auth_methods", (string)null);
}); });

View file

@ -223,6 +223,15 @@ public class AuthService(
{ {
AssertValidAuthType(authType, null); AssertValidAuthType(authType, null);
// This is already checked when
var currentCount = await db
.AuthMethods.Where(m => m.UserId == userId && m.AuthType == authType)
.CountAsync(ct);
if (currentCount >= AuthUtils.MaxAuthMethodsPerType)
throw new ApiError.BadRequest(
"Too many linked accounts of this type, maximum of 3 per account."
);
var authMethod = new AuthMethod var authMethod = new AuthMethod
{ {
Id = snowflakeGenerator.GenerateSnowflake(), Id = snowflakeGenerator.GenerateSnowflake(),

View file

@ -0,0 +1,35 @@
import { apiRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import type { AuthResponse } from "$api/models/auth";
import { setToken } from "$lib";
import log from "$lib/log";
import { isRedirect, redirect, type RequestEvent } from "@sveltejs/kit";
export default function createRegisterAction(callbackUrl: string) {
return async function ({ request, fetch, cookies }: RequestEvent) {
const data = await request.formData();
const username = data.get("username") as string | null;
const ticket = data.get("ticket") as string | null;
if (!username || !ticket)
return {
error: { message: "Bad request", code: ErrorCode.BadRequest, status: 403 } as RawApiError,
};
try {
const resp = await apiRequest<AuthResponse>("POST", callbackUrl, {
body: { username, ticket },
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

@ -1,4 +1,4 @@
import type { User } from "./user"; import type { AuthType, User } from "./user";
export type AuthResponse = { export type AuthResponse = {
user: User; user: User;
@ -21,3 +21,10 @@ export type AuthUrls = {
google?: string; google?: string;
tumblr?: string; tumblr?: string;
}; };
export type AddAccountResponse = {
id: string;
type: AuthType;
remote_id: string;
remote_username?: string;
};

View file

@ -16,4 +16,5 @@ export type Limits = {
member_count: number; member_count: number;
bio_length: number; bio_length: number;
custom_preferences: number; custom_preferences: number;
max_auth_methods: number;
}; };

View file

@ -71,9 +71,11 @@ export type PrideFlag = {
description: string | null; description: string | null;
}; };
export type AuthType = "DISCORD" | "GOOGLE" | "TUMBLR" | "FEDIVERSE" | "EMAIL";
export type AuthMethod = { export type AuthMethod = {
id: string; id: string;
type: "DISCORD" | "GOOGLE" | "TUMBLR" | "FEDIVERSE" | "EMAIL"; type: AuthType;
remote_id: string; remote_id: string;
remote_username?: string; remote_username?: string;
}; };

View file

@ -0,0 +1,24 @@
<script lang="ts">
import type { AuthMethod } from "$api/models";
import AuthMethodRow from "./AuthMethodRow.svelte";
type Props = {
methods: AuthMethod[];
canRemove: boolean;
max: number;
buttonLink: string;
buttonText: string;
};
let { methods, canRemove, max, buttonLink, buttonText }: Props = $props();
</script>
{#if methods.length > 0}
<div class="list-group mb-3">
{#each methods as method (method.id)}
<AuthMethodRow {method} {canRemove} />
{/each}
</div>
{/if}
{#if methods.length < max}
<a class="btn btn-primary mb-3" href={buttonLink}>{buttonText}</a>
{/if}

View file

@ -0,0 +1,26 @@
<script lang="ts">
import { t } from "$lib/i18n";
import type { AuthMethod } from "$api/models";
type Props = { method: AuthMethod; canRemove: boolean };
let { method, canRemove }: Props = $props();
let name = $derived(
method.type === "EMAIL" ? method.remote_id : (method.remote_username ?? method.remote_id),
);
let showId = $derived(method.type !== "FEDIVERSE");
</script>
<div class="list-group-item">
<div class="row">
<div class="col">
{name}
{#if showId}({method.remote_id}){/if}
</div>
{#if canRemove}
<div class="col text-end">
<a href="/settings/auth/remove-method/{method.id}">{$t("settings.auth-remove-method")}</a>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,34 @@
<script lang="ts">
import type { AuthMethod, PartialUser } from "$api/models";
import { t } from "$lib/i18n";
type Props = { method: AuthMethod; user: PartialUser };
let { method, user }: Props = $props();
let name = $derived(
method.type === "EMAIL" ? method.remote_id : (method.remote_username ?? method.remote_id),
);
let text = $derived.by(() => {
switch (method.type) {
case "DISCORD":
return $t("auth.successful-link-discord");
case "GOOGLE":
return $t("auth.successful-link-google");
case "TUMBLR":
return $t("auth.successful-link-tumblr");
case "FEDIVERSE":
return $t("auth.successful-link-fedi");
default:
return "<you shouldn't see this!>";
}
});
</script>
<h1>{$t("auth.new-auth-method-added")}</h1>
<p>{text} <code>{name}</code></p>
<p>{$t("auth.successful-link-profile-hint")}</p>
<p>
<a class="btn btn-primary" href="/@{user.username}">{$t("auth.successful-link-profile-link")}</a>
</p>

View file

@ -0,0 +1,35 @@
<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";
type Props = {
title: string;
remoteLabel: string;
remoteUser: string;
ticket: string;
error?: RawApiError;
};
let { title, remoteLabel, remoteUser, ticket, error }: Props = $props();
</script>
<h1>{title}</h1>
{#if error}
<ErrorAlert {error} />
{/if}
<form method="POST" use:enhance>
<div class="mb-3">
<Label>{remoteLabel}</Label>
<Input type="text" readonly value={remoteUser} />
</div>
<div class="mb-3">
<Label>{$t("auth.register-username-label")}</Label>
<Input type="text" name="username" required />
</div>
<input type="hidden" name="ticket" value={ticket} />
<Button color="primary" type="submit">{$t("auth.register-button")}</Button>
</form>

View file

@ -1,157 +1,168 @@
{ {
"hello": "Hello, {{name}}!", "hello": "Hello, {{name}}!",
"nav": { "nav": {
"log-in": "Log in or sign up", "log-in": "Log in or sign up",
"settings": "Settings" "settings": "Settings"
}, },
"avatar-tooltip": "Avatar for {{name}}", "avatar-tooltip": "Avatar for {{name}}",
"profile": { "profile": {
"edit-member-profile-notice": "You are currently viewing the public profile of {{memberName}}.", "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-user-profile-notice": "You are currently viewing your public profile.",
"edit-profile-link": "Edit profile", "edit-profile-link": "Edit profile",
"names-header": "Names", "names-header": "Names",
"pronouns-header": "Pronouns", "pronouns-header": "Pronouns",
"default-members-header": "Members", "default-members-header": "Members",
"create-member-button": "Create member", "create-member-button": "Create member",
"back-to-user": "Back to {{name}}" "back-to-user": "Back to {{name}}"
}, },
"title": { "title": {
"log-in": "Log in", "log-in": "Log in",
"welcome": "Welcome", "welcome": "Welcome",
"settings": "Settings" "settings": "Settings",
}, "an-error-occurred": "An error occurred"
"auth": { },
"log-in-form-title": "Log in with email", "auth": {
"log-in-form-email-label": "Email address", "log-in-form-title": "Log in with email",
"log-in-form-password-label": "Password", "log-in-form-email-label": "Email address",
"register-with-email-button": "Register with email", "log-in-form-password-label": "Password",
"log-in-button": "Log in", "register-with-email-button": "Register with email",
"log-in-3rd-party-header": "Log in with another service", "log-in-button": "Log in",
"log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:", "log-in-3rd-party-header": "Log in with another service",
"log-in-with-discord": "Log in with Discord", "log-in-3rd-party-desc": "If you prefer, you can also log in with one of these services:",
"log-in-with-google": "Log in with Google", "log-in-with-discord": "Log in with Discord",
"log-in-with-tumblr": "Log in with Tumblr", "log-in-with-google": "Log in with Google",
"log-in-with-the-fediverse": "Log in with the Fediverse", "log-in-with-tumblr": "Log in with Tumblr",
"remote-fediverse-account-label": "Your Fediverse account", "log-in-with-the-fediverse": "Log in with the Fediverse",
"register-username-label": "Username", "remote-fediverse-account-label": "Your Fediverse account",
"register-button": "Register account", "register-username-label": "Username",
"register-with-mastodon": "Register with a Fediverse account", "register-button": "Register account",
"log-in-with-fediverse-error-blurb": "Is your instance returning an error?", "register-with-mastodon": "Register with a Fediverse account",
"log-in-with-fediverse-force-refresh-button": "Force a refresh on our end" "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",
"error": { "register-with-discord": "Register with a Discord account",
"bad-request-header": "Something was wrong with your input", "new-auth-method-added": "Successfully added authentication method!",
"generic-header": "Something went wrong", "successful-link-discord": "Your account has successfully been linked to the following Discord account:",
"raw-header": "Raw error", "successful-link-google": "Your account has successfully been linked to the following Google account:",
"authentication-error": "Something went wrong when logging you in.", "successful-link-tumblr": "Your account has successfully been linked to the following Tumblr account:",
"bad-request": "Your input was rejected by the server, please check for any mistakes and try again.", "successful-link-fedi": "Your account has successfully been linked to the following fediverse account:",
"forbidden": "You are not allowed to perform that action.", "successful-link-profile-hint": "You now can close this page, or go back to your profile:",
"internal-server-error": "Server experienced an internal error, please try again later.", "successful-link-profile-link": "Go to your profile",
"authentication-required": "You need to log in first.", "remote-discord-account-label": "Your Discord account"
"missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?", },
"generic-error": "An unknown error occurred.", "error": {
"user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.", "bad-request-header": "Something was wrong with your input",
"member-not-found": "Member not found, please check your spelling and try again.", "generic-header": "Something went wrong",
"account-already-linked": "This account is already linked with a pronouns.cc account.", "raw-header": "Raw error",
"last-auth-method": "You cannot remove your last authentication method.", "authentication-error": "Something went wrong when logging you in.",
"validation-max-length-error": "Value is too long, maximum length is {{max}}, current length is {{actual}}.", "bad-request": "Your input was rejected by the server, please check for any mistakes and try again.",
"validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.", "forbidden": "You are not allowed to perform that action.",
"validation-disallowed-value-1": "The following value is not allowed here", "internal-server-error": "Server experienced an internal error, please try again later.",
"validation-disallowed-value-2": "Allowed values are", "authentication-required": "You need to log in first.",
"validation-reason": "Reason", "missing-scopes": "The current token is missing a required scope. Did you manually edit your cookies?",
"validation-generic": "The value you entered is not allowed here. Reason", "generic-error": "An unknown error occurred.",
"extra-info-header": "Extra error information", "user-not-found": "User not found, please check your spelling and try again. Remember that usernames are case sensitive.",
"noscript-title": "This page requires JavaScript", "member-not-found": "Member not found, please check your spelling and try again.",
"noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.", "account-already-linked": "This account is already linked with a pronouns.cc account.",
"noscript-short": "Requires JavaScript" "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}}.",
"settings": { "validation-min-length-error": "Value is too short, minimum length is {{min}}, current length is {{actual}}.",
"general-information-tab": "General information", "validation-disallowed-value-1": "The following value is not allowed here",
"your-profile-tab": "Your profile", "validation-disallowed-value-2": "Allowed values are",
"members-tab": "Members", "validation-reason": "Reason",
"authentication-tab": "Authentication", "validation-generic": "The value you entered is not allowed here. Reason",
"export-tab": "Export your data", "extra-info-header": "Extra error information",
"change-username-button": "Change username", "noscript-title": "This page requires JavaScript",
"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.", "noscript-info": "This page requires JavaScript to function correctly. Some buttons may not work, or the page may not work at all.",
"username-update-error": "Could not update your username as the new username is invalid:\n{{message}}", "noscript-short": "Requires JavaScript"
"change-avatar-link": "Change your avatar here", },
"new-username": "New username", "settings": {
"table-role": "Role", "general-information-tab": "General information",
"table-custom-preferences": "Custom preferences", "your-profile-tab": "Your profile",
"table-member-list-hidden": "Member list hidden?", "members-tab": "Members",
"table-member-count": "Member count", "authentication-tab": "Authentication",
"table-created-at": "Account created at", "export-tab": "Export your data",
"table-id": "Your ID", "change-username-button": "Change username",
"table-title": "Account information", "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.",
"force-log-out-title": "Log out everywhere", "username-update-error": "Could not update your username as the new username is invalid:\n{{message}}",
"force-log-out-button": "Force log out", "change-avatar-link": "Change your avatar here",
"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.", "new-username": "New username",
"log-out-title": "Log out", "table-role": "Role",
"log-out-hint": "Use this button to log out on this device only.", "table-custom-preferences": "Custom preferences",
"log-out-button": "Log out", "table-member-list-hidden": "Member list hidden?",
"avatar": "Avatar", "table-member-count": "Member count",
"username-update-success": "Successfully changed your username!", "table-created-at": "Account created at",
"create-member-title": "Create a new member", "table-id": "Your ID",
"create-member-name-label": "Member name" "table-title": "Account information",
}, "force-log-out-title": "Log out everywhere",
"yes": "Yes", "force-log-out-button": "Force log out",
"no": "No", "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.",
"edit-profile": { "log-out-title": "Log out",
"user-header": "Editing your profile", "log-out-hint": "Use this button to log out on this device only.",
"general-tab": "General", "log-out-button": "Log out",
"names-pronouns-tab": "Names & pronouns", "avatar": "Avatar",
"file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})", "username-update-success": "Successfully changed your username!",
"sid-current": "Current short ID:", "create-member-title": "Create a new member",
"sid": "Short ID", "create-member-name-label": "Member name",
"sid-reroll": "Reroll short ID", "auth-remove-method": "Remove"
"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", "yes": "Yes",
"update-avatar": "Update avatar", "no": "No",
"avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.", "edit-profile": {
"member-header-label": "\"Members\" header text", "user-header": "Editing your profile",
"member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.", "general-tab": "General",
"hide-member-list-label": "Hide member list", "names-pronouns-tab": "Names & pronouns",
"timezone-label": "Timezone", "file-too-large": "This file is too large, please resize it (maximum is {{max}}, the file you're trying to upload is {{current}})",
"timezone-preview": "This will show up on your profile like this:", "sid-current": "Current short ID:",
"timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.", "sid": "Short ID",
"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.", "sid-reroll": "Reroll short ID",
"profile-options-header": "Profile options", "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.",
"bio-tab": "Bio", "sid-copy": "Copy short link",
"saved-changes": "Successfully saved changes!", "update-avatar": "Update avatar",
"bio-length-hint": "Using {{length}}/{{maxLength}} characters", "avatar-updated": "Avatar updated! It might take a moment to be reflected on your profile.",
"preview": "Preview", "member-header-label": "\"Members\" header text",
"fields-tab": "Fields", "member-header-info": "This is the text used for the \"Members\" heading. If you leave it blank, the default text will be used.",
"flags-links-tab": "Flags & links", "hide-member-list-label": "Hide member list",
"back-to-settings-tab": "Back to settings", "timezone-label": "Timezone",
"member-header": "Editing profile of {{name}}", "timezone-preview": "This will show up on your profile like this:",
"username": "Username", "timezone-info": "This is optional. Your timezone is never shared directly, only the difference between UTC and your current timezone is.",
"change-username-info": "As changing your username will also change all of your members' links, you can only change it in your account settings.", "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.",
"change-username-link": "Go to settings", "profile-options-header": "Profile options",
"member-name": "Name", "bio-tab": "Bio",
"change-member-name": "Change name", "saved-changes": "Successfully saved changes!",
"display-name": "Display name", "bio-length-hint": "Using {{length}}/{{maxLength}} characters",
"unlisted-label": "Hide from member list", "preview": "Preview",
"unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:", "fields-tab": "Fields",
"edit-names-pronouns-header": "Edit names and pronouns", "flags-links-tab": "Flags & links",
"back-to-profile-tab": "Back to profile", "back-to-settings-tab": "Back to settings",
"editing-fields-header": "Editing fields" "member-header": "Editing profile of {{name}}",
}, "username": "Username",
"save-changes": "Save changes", "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": "Change", "change-username-link": "Go to settings",
"editor": { "member-name": "Name",
"remove-entry": "Remove entry", "change-member-name": "Change name",
"move-entry-down": "Move entry down", "display-name": "Display name",
"move-entry-up": "Move entry up", "unlisted-label": "Hide from member list",
"add-entry": "Add entry", "unlisted-note": "This only hides this member from your public member list. They will still be visible to anyone at this link:",
"change-display-text": "Change display text", "edit-names-pronouns-header": "Edit names and pronouns",
"display-text-example": "Optional display text (e.g. it/its)", "back-to-profile-tab": "Back to profile",
"display-text-label": "Display text", "editing-fields-header": "Editing fields"
"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", "save-changes": "Save changes",
"move-field-down": "Move field down", "change": "Change",
"remove-field": "Remove field", "editor": {
"field-name": "Field name", "remove-entry": "Remove entry",
"add-field": "Add field", "move-entry-down": "Move entry down",
"new-entry": "New entry" "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"
}
} }

View file

@ -0,0 +1,64 @@
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";
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 actions = {
default: createRegisterAction("/auth/discord/register"),
};

View file

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

View file

@ -1,9 +1,9 @@
import { apiRequest } from "$api"; import { apiRequest } from "$api";
import ApiError, { ErrorCode, type RawApiError } from "$api/error"; import ApiError, { ErrorCode } from "$api/error";
import type { AuthResponse, CallbackResponse } from "$api/models/auth.js"; import type { CallbackResponse } from "$api/models/auth.js";
import { setToken } from "$lib"; import { setToken } from "$lib";
import log from "$lib/log.js"; import createRegisterAction from "$lib/actions/register.js";
import { isRedirect, redirect } from "@sveltejs/kit"; import { 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(); const { meUser } = await parent();
@ -33,30 +33,5 @@ export const load = async ({ parent, params, url, fetch, cookies }) => {
}; };
export const actions = { export const actions = {
default: async ({ request, fetch, cookies }) => { default: createRegisterAction("/auth/fediverse/register"),
const data = await request.formData();
const username = data.get("username") as string | null;
const ticket = data.get("ticket") as string | null;
if (!username || !ticket)
return {
error: { message: "Bad request", code: ErrorCode.BadRequest, status: 403 } as RawApiError,
};
try {
const resp = await apiRequest<AuthResponse>("POST", "/auth/fediverse/register", {
body: { username, ticket },
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

@ -1,9 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Button, Input, Label } from "@sveltestrap/sveltestrap";
import type { ActionData, PageData } from "./$types"; import type { ActionData, PageData } from "./$types";
import { t } from "$lib/i18n"; import { t } from "$lib/i18n";
import { enhance } from "$app/forms"; import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte";
import ErrorAlert from "$components/ErrorAlert.svelte";
type Props = { data: PageData; form: ActionData }; type Props = { data: PageData; form: ActionData };
let { data, form }: Props = $props(); let { data, form }: Props = $props();
@ -14,22 +12,11 @@
</svelte:head> </svelte:head>
<div class="container"> <div class="container">
<h1>{$t("auth.register-with-mastodon")}</h1> <OauthRegistrationForm
title={$t("auth.register-with-mastodon")}
{#if form?.error} remoteLabel={$t("auth.remote-fediverse-account-label")}
<ErrorAlert error={form?.error} /> remoteUser={data.remoteUser}
{/if} ticket={data.ticket}
error={form?.error}
<form method="POST" use:enhance> />
<div class="mb-3">
<Label>{$t("auth.remote-fediverse-account-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>
<input type="hidden" name="ticket" value={data.ticket} />
<Button color="primary" type="submit">{$t("auth.register-button")}</Button>
</form>
</div> </div>

View file

@ -0,0 +1,7 @@
import { apiRequest } from "$api";
import type { AuthUrls } from "$api/models/auth";
export const load = async ({ fetch }) => {
const urls = await apiRequest<AuthUrls>("POST", "/auth/urls", { fetch, isInternal: true });
return { urls };
};

View file

@ -0,0 +1,65 @@
<script lang="ts">
import AuthMethodList from "$components/settings/AuthMethodList.svelte";
import AuthMethodRow from "$components/settings/AuthMethodRow.svelte";
import type { PageData } from "./$types";
type Props = { data: PageData };
let { data }: Props = $props();
let max = $derived(data.meta.limits.max_auth_methods);
let canRemove = $derived(data.user.auth_methods.length > 1);
let emails = $derived(data.user.auth_methods.filter((m) => m.type === "EMAIL"));
let discordAccounts = $derived(data.user.auth_methods.filter((m) => m.type === "DISCORD"));
let googleAccounts = $derived(data.user.auth_methods.filter((m) => m.type === "GOOGLE"));
let tumblrAccounts = $derived(data.user.auth_methods.filter((m) => m.type === "TUMBLR"));
let fediAccounts = $derived(data.user.auth_methods.filter((m) => m.type === "FEDIVERSE"));
</script>
{#if data.urls.email_enabled}
<h3>Email addresses</h3>
<AuthMethodList
methods={emails}
{canRemove}
{max}
buttonLink="/settings/auth/add-email"
buttonText="Add email address"
/>
{/if}
{#if data.urls.discord}
<h3>Discord accounts</h3>
<AuthMethodList
methods={discordAccounts}
{canRemove}
{max}
buttonLink="/settings/auth/add-discord"
buttonText="Link Discord account"
/>
{/if}
{#if data.urls.google}
<h3>Google accounts</h3>
<AuthMethodList
methods={googleAccounts}
{canRemove}
{max}
buttonLink="/settings/auth/add-google"
buttonText="Link Google account"
/>
{/if}
{#if data.urls.tumblr}
<h3>Tumblr accounts</h3>
<AuthMethodList
methods={tumblrAccounts}
{canRemove}
{max}
buttonLink="/settings/auth/add-tumblr"
buttonText="Link Tumblr account"
/>
{/if}
<h3>Fediverse accounts</h3>
<AuthMethodList
methods={fediAccounts}
{canRemove}
{max}
buttonLink="/settings/auth/add-fediverse"
buttonText="Link Fediverse account"
/>

View file

@ -0,0 +1,12 @@
import { apiRequest } from "$api";
import { redirect } from "@sveltejs/kit";
export const load = async ({ fetch, cookies }) => {
const { url } = await apiRequest<{ url: string }>("GET", "/auth/discord/add-account", {
isInternal: true,
fetch,
cookies,
});
redirect(303, url);
};