Compare commits
2 commits
8b1d5b2c1b
...
de733a0682
Author | SHA1 | Date | |
---|---|---|---|
de733a0682 | |||
4780be3019 |
22 changed files with 618 additions and 212 deletions
|
@ -5,6 +5,7 @@ using Foxnouns.Backend.Database.Models;
|
|||
using Foxnouns.Backend.Extensions;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
using Foxnouns.Backend.Services;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Newtonsoft.Json;
|
||||
|
@ -56,9 +57,10 @@ public class AuthController(
|
|||
|
||||
public record AddOauthAccountResponse(
|
||||
Snowflake Id,
|
||||
AuthType Type,
|
||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
|
||||
string RemoteId,
|
||||
string? RemoteUsername
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
string? RemoteUsername
|
||||
);
|
||||
|
||||
public record OauthRegisterRequest(string Ticket, string Username);
|
||||
|
|
|
@ -27,7 +27,8 @@ public class MetaController : ApiControllerBase
|
|||
new Limits(
|
||||
MemberCount: MembersController.MaxMemberCount,
|
||||
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);
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 => m.Sid).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.Fields).HasColumnType("jsonb");
|
||||
|
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -98,6 +98,16 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
b.HasIndex("UserId")
|
||||
.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);
|
||||
});
|
||||
|
||||
|
|
|
@ -223,6 +223,15 @@ public class AuthService(
|
|||
{
|
||||
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
|
||||
{
|
||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||
|
|
35
Foxnouns.Frontend/src/lib/actions/register.ts
Normal file
35
Foxnouns.Frontend/src/lib/actions/register.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import type { User } from "./user";
|
||||
import type { AuthType, User } from "./user";
|
||||
|
||||
export type AuthResponse = {
|
||||
user: User;
|
||||
|
@ -21,3 +21,10 @@ export type AuthUrls = {
|
|||
google?: string;
|
||||
tumblr?: string;
|
||||
};
|
||||
|
||||
export type AddAccountResponse = {
|
||||
id: string;
|
||||
type: AuthType;
|
||||
remote_id: string;
|
||||
remote_username?: string;
|
||||
};
|
||||
|
|
|
@ -16,4 +16,5 @@ export type Limits = {
|
|||
member_count: number;
|
||||
bio_length: number;
|
||||
custom_preferences: number;
|
||||
max_auth_methods: number;
|
||||
};
|
||||
|
|
|
@ -71,9 +71,11 @@ export type PrideFlag = {
|
|||
description: string | null;
|
||||
};
|
||||
|
||||
export type AuthType = "DISCORD" | "GOOGLE" | "TUMBLR" | "FEDIVERSE" | "EMAIL";
|
||||
|
||||
export type AuthMethod = {
|
||||
id: string;
|
||||
type: "DISCORD" | "GOOGLE" | "TUMBLR" | "FEDIVERSE" | "EMAIL";
|
||||
type: AuthType;
|
||||
remote_id: string;
|
||||
remote_username?: string;
|
||||
};
|
||||
|
|
|
@ -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}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -1,157 +1,168 @@
|
|||
{
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"),
|
||||
};
|
|
@ -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>
|
|
@ -1,9 +1,9 @@
|
|||
import { apiRequest } from "$api";
|
||||
import ApiError, { ErrorCode, type RawApiError } from "$api/error";
|
||||
import type { AuthResponse, CallbackResponse } from "$api/models/auth.js";
|
||||
import ApiError, { ErrorCode } from "$api/error";
|
||||
import type { CallbackResponse } from "$api/models/auth.js";
|
||||
import { setToken } from "$lib";
|
||||
import log from "$lib/log.js";
|
||||
import { isRedirect, redirect } from "@sveltejs/kit";
|
||||
import createRegisterAction from "$lib/actions/register.js";
|
||||
import { redirect } from "@sveltejs/kit";
|
||||
|
||||
export const load = async ({ parent, params, url, fetch, cookies }) => {
|
||||
const { meUser } = await parent();
|
||||
|
@ -33,30 +33,5 @@ export const load = async ({ parent, params, url, fetch, cookies }) => {
|
|||
};
|
||||
|
||||
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;
|
||||
|
||||
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;
|
||||
}
|
||||
},
|
||||
default: createRegisterAction("/auth/fediverse/register"),
|
||||
};
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { Button, Input, Label } from "@sveltestrap/sveltestrap";
|
||||
import type { ActionData, PageData } from "./$types";
|
||||
import { t } from "$lib/i18n";
|
||||
import { enhance } from "$app/forms";
|
||||
import ErrorAlert from "$components/ErrorAlert.svelte";
|
||||
import OauthRegistrationForm from "$components/settings/OauthRegistrationForm.svelte";
|
||||
|
||||
type Props = { data: PageData; form: ActionData };
|
||||
let { data, form }: Props = $props();
|
||||
|
@ -14,22 +12,11 @@
|
|||
</svelte:head>
|
||||
|
||||
<div class="container">
|
||||
<h1>{$t("auth.register-with-mastodon")}</h1>
|
||||
|
||||
{#if form?.error}
|
||||
<ErrorAlert error={form?.error} />
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
<OauthRegistrationForm
|
||||
title={$t("auth.register-with-mastodon")}
|
||||
remoteLabel={$t("auth.remote-fediverse-account-label")}
|
||||
remoteUser={data.remoteUser}
|
||||
ticket={data.ticket}
|
||||
error={form?.error}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -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 };
|
||||
};
|
65
Foxnouns.Frontend/src/routes/settings/auth/+page.svelte
Normal file
65
Foxnouns.Frontend/src/routes/settings/auth/+page.svelte
Normal 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"
|
||||
/>
|
|
@ -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);
|
||||
};
|
Loading…
Reference in a new issue