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
This commit is contained in:
parent
4780be3019
commit
de733a0682
19 changed files with 545 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,8 +57,9 @@ public class AuthController(
|
|||
|
||||
public record AddOauthAccountResponse(
|
||||
Snowflake Id,
|
||||
AuthType Type,
|
||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
|
||||
string RemoteId,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
string? RemoteUsername
|
||||
);
|
||||
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -18,7 +18,8 @@
|
|||
"title": {
|
||||
"log-in": "Log in",
|
||||
"welcome": "Welcome",
|
||||
"settings": "Settings"
|
||||
"settings": "Settings",
|
||||
"an-error-occurred": "An error occurred"
|
||||
},
|
||||
"auth": {
|
||||
"log-in-form-title": "Log in with email",
|
||||
|
@ -37,7 +38,16 @@
|
|||
"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"
|
||||
"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",
|
||||
|
@ -92,7 +102,8 @@
|
|||
"avatar": "Avatar",
|
||||
"username-update-success": "Successfully changed your username!",
|
||||
"create-member-title": "Create a new member",
|
||||
"create-member-name-label": "Member name"
|
||||
"create-member-name-label": "Member name",
|
||||
"auth-remove-method": "Remove"
|
||||
},
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
|
|
|
@ -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