Compare commits

..

No commits in common. "de733a06824fdf0d15620c4e0a2b5609f245ef34" and "8b1d5b2c1b6a9afface067ada3b8d9b844b7ac0e" have entirely different histories.

22 changed files with 212 additions and 618 deletions

View file

@ -5,7 +5,6 @@ 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;
@ -57,10 +56,9 @@ public class AuthController(
public record AddOauthAccountResponse( public record AddOauthAccountResponse(
Snowflake Id, Snowflake Id,
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, AuthType Type,
string RemoteId, string RemoteId,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] string? RemoteUsername
string? RemoteUsername
); );
public record OauthRegisterRequest(string Ticket, string Username); public record OauthRegisterRequest(string Ticket, string Username);

View file

@ -27,8 +27,7 @@ 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
) )
) )
); );
@ -50,10 +49,5 @@ 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( private record Limits(int MemberCount, int BioLength, int CustomPreferences);
int MemberCount,
int BioLength,
int CustomPreferences,
int MaxAuthMethods
);
} }

View file

@ -71,22 +71,6 @@ 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

@ -1,47 +0,0 @@
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,16 +98,6 @@ 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,15 +223,6 @@ 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

@ -1,35 +0,0 @@
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 { AuthType, User } from "./user"; import type { User } from "./user";
export type AuthResponse = { export type AuthResponse = {
user: User; user: User;
@ -21,10 +21,3 @@ 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,5 +16,4 @@ 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,11 +71,9 @@ 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: AuthType; type: "DISCORD" | "GOOGLE" | "TUMBLR" | "FEDIVERSE" | "EMAIL";
remote_id: string; remote_id: string;
remote_username?: string; remote_username?: string;
}; };

View file

@ -1,24 +0,0 @@
<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

@ -1,26 +0,0 @@
<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

@ -1,34 +0,0 @@
<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

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

@ -1,64 +0,0 @@
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

@ -1,31 +0,0 @@
<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 } from "$api/error"; import ApiError, { ErrorCode, type RawApiError } from "$api/error";
import type { CallbackResponse } from "$api/models/auth.js"; import type { AuthResponse, CallbackResponse } from "$api/models/auth.js";
import { setToken } from "$lib"; import { setToken } from "$lib";
import createRegisterAction from "$lib/actions/register.js"; import log from "$lib/log.js";
import { redirect } from "@sveltejs/kit"; import { isRedirect, redirect } from "@sveltejs/kit";
export const load = async ({ parent, params, url, fetch, cookies }) => { export const load = async ({ parent, params, url, fetch, cookies }) => {
const { meUser } = await parent(); const { meUser } = await parent();
@ -33,5 +33,30 @@ export const load = async ({ parent, params, url, fetch, cookies }) => {
}; };
export const actions = { export const actions = {
default: createRegisterAction("/auth/fediverse/register"), 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;
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,7 +1,9 @@
<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 OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte"; import { enhance } from "$app/forms";
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();
@ -12,11 +14,22 @@
</svelte:head> </svelte:head>
<div class="container"> <div class="container">
<OauthRegistrationForm <h1>{$t("auth.register-with-mastodon")}</h1>
title={$t("auth.register-with-mastodon")}
remoteLabel={$t("auth.remote-fediverse-account-label")} {#if form?.error}
remoteUser={data.remoteUser} <ErrorAlert error={form?.error} />
ticket={data.ticket} {/if}
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

@ -1,7 +0,0 @@
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

@ -1,65 +0,0 @@
<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

@ -1,12 +0,0 @@
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);
};