feat: allow linking fediverse account to existing user
This commit is contained in:
parent
d6bb2f7743
commit
97191933cb
14 changed files with 306 additions and 93 deletions
|
@ -31,6 +31,7 @@ type User struct {
|
|||
Fediverse *string
|
||||
FediverseUsername *string
|
||||
FediverseAppID *int64
|
||||
FediverseInstance *string
|
||||
|
||||
MaxInvites int
|
||||
|
||||
|
@ -99,7 +100,8 @@ func (db *DB) CreateUser(ctx context.Context, tx pgx.Tx, username string) (u Use
|
|||
}
|
||||
|
||||
func (db *DB) FediverseUser(ctx context.Context, userID string, instanceAppID int64) (u User, err error) {
|
||||
sql, args, err := sq.Select("*").From("users").
|
||||
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
|
||||
From("users").
|
||||
Where("fediverse = ?", userID).Where("fediverse_app_id = ?", instanceAppID).
|
||||
ToSql()
|
||||
if err != nil {
|
||||
|
@ -141,7 +143,8 @@ func (u *User) UpdateFromFedi(ctx context.Context, ex Execer, userID, username s
|
|||
|
||||
// DiscordUser fetches a user by Discord user ID.
|
||||
func (db *DB) DiscordUser(ctx context.Context, discordID string) (u User, err error) {
|
||||
sql, args, err := sq.Select("*").From("users").Where("discord = ?", discordID).ToSql()
|
||||
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
|
||||
From("users").Where("discord = ?", discordID).ToSql()
|
||||
if err != nil {
|
||||
return u, errors.Wrap(err, "building sql")
|
||||
}
|
||||
|
@ -181,7 +184,8 @@ func (u *User) UpdateFromDiscord(ctx context.Context, ex Execer, du *discordgo.U
|
|||
|
||||
// User gets a user by ID.
|
||||
func (db *DB) User(ctx context.Context, id xid.ID) (u User, err error) {
|
||||
sql, args, err := sq.Select("*").From("users").Where("id = ?", id).ToSql()
|
||||
sql, args, err := sq.Select("*", "(SELECT instance FROM fediverse_apps WHERE id = users.fediverse_app_id) AS fediverse_instance").
|
||||
From("users").Where("id = ?", id).ToSql()
|
||||
if err != nil {
|
||||
return u, errors.Wrap(err, "building sql")
|
||||
}
|
||||
|
|
|
@ -203,15 +203,15 @@ func (s *Server) discordSignup(w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||
return server.APIError{Code: server.ErrUsernameTaken}
|
||||
}
|
||||
|
||||
return errors.Wrap(err, "creating user")
|
||||
}
|
||||
|
||||
err = u.UpdateFromDiscord(ctx, tx, du)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||
return server.APIError{Code: server.ErrUsernameTaken}
|
||||
}
|
||||
|
||||
return errors.Wrap(err, "updating user from discord")
|
||||
}
|
||||
|
||||
|
|
|
@ -174,6 +174,57 @@ func (s *Server) mastodonCallback(w http.ResponseWriter, r *http.Request) error
|
|||
return nil
|
||||
}
|
||||
|
||||
type fediLinkRequest struct {
|
||||
Instance string `json:"instance"`
|
||||
Ticket string `json:"ticket"`
|
||||
}
|
||||
|
||||
func (s *Server) mastodonLink(w http.ResponseWriter, r *http.Request) error {
|
||||
ctx := r.Context()
|
||||
|
||||
claims, _ := server.ClaimsFromContext(ctx)
|
||||
|
||||
req, err := Decode[fediLinkRequest](r)
|
||||
if err != nil {
|
||||
return server.APIError{Code: server.ErrBadRequest}
|
||||
}
|
||||
|
||||
app, err := s.DB.FediverseApp(ctx, req.Instance)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting instance application")
|
||||
}
|
||||
|
||||
u, err := s.DB.User(ctx, claims.UserID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting user")
|
||||
}
|
||||
|
||||
if u.Fediverse != nil {
|
||||
return server.APIError{Code: server.ErrAlreadyLinked}
|
||||
}
|
||||
|
||||
mu := new(partialMastodonAccount)
|
||||
err = s.DB.GetJSON(ctx, "mastodon:"+req.Ticket, &mu)
|
||||
if err != nil {
|
||||
log.Errorf("getting mastoAPI user for ticket: %v", err)
|
||||
|
||||
return server.APIError{Code: server.ErrInvalidTicket}
|
||||
}
|
||||
|
||||
err = u.UpdateFromFedi(ctx, s.DB, mu.ID, mu.Username, app.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "updating user from mastoAPI")
|
||||
}
|
||||
|
||||
fields, err := s.DB.UserFields(ctx, u.ID)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "getting user fields")
|
||||
}
|
||||
|
||||
render.JSON(w, r, dbUserToUserResponse(u, fields))
|
||||
return nil
|
||||
}
|
||||
|
||||
type fediSignupRequest struct {
|
||||
Instance string `json:"instance"`
|
||||
Ticket string `json:"ticket"`
|
||||
|
@ -225,15 +276,15 @@ func (s *Server) mastodonSignup(w http.ResponseWriter, r *http.Request) error {
|
|||
|
||||
u, err := s.DB.CreateUser(ctx, tx, req.Username)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||
return server.APIError{Code: server.ErrUsernameTaken}
|
||||
}
|
||||
|
||||
return errors.Wrap(err, "creating user")
|
||||
}
|
||||
|
||||
err = u.UpdateFromFedi(ctx, tx, mu.ID, mu.Username, app.ID)
|
||||
if err != nil {
|
||||
if errors.Cause(err) == db.ErrUsernameTaken {
|
||||
return server.APIError{Code: server.ErrUsernameTaken}
|
||||
}
|
||||
|
||||
return errors.Wrap(err, "updating user from mastoAPI")
|
||||
}
|
||||
|
||||
|
|
|
@ -33,6 +33,10 @@ type userResponse struct {
|
|||
|
||||
Discord *string `json:"discord"`
|
||||
DiscordUsername *string `json:"discord_username"`
|
||||
|
||||
Fediverse *string `json:"fediverse"`
|
||||
FediverseUsername *string `json:"fediverse_username"`
|
||||
FediverseInstance *string `json:"fediverse_instance"`
|
||||
}
|
||||
|
||||
func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
|
||||
|
@ -48,6 +52,9 @@ func dbUserToUserResponse(u db.User, fields []db.Field) *userResponse {
|
|||
Fields: db.NotNull(fields),
|
||||
Discord: u.Discord,
|
||||
DiscordUsername: u.DiscordUsername,
|
||||
Fediverse: u.Fediverse,
|
||||
FediverseUsername: u.FediverseUsername,
|
||||
FediverseInstance: u.FediverseInstance,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -78,7 +85,7 @@ func Mount(srv *server.Server, r chi.Router) {
|
|||
r.Route("/mastodon", func(r chi.Router) {
|
||||
r.Post("/callback", server.WrapHandler(s.mastodonCallback))
|
||||
r.Post("/signup", server.WrapHandler(s.mastodonSignup))
|
||||
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(nil))
|
||||
r.With(server.MustAuth).Post("/add-provider", server.WrapHandler(s.mastodonLink))
|
||||
})
|
||||
|
||||
// invite routes
|
||||
|
|
|
@ -159,15 +159,6 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
|
|||
return err
|
||||
}
|
||||
|
||||
// get fedi instance name if the user has a linked fedi account
|
||||
var fediInstance *string
|
||||
if u.FediverseAppID != nil {
|
||||
app, err := s.DB.FediverseAppByID(ctx, *u.FediverseAppID)
|
||||
if err == nil {
|
||||
fediInstance = &app.Instance
|
||||
}
|
||||
}
|
||||
|
||||
render.JSON(w, r, GetMeResponse{
|
||||
GetUserResponse: dbUserToResponse(u, fields, members),
|
||||
MaxInvites: u.MaxInvites,
|
||||
|
@ -175,7 +166,7 @@ func (s *Server) getMeUser(w http.ResponseWriter, r *http.Request) error {
|
|||
DiscordUsername: u.DiscordUsername,
|
||||
Fediverse: u.Fediverse,
|
||||
FediverseUsername: u.FediverseUsername,
|
||||
FediverseInstance: fediInstance,
|
||||
FediverseInstance: u.FediverseInstance,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -94,6 +94,7 @@ const (
|
|||
ErrDeletionPending = 1011 // own user deletion pending, returned with undo code
|
||||
ErrRecentExport = 1012 // latest export is too recent
|
||||
ErrUnsupportedInstance = 1013 // unsupported fediverse software
|
||||
ErrAlreadyLinked = 1014 // user already has linked account of the same type
|
||||
|
||||
// User-related error codes
|
||||
ErrUserNotFound = 2001
|
||||
|
@ -130,6 +131,7 @@ var errCodeMessages = map[int]string{
|
|||
ErrDeletionPending: "Your account is pending deletion",
|
||||
ErrRecentExport: "Your latest data export is less than 1 day old",
|
||||
ErrUnsupportedInstance: "Unsupported instance software",
|
||||
ErrAlreadyLinked: "Your account is already linked to an account of this type",
|
||||
|
||||
ErrUserNotFound: "User not found",
|
||||
|
||||
|
@ -163,6 +165,7 @@ var errCodeStatuses = map[int]int{
|
|||
ErrDeletionPending: http.StatusBadRequest,
|
||||
ErrRecentExport: http.StatusBadRequest,
|
||||
ErrUnsupportedInstance: http.StatusBadRequest,
|
||||
ErrAlreadyLinked: http.StatusBadRequest,
|
||||
|
||||
ErrUserNotFound: http.StatusNotFound,
|
||||
|
||||
|
|
|
@ -21,6 +21,9 @@ export interface MeUser extends User {
|
|||
max_invites: number;
|
||||
discord: string | null;
|
||||
discord_username: string | null;
|
||||
fediverse: string | null;
|
||||
fediverse_username: string | null;
|
||||
fediverse_instance: string | null;
|
||||
}
|
||||
|
||||
export interface Field {
|
||||
|
|
|
@ -4,10 +4,11 @@
|
|||
|
||||
import { goto } from "$app/navigation";
|
||||
import type { APIError, MeUser } from "$lib/api/entities";
|
||||
import { apiFetch } from "$lib/api/fetch";
|
||||
import { apiFetch, apiFetchClient } from "$lib/api/fetch";
|
||||
import { userStore } from "$lib/store";
|
||||
import type { PageData } from "./$types";
|
||||
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||
import { addToast } from "$lib/toast";
|
||||
|
||||
interface SignupResponse {
|
||||
user: MeUser;
|
||||
|
@ -67,6 +68,22 @@
|
|||
deleteError = e as APIError;
|
||||
}
|
||||
};
|
||||
|
||||
const linkAccount = async () => {
|
||||
try {
|
||||
const resp = await apiFetchClient<MeUser>("/auth/mastodon/add-provider", "POST", {
|
||||
instance: data.instance,
|
||||
ticket: data.ticket,
|
||||
});
|
||||
|
||||
localStorage.setItem("pronouns-user", JSON.stringify(resp));
|
||||
userStore.set(resp);
|
||||
addToast({ header: "Linked account", body: "Successfully linked account!" });
|
||||
goto("/settings/auth");
|
||||
} catch (e) {
|
||||
data.error = e as APIError;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
@ -78,7 +95,32 @@
|
|||
{#if data.error}
|
||||
<ErrorAlert error={data.error} />
|
||||
{/if}
|
||||
{#if data.ticket}
|
||||
{#if data.ticket && $userStore}
|
||||
<div>
|
||||
<label for="fediverse">Fediverse username</label>
|
||||
<input
|
||||
id="fediverse"
|
||||
class="form-control"
|
||||
name="fediverse"
|
||||
readonly
|
||||
value="{data.fediverse}@{data.instance}"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="fediverse">pronouns.cc username</label>
|
||||
<input
|
||||
id="pronounscc"
|
||||
class="form-control"
|
||||
name="pronounscc"
|
||||
readonly
|
||||
value={$userStore.name}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button on:click={linkAccount}>Link account</Button>
|
||||
<Button color="secondary" href="/settings/auth">Cancel</Button>
|
||||
</div>
|
||||
{:else if data.ticket}
|
||||
<form on:submit|preventDefault={signupForm}>
|
||||
<div>
|
||||
<label for="fediverse">Fediverse username</label>
|
||||
|
@ -86,7 +128,7 @@
|
|||
id="fediverse"
|
||||
class="form-control"
|
||||
name="fediverse"
|
||||
disabled
|
||||
readonly
|
||||
value="{data.fediverse}@{data.instance}"
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -35,7 +35,14 @@
|
|||
<ListGroupItem tag="a" active={$page.url.pathname === "/settings"} href="/settings">
|
||||
Your profile
|
||||
</ListGroupItem>
|
||||
{#if data.require_invite}
|
||||
<ListGroupItem
|
||||
tag="a"
|
||||
active={$page.url.pathname === "/settings/auth"}
|
||||
href="/settings/auth"
|
||||
>
|
||||
Authentication
|
||||
</ListGroupItem>
|
||||
{#if data.invitesEnabled}
|
||||
<ListGroupItem
|
||||
tag="a"
|
||||
active={$page.url.pathname === "/settings/invites"}
|
||||
|
|
|
@ -1,8 +1,36 @@
|
|||
import {
|
||||
ErrorCode,
|
||||
type APIError,
|
||||
type Invite,
|
||||
type MeUser,
|
||||
type PartialMember,
|
||||
} from "$lib/api/entities";
|
||||
import { apiFetchClient } from "$lib/api/fetch";
|
||||
import type { LayoutLoad } from "./$types";
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load = (async ({ parent }) => {
|
||||
const user = await apiFetchClient<MeUser>("/users/@me");
|
||||
const members = await apiFetchClient<PartialMember[]>("/users/@me/members");
|
||||
|
||||
let invites: Invite[] = [];
|
||||
let invitesEnabled = true;
|
||||
try {
|
||||
invites = await apiFetchClient<Invite[]>("/auth/invites");
|
||||
} catch (e) {
|
||||
if ((e as APIError).code === ErrorCode.InvitesDisabled) {
|
||||
invitesEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const data = await parent();
|
||||
return data;
|
||||
|
||||
return {
|
||||
...data,
|
||||
user,
|
||||
members,
|
||||
invites,
|
||||
invitesEnabled,
|
||||
};
|
||||
}) satisfies LayoutLoad;
|
||||
|
|
|
@ -1,30 +0,0 @@
|
|||
import {
|
||||
type Invite,
|
||||
type APIError,
|
||||
type MeUser,
|
||||
type PartialMember,
|
||||
ErrorCode,
|
||||
} from "$lib/api/entities";
|
||||
import { apiFetchClient } from "$lib/api/fetch";
|
||||
import { error } from "@sveltejs/kit";
|
||||
|
||||
export const load = async () => {
|
||||
try {
|
||||
const user = await apiFetchClient<MeUser>("/users/@me");
|
||||
const members = await apiFetchClient<PartialMember[]>("/users/@me/members");
|
||||
|
||||
let invites: Invite[] = [];
|
||||
let invitesEnabled = true;
|
||||
try {
|
||||
invites = await apiFetchClient<Invite[]>("/auth/invites");
|
||||
} catch (e) {
|
||||
if ((e as APIError).code === ErrorCode.InvitesDisabled) {
|
||||
invitesEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return { user, members, invites, invitesEnabled };
|
||||
} catch (e) {
|
||||
throw error(500, (e as APIError).message);
|
||||
}
|
||||
};
|
115
frontend/src/routes/settings/auth/+page.svelte
Normal file
115
frontend/src/routes/settings/auth/+page.svelte
Normal file
|
@ -0,0 +1,115 @@
|
|||
<script lang="ts">
|
||||
import type { APIError } from "$lib/api/entities";
|
||||
import { apiFetch } from "$lib/api/fetch";
|
||||
import ErrorAlert from "$lib/components/ErrorAlert.svelte";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardText,
|
||||
CardTitle,
|
||||
Input,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
} from "sveltestrap";
|
||||
import type { PageData } from "./$types";
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
let canUnlink = false;
|
||||
|
||||
$: canUnlink =
|
||||
[data.user.discord, data.user.fediverse]
|
||||
.map<number>((entry) => (entry === null ? 0 : 1))
|
||||
.reduce((prev, current) => prev + current) >= 2;
|
||||
|
||||
let error: APIError | null = null;
|
||||
let instance = "";
|
||||
let fediDisabled = false;
|
||||
|
||||
let fediLinkModalOpen = false;
|
||||
let toggleFediLinkModal = () => (fediLinkModalOpen = !fediLinkModalOpen);
|
||||
|
||||
const fediLogin = async () => {
|
||||
fediDisabled = true;
|
||||
try {
|
||||
const resp = await apiFetch<{ url: string }>(
|
||||
`/auth/urls/fediverse?instance=${encodeURIComponent(instance)}`,
|
||||
{},
|
||||
);
|
||||
window.location.assign(resp.url);
|
||||
} catch (e) {
|
||||
error = e as APIError;
|
||||
} finally {
|
||||
fediDisabled = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h1>Authentication providers</h1>
|
||||
|
||||
<div>
|
||||
<div class="my-2">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Fediverse</CardTitle>
|
||||
<CardText>
|
||||
{#if data.user.fediverse}
|
||||
Your currently linked Fediverse account is <b
|
||||
>{data.user.fediverse_username}@{data.user.fediverse_instance}</b
|
||||
>
|
||||
(<code>{data.user.fediverse}</code>).
|
||||
{:else}
|
||||
You do not have a linked Fediverse account.
|
||||
{/if}
|
||||
</CardText>
|
||||
{#if data.user.fediverse}
|
||||
<Button color="danger" disabled={!canUnlink}>Unlink account</Button>
|
||||
{:else}
|
||||
<Button color="secondary" on:click={toggleFediLinkModal}>Link account</Button>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
<div class="my-2">
|
||||
<Card>
|
||||
<CardBody>
|
||||
<CardTitle>Discord</CardTitle>
|
||||
<CardText>
|
||||
{#if data.user.discord}
|
||||
Your currently linked Discord account is <b>{data.user.discord_username}</b>
|
||||
(<code>{data.user.discord}</code>).
|
||||
{:else}
|
||||
You do not have a linked Discord account.
|
||||
{/if}
|
||||
</CardText>
|
||||
{#if data.user.discord}
|
||||
<Button color="danger" disabled={!canUnlink}>Unlink account</Button>
|
||||
{:else}
|
||||
<Button color="secondary" href={data.urls.discord}>Link account</Button>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
<Modal header="Pick an instance" isOpen={fediLinkModalOpen} toggle={toggleFediLinkModal}>
|
||||
<ModalBody>
|
||||
<p>
|
||||
<strong>Note:</strong> Misskey (and derivatives) are not supported yet, sorry.
|
||||
</p>
|
||||
<Input placeholder="Instance (e.g. mastodon.social)" bind:value={instance} />
|
||||
{#if error}
|
||||
<div class="mt-2">
|
||||
<ErrorAlert {error} />
|
||||
</div>
|
||||
{/if}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="primary" disabled={fediDisabled || instance === ""} on:click={fediLogin}
|
||||
>Log in</Button
|
||||
>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</div>
|
||||
</div>
|
17
frontend/src/routes/settings/auth/+page.ts
Normal file
17
frontend/src/routes/settings/auth/+page.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { PUBLIC_BASE_URL } from "$env/static/public";
|
||||
import { apiFetch } from "$lib/api/fetch";
|
||||
|
||||
export const load = async () => {
|
||||
const resp = await apiFetch<UrlsResponse>("/auth/urls", {
|
||||
method: "POST",
|
||||
body: {
|
||||
callback_domain: PUBLIC_BASE_URL,
|
||||
},
|
||||
});
|
||||
|
||||
return { urls: resp };
|
||||
};
|
||||
|
||||
interface UrlsResponse {
|
||||
discord: string;
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
import { ErrorCode, type APIError, type Invite } from "$lib/api/entities";
|
||||
import { apiFetchClient } from "$lib/api/fetch";
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { PageLoad } from "../$types";
|
||||
|
||||
export const load = (async () => {
|
||||
const data = {
|
||||
invitesEnabled: true,
|
||||
invites: [] as Invite[],
|
||||
};
|
||||
|
||||
try {
|
||||
const invites = await apiFetchClient<Invite[]>("/auth/invites");
|
||||
data.invites = invites;
|
||||
} catch (e) {
|
||||
if ((e as APIError).code === ErrorCode.InvitesDisabled) {
|
||||
data.invitesEnabled = false;
|
||||
data.invites = [];
|
||||
} else {
|
||||
throw error((e as APIError).code, (e as APIError).message);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
}) satisfies PageLoad;
|
Loading…
Reference in a new issue