feat(frontend): working Discord login + signup

This commit is contained in:
Sam 2023-03-12 04:25:53 +01:00
parent 0e72097346
commit c8b5b7e2c2
No known key found for this signature in database
GPG key ID: B4EF20DDE721CAA1
24 changed files with 287 additions and 119 deletions

View file

@ -7,12 +7,9 @@ import (
"net/url" "net/url"
"os" "os"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"emperror.dev/errors" "emperror.dev/errors"
"github.com/Masterminds/squirrel" "github.com/Masterminds/squirrel"
"github.com/jackc/pgconn" "github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/log/zapadapter"
"github.com/jackc/pgx/v4/pgxpool" "github.com/jackc/pgx/v4/pgxpool"
"github.com/mediocregopher/radix/v4" "github.com/mediocregopher/radix/v4"
"github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7"
@ -38,15 +35,7 @@ type DB struct {
} }
func New() (*DB, error) { func New() (*DB, error) {
pgxCfg, err := pgxpool.ParseConfig(os.Getenv("DATABASE_URL")) pool, err := pgxpool.Connect(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil {
return nil, errors.Wrap(err, "parsing config")
}
pgxCfg.ConnConfig.LogLevel = pgx.LogLevelDebug
pgxCfg.ConnConfig.Logger = zapadapter.NewLogger(log.Logger)
pool, err := pgxpool.ConnectConfig(context.Background(), pgxCfg)
// pool, err := pgxpool.Connect(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil { if err != nil {
return nil, errors.Wrap(err, "creating postgres client") return nil, errors.Wrap(err, "creating postgres client")
} }

View file

@ -52,7 +52,7 @@ func (f Field) Validate() string {
// UserFields returns the fields associated with the given user ID. // UserFields returns the fields associated with the given user ID.
func (db *DB) UserFields(ctx context.Context, id xid.ID) (fs []Field, err error) { func (db *DB) UserFields(ctx context.Context, id xid.ID) (fs []Field, err error) {
sql, args, err := sq.Select("*").From("user_fields").Where("user_id = ?", id).OrderBy("id").ToSql() sql, args, err := sq.Select("id", "name", "entries").From("user_fields").Where("user_id = ?", id).OrderBy("id").ToSql()
if err != nil { if err != nil {
return fs, errors.Wrap(err, "building sql") return fs, errors.Wrap(err, "building sql")
} }
@ -88,7 +88,7 @@ func (db *DB) SetUserFields(ctx context.Context, tx pgx.Tx, userID xid.ID, field
// MemberFields returns the fields associated with the given member ID. // MemberFields returns the fields associated with the given member ID.
func (db *DB) MemberFields(ctx context.Context, id xid.ID) (fs []Field, err error) { func (db *DB) MemberFields(ctx context.Context, id xid.ID) (fs []Field, err error) {
sql, args, err := sq.Select("*").From("member_fields").Where("member_id = ?", id).OrderBy("id").ToSql() sql, args, err := sq.Select("id", "name", "entries").From("member_fields").Where("member_id = ?", id).OrderBy("id").ToSql()
if err != nil { if err != nil {
return fs, errors.Wrap(err, "building sql") return fs, errors.Wrap(err, "building sql")
} }

View file

@ -61,7 +61,7 @@ func (s *Server) discordCallback(w http.ResponseWriter, r *http.Request) error {
} }
cfg := discordOAuthConfig cfg := discordOAuthConfig
cfg.RedirectURL = decoded.CallbackDomain + "/login/discord" cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/discord"
token, err := cfg.Exchange(r.Context(), decoded.Code) token, err := cfg.Exchange(r.Context(), decoded.Code)
if err != nil { if err != nil {
log.Errorf("exchanging oauth code: %v", err) log.Errorf("exchanging oauth code: %v", err)

View file

@ -105,10 +105,10 @@ func (s *Server) oauthURLs(w http.ResponseWriter, r *http.Request) error {
// copy Discord config and set redirect url // copy Discord config and set redirect url
discordCfg := discordOAuthConfig discordCfg := discordOAuthConfig
discordCfg.RedirectURL = req.CallbackDomain + "/login/discord" discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord"
render.JSON(w, r, oauthURLsResponse{ render.JSON(w, r, oauthURLsResponse{
Discord: discordCfg.AuthCodeURL(state), Discord: discordCfg.AuthCodeURL(state) + "&prompt=none",
}) })
return nil return nil
} }

View file

@ -35,7 +35,9 @@ type PartialMember struct {
ID xid.ID `json:"id"` ID xid.ID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
DisplayName *string `json:"display_name"` DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
AvatarURLs []string `json:"avatar_urls"` AvatarURLs []string `json:"avatar_urls"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"` Names []db.FieldEntry `json:"names"`
Pronouns []db.PronounEntry `json:"pronouns"` Pronouns []db.PronounEntry `json:"pronouns"`
} }
@ -59,7 +61,9 @@ func dbUserToResponse(u db.User, fields []db.Field, members []db.Member) GetUser
ID: members[i].ID, ID: members[i].ID,
Name: members[i].Name, Name: members[i].Name,
DisplayName: members[i].DisplayName, DisplayName: members[i].DisplayName,
Bio: members[i].Bio,
AvatarURLs: db.NotNull(members[i].AvatarURLs), AvatarURLs: db.NotNull(members[i].AvatarURLs),
Links: db.NotNull(members[i].Links),
Names: db.NotNull(members[i].Names), Names: db.NotNull(members[i].Names),
Pronouns: db.NotNull(members[i].Pronouns), Pronouns: db.NotNull(members[i].Pronouns),
} }

View file

@ -23,7 +23,6 @@ type PatchUserRequest struct {
} }
// patchUser parses a PatchUserRequest and updates the user with the given ID. // patchUser parses a PatchUserRequest and updates the user with the given ID.
// TODO: could this be refactored to be less repetitive? names, pronouns, and fields are all validated in the same way
func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error { func (s *Server) patchUser(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context() ctx := r.Context()

View file

@ -3,8 +3,8 @@ export interface User {
name: string; name: string;
display_name: string | null; display_name: string | null;
bio: string | null; bio: string | null;
avatar_urls: string[] | null; avatar_urls: string[];
links: string[] | null; links: string[];
names: FieldEntry[]; names: FieldEntry[];
pronouns: Pronoun[]; pronouns: Pronoun[];
@ -46,15 +46,16 @@ export interface PartialMember {
id: string; id: string;
name: string; name: string;
display_name: string | null; display_name: string | null;
avatar_urls: string[] | null; bio: string | null;
avatar_urls: string[];
links: string[];
names: FieldEntry[];
pronouns: Pronoun[];
} }
export interface Member extends PartialMember { export interface Member extends PartialMember {
bio: string | null;
links: string | null;
names: FieldEntry[];
pronouns: Pronoun[];
fields: Field[]; fields: Field[];
user: MemberPartialUser; user: MemberPartialUser;
} }
@ -62,7 +63,7 @@ export interface MemberPartialUser {
id: string; id: string;
name: string; name: string;
display_name: string | null; display_name: string | null;
avatar_urls: string[] | null; avatar_urls: string[];
} }
export interface APIError { export interface APIError {

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
export let urls: string[] | null; export let urls: string[];
export let alt: string; export let alt: string;
export let width = 300;
const contentTypeFor = (url: string) => { const contentTypeFor = (url: string) => {
if (url.endsWith(".webp")) { if (url.endsWith(".webp")) {
@ -15,14 +16,14 @@
}; };
</script> </script>
{#if urls} {#if urls.length > 0}
<picture class="rounded-circle img-fluid"> <picture class="rounded-circle img-fluid">
{#each urls as url} {#each urls as url}
<source width=300 srcSet={url} type={contentTypeFor(url)} /> <source {width} srcSet={url} type={contentTypeFor(url)} />
{/each} {/each}
<img width=300 src={urls[0]} {alt} class="rounded-circle img-fluid" /> <img {width} src={urls[0]} {alt} class="rounded-circle img-fluid" />
</picture> </picture>
{:else} {:else}
<!-- TODO: actual placeholder that isn't a cat --> <!-- TODO: actual placeholder that isn't a cat -->
<img width=300 class="rounded-circle img-fluid" src="https://placekitten.com/512/512" {alt} /> <img {width} class="rounded-circle img-fluid" src="https://placekitten.com/512/512" {alt} />
{/if} {/if}

View file

@ -9,7 +9,7 @@
</script> </script>
<div> <div>
<h5>{field.name}</h5> <h4>{field.name}</h4>
<ul class="list-unstyled"> <ul class="list-unstyled">
{#each field.entries as entry} {#each field.entries as entry}
<li><StatusIcon status={entry.status} /> {entry.value}</li> <li><StatusIcon status={entry.status} /> {entry.value}</li>

View file

@ -1,15 +1,44 @@
<script lang="ts"> <script lang="ts">
import type { PartialMember, User } from "$lib/api/entities"; import { WordStatus, type PartialMember, type User } from "$lib/api/entities";
import FallbackImage from "./FallbackImage.svelte"; import FallbackImage from "./FallbackImage.svelte";
export let user: User; export let user: User;
export let member: PartialMember; export let member: PartialMember;
let pronouns: string | undefined;
const getPronouns = (member: PartialMember) => {
const filteredPronouns = member.pronouns.filter(
(pronouns) => pronouns.status === WordStatus.Favourite,
);
if (filteredPronouns.length === 0) {
return undefined;
}
return filteredPronouns
.map((pronouns) => {
if (pronouns.display_text) {
return pronouns.display_text;
} else {
const split = pronouns.pronouns.split("/");
if (split.length < 2) return split.join("/");
else return split.slice(0, 2).join("/");
}
})
.join(", ");
};
$: pronouns = getPronouns(member);
</script> </script>
<div> <div>
<FallbackImage <FallbackImage urls={member.avatar_urls} width={200} alt="Avatar for {member.name}" />
urls={member.avatar_urls} <p class="m-2">
alt="Avatar for {member.name}" <a class="text-reset fs-5" href="/@{user.name}/{member.name}">
/> {member.display_name ?? member.name}
<a class="text-reset" href="/@{user.name}/{member.name}"><h5 class="m-2">{member.display_name ?? member.name}</h5></a> </a>
{#if pronouns}
<br />
{pronouns}
{/if}
</p>
</div> </div>

View file

@ -3,8 +3,8 @@ import { browser } from "$app/environment";
import type { MeUser } from "./api/entities"; import type { MeUser } from "./api/entities";
export const userStore = writable<MeUser | null>(null); const initialUserValue = null;
export const tokenStore = writable<string | null>(null); export const userStore = writable<MeUser | null>(initialUserValue);
let defaultThemeValue = "dark"; let defaultThemeValue = "dark";
const initialThemeValue = browser const initialThemeValue = browser

View file

@ -0,0 +1,19 @@
import { error } from "@sveltejs/kit";
import type { LayoutServerLoad } from "./$types";
import type { APIError } from "$lib/api/entities";
import { apiFetch } from "$lib/api/fetch";
export const load = (async (event) => {
try {
return await apiFetch<MetaResponse>("/meta", {});
} catch (e) {
throw error(500, (e as APIError).message);
}
}) satisfies LayoutServerLoad;
interface MetaResponse {
git_repository: string;
git_commit: string;
users: number;
members: number;
}

View file

@ -1,9 +1,26 @@
<script> <script lang="ts">
import "bootstrap/dist/css/bootstrap.min.css"; import "bootstrap/dist/css/bootstrap.min.css";
import "bootstrap-icons/font/bootstrap-icons.css"; import "bootstrap-icons/font/bootstrap-icons.css";
import Navigation from "./nav/Navigation.svelte"; import Navigation from "./nav/Navigation.svelte";
import type { LayoutData } from "./$types";
import { version } from "$app/environment";
export let data: LayoutData;
const versionMismatch = data.git_commit !== version && data.git_commit !== "[unknown]";
</script> </script>
<Navigation /> <Navigation />
<slot /> <div class="container">
<slot />
<footer>
<hr />
<p>
pronouns.cc <a href={data.git_repository}>{version}</a>
{#if versionMismatch}(backend: {data.git_commit}){/if} &middot;
<a href="/page/terms">Terms of service</a>
- <a href="/page/privacy">Privacy policy</a>
</p>
</footer>
</div>

View file

@ -1,10 +1,21 @@
import { apiFetch } from "$lib/api/fetch"; import { apiFetch } from "$lib/api/fetch";
import type { User } from "$lib/api/entities"; import { ErrorCode, type APIError, type User } from "$lib/api/entities";
import { error } from "@sveltejs/kit";
export const load = async ({ params }) => { export const load = async ({ params }) => {
try {
const resp = await apiFetch<User>(`/users/${params.username}`, { const resp = await apiFetch<User>(`/users/${params.username}`, {
method: "GET", method: "GET",
}); });
return resp; return resp;
} catch (e) {
if ((e as APIError).code === ErrorCode.UserNotFound) {
throw error(404, (e as APIError).message);
}
console.log(e);
throw e;
}
}; };

View file

@ -48,7 +48,8 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="row"> </div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{#if data.names} {#if data.names}
<div class="col-md"> <div class="col-md">
<h4>Names</h4> <h4>Names</h4>
@ -72,17 +73,12 @@
</ul> </ul>
</div> </div>
{/if} {/if}
</div>
</div>
{#if data.fields}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{#each data.fields as field} {#each data.fields as field}
<div class="col"> <div class="col">
<FieldCard {field} /> <FieldCard {field} />
</div> </div>
{/each} {/each}
</div> </div>
{/if}
{#if data.members} {#if data.members}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
@ -90,7 +86,7 @@
<h2>Members</h2> <h2>Members</h2>
</div> </div>
</div> </div>
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-5 text-center"> <div class="row row-cols-1 row-cols-sm-2 row-cols-md-4 text-center">
{#each data.members as member} {#each data.members as member}
<PartialMemberCard user={data} {member} /> <PartialMemberCard user={data} {member} />
{/each} {/each}

View file

@ -49,7 +49,7 @@
</div> </div>
{/if} {/if}
</div> </div>
<div class="row"> <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{#if data.names} {#if data.names}
<div class="col-md"> <div class="col-md">
<h4>Names</h4> <h4>Names</h4>
@ -73,15 +73,11 @@
</ul> </ul>
</div> </div>
{/if} {/if}
</div>
</div>
{#if data.fields}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{#each data.fields as field} {#each data.fields as field}
<div class="col"> <div class="col">
<FieldCard {field} /> <FieldCard {field} />
</div> </div>
{/each} {/each}
</div> </div>
{/if} </div>
</div> </div>

View file

@ -1,16 +1,16 @@
import type { MeUser } from "$lib/api/entities"; import type { APIError, MeUser } from "$lib/api/entities";
import { apiFetch } from "$lib/api/fetch"; import { apiFetch } from "$lib/api/fetch";
import type { PageServerLoad } from "./$types"; import type { PageServerLoad, Actions } from "./$types";
import { PUBLIC_BASE_URL } from "$env/static/public"; import { PUBLIC_BASE_URL } from "$env/static/public";
export const load = (async (event) => { export const load = (async ({ locals, url }) => {
try { try {
const resp = await apiFetch<CallbackResponse>("/auth/discord/callback", { const resp = await apiFetch<CallbackResponse>("/auth/discord/callback", {
method: "POST", method: "POST",
body: { body: {
callback_domain: PUBLIC_BASE_URL, callback_domain: PUBLIC_BASE_URL,
code: event.url.searchParams.get("code"), code: url.searchParams.get("code"),
state: event.url.searchParams.get("state"), state: url.searchParams.get("state"),
}, },
}); });
@ -18,7 +18,9 @@ export const load = (async (event) => {
...resp, ...resp,
}; };
} catch (e) { } catch (e) {
return { error: e }; console.log(e);
return { error: e as APIError };
} }
}) satisfies PageServerLoad; }) satisfies PageServerLoad;

View file

@ -0,0 +1,95 @@
<script lang="ts">
import { onMount } from "svelte";
import { Alert } from "sveltestrap";
import { goto } from "$app/navigation";
import type { APIError, MeUser } from "$lib/api/entities";
import { apiFetch } from "$lib/api/fetch";
import { userStore } from "$lib/store";
import type { PageData } from "./$types";
interface SignupResponse {
user: MeUser;
token: string;
}
export let data: PageData;
onMount(() => {
if (data.token && data.user) {
localStorage.setItem("pronouns-token", data.token);
userStore.set(data.user);
goto("/");
}
});
let username = "";
let invite = "";
const signupForm = async () => {
try {
const resp = await apiFetch<SignupResponse>("/auth/discord/signup", {
method: "POST",
body: {
ticket: data.ticket,
username: username,
invite_code: invite,
},
});
localStorage.setItem("pronouns-token", resp.token);
userStore.set(resp.user);
goto("/");
} catch (e) {
data.error = e as APIError;
}
};
</script>
<svelte:head>
<title>Log in with Discord - pronouns.cc</title>
</svelte:head>
<h1>Log in with Discord</h1>
{#if data.error}
<Alert color="danger">
<h4 class="alert-heading">An error occurred</h4>
<b>{data.error.code}:</b>
{data.error.message}
</Alert>
{/if}
{#if data.ticket}
<form on:submit|preventDefault={signupForm}>
<div>
<label for="discord">Discord username</label>
<input id="discord" class="form-control" name="discord" disabled value={data.discord} />
</div>
<div>
<label for="username">Username</label>
<input id="username" class="form-control" name="username" bind:value={username} />
</div>
{#if data.require_invite}
<div>
<label for="invite">Invite code</label>
<input
id="invite"
class="form-control"
name="invite"
bind:value={invite}
aria-describedby="invite-help"
/>
<div id="invite-help" class="form-text">
You currently need an invite code to sign up. You can get one from an existing user.
</div>
</div>
{/if}
<div class="form-text">
By signing up, you agree to the <a href="/page/tos">terms of service</a> and the
<a href="/page/privacy">privacy policy</a>.
</div>
<button type="submit" class="btn btn-primary">Sign up</button>
</form>
{:else}
Loading...
{/if}

View file

@ -0,0 +1,16 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { userStore } from "$lib/store";
import { onMount } from "svelte";
export const ssr = false;
onMount(() => {
userStore.set(null);
localStorage.removeItem("pronouns-token");
localStorage.removeItem("pronouns-user");
goto("/");
});
</script>
<h1>Log out</h1>

View file

@ -1,17 +0,0 @@
<script lang="ts">
import type { PageData } from "./$types";
export let data: PageData;
</script>
<svelte:head>
<title>Log in with Discord - pronouns.cc</title>
</svelte:head>
<h1>Log in with Discord</h1>
{#if data.error}
{:else}
{/if}

View file

@ -60,7 +60,7 @@
}; };
const toggleTheme = () => { const toggleTheme = () => {
themeStore.set(theme === "dark" ? "light" : "dark") themeStore.set(theme === "dark" ? "light" : "dark");
}; };
const toggleMenu = () => { const toggleMenu = () => {
showMenu = !showMenu; showMenu = !showMenu;
@ -78,15 +78,21 @@
<NavbarToggler on:click={toggleMenu} /> <NavbarToggler on:click={toggleMenu} />
<Collapse isOpen={showMenu} navbar expand="lg"> <Collapse isOpen={showMenu} navbar expand="lg">
<Nav class="ms-auto" navbar> <Nav class="ms-auto" navbar>
<NavItem>
{#if currentUser} {#if currentUser}
<NavItem>
<NavLink href="/@{currentUser.name}">@{currentUser.name}</NavLink> <NavLink href="/@{currentUser.name}">@{currentUser.name}</NavLink>
<NavLink href="/settings">Settings</NavLink>
<NavLink href="/logout">Log out</NavLink>
{:else}
<NavLink href="/login">Log in</NavLink>
{/if}
</NavItem> </NavItem>
<NavItem>
<NavLink href="/settings">Settings</NavLink>
</NavItem>
<NavItem>
<NavLink href="/auth/logout">Log out</NavLink>
</NavItem>
{:else}
<NavItem>
<NavLink href="/auth/login">Log in</NavLink>
</NavItem>
{/if}
<NavItem> <NavItem>
<NavLink <NavLink
on:click={() => toggleTheme()} on:click={() => toggleTheme()}

View file

@ -1,5 +1,6 @@
import adapter from "@sveltejs/adapter-node"; import adapter from "@sveltejs/adapter-node";
import { vitePreprocess } from "@sveltejs/kit/vite"; import { vitePreprocess } from "@sveltejs/kit/vite";
import * as child_process from "node:child_process";
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
@ -9,6 +10,9 @@ const config = {
kit: { kit: {
adapter: adapter(), adapter: adapter(),
version: {
name: child_process.execSync("git rev-parse --short HEAD").toString().trim(),
},
}, },
}; };