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"
"os"
"codeberg.org/u1f320/pronouns.cc/backend/log"
"emperror.dev/errors"
"github.com/Masterminds/squirrel"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/log/zapadapter"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/mediocregopher/radix/v4"
"github.com/minio/minio-go/v7"
@ -38,15 +35,7 @@ type DB struct {
}
func New() (*DB, error) {
pgxCfg, err := pgxpool.ParseConfig(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"))
pool, err := pgxpool.Connect(context.Background(), os.Getenv("DATABASE_URL"))
if err != nil {
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.
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 {
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.
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 {
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.RedirectURL = decoded.CallbackDomain + "/login/discord"
cfg.RedirectURL = decoded.CallbackDomain + "/auth/login/discord"
token, err := cfg.Exchange(r.Context(), decoded.Code)
if err != nil {
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
discordCfg := discordOAuthConfig
discordCfg.RedirectURL = req.CallbackDomain + "/login/discord"
discordCfg.RedirectURL = req.CallbackDomain + "/auth/login/discord"
render.JSON(w, r, oauthURLsResponse{
Discord: discordCfg.AuthCodeURL(state),
Discord: discordCfg.AuthCodeURL(state) + "&prompt=none",
})
return nil
}

View file

@ -35,7 +35,9 @@ type PartialMember struct {
ID xid.ID `json:"id"`
Name string `json:"name"`
DisplayName *string `json:"display_name"`
Bio *string `json:"bio"`
AvatarURLs []string `json:"avatar_urls"`
Links []string `json:"links"`
Names []db.FieldEntry `json:"names"`
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,
Name: members[i].Name,
DisplayName: members[i].DisplayName,
Bio: members[i].Bio,
AvatarURLs: db.NotNull(members[i].AvatarURLs),
Links: db.NotNull(members[i].Links),
Names: db.NotNull(members[i].Names),
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.
// 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 {
ctx := r.Context()

View file

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

View file

@ -1,6 +1,7 @@
<script lang="ts">
export let urls: string[] | null;
export let urls: string[];
export let alt: string;
export let width = 300;
const contentTypeFor = (url: string) => {
if (url.endsWith(".webp")) {
@ -15,14 +16,14 @@
};
</script>
{#if urls}
{#if urls.length > 0}
<picture class="rounded-circle img-fluid">
{#each urls as url}
<source width=300 srcSet={url} type={contentTypeFor(url)} />
<source {width} srcSet={url} type={contentTypeFor(url)} />
{/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>
{:else}
<!-- 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}

View file

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

View file

@ -1,15 +1,44 @@
<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";
export let user: User;
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>
<div>
<FallbackImage
urls={member.avatar_urls}
alt="Avatar for {member.name}"
/>
<a class="text-reset" href="/@{user.name}/{member.name}"><h5 class="m-2">{member.display_name ?? member.name}</h5></a>
<FallbackImage urls={member.avatar_urls} width={200} alt="Avatar for {member.name}" />
<p class="m-2">
<a class="text-reset fs-5" href="/@{user.name}/{member.name}">
{member.display_name ?? member.name}
</a>
{#if pronouns}
<br />
{pronouns}
{/if}
</p>
</div>

View file

@ -3,8 +3,8 @@ import { browser } from "$app/environment";
import type { MeUser } from "./api/entities";
export const userStore = writable<MeUser | null>(null);
export const tokenStore = writable<string | null>(null);
const initialUserValue = null;
export const userStore = writable<MeUser | null>(initialUserValue);
let defaultThemeValue = "dark";
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-icons/font/bootstrap-icons.css";
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>
<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 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 }) => {
const resp = await apiFetch<User>(`/users/${params.username}`, {
method: "GET",
});
try {
const resp = await apiFetch<User>(`/users/${params.username}`, {
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,41 +48,37 @@
</div>
{/if}
</div>
<div class="row">
{#if data.names}
<div class="col-md">
<h4>Names</h4>
<ul class="list-unstyled">
{#each data.names as name}
<li><StatusIcon status={name.status} /> {name.value}</li>
{/each}
</ul>
</div>
{/if}
{#if data.pronouns}
<div class="col-md">
<h4>Pronouns</h4>
<ul class="list-unstyled">
{#each data.pronouns as pronouns}
<li>
<StatusIcon status={pronouns.status} />
<PronounLink {pronouns} />
</li>
{/each}
</ul>
</div>
{/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}
<div class="col">
<FieldCard {field} />
</div>
{/each}
</div>
{/if}
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{#if data.names}
<div class="col-md">
<h4>Names</h4>
<ul class="list-unstyled">
{#each data.names as name}
<li><StatusIcon status={name.status} /> {name.value}</li>
{/each}
</ul>
</div>
{/if}
{#if data.pronouns}
<div class="col-md">
<h4>Pronouns</h4>
<ul class="list-unstyled">
{#each data.pronouns as pronouns}
<li>
<StatusIcon status={pronouns.status} />
<PronounLink {pronouns} />
</li>
{/each}
</ul>
</div>
{/if}
{#each data.fields as field}
<div class="col">
<FieldCard {field} />
</div>
{/each}
</div>
{#if data.members}
<div class="row">
<div class="col">
@ -90,7 +86,7 @@
<h2>Members</h2>
</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}
<PartialMemberCard user={data} {member} />
{/each}

View file

@ -49,7 +49,7 @@
</div>
{/if}
</div>
<div class="row">
<div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
{#if data.names}
<div class="col-md">
<h4>Names</h4>
@ -73,15 +73,11 @@
</ul>
</div>
{/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}
<div class="col">
<FieldCard {field} />
</div>
{/each}
</div>
{/if}
</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 type { PageServerLoad } from "./$types";
import type { PageServerLoad, Actions } from "./$types";
import { PUBLIC_BASE_URL } from "$env/static/public";
export const load = (async (event) => {
export const load = (async ({ locals, url }) => {
try {
const resp = await apiFetch<CallbackResponse>("/auth/discord/callback", {
method: "POST",
body: {
callback_domain: PUBLIC_BASE_URL,
code: event.url.searchParams.get("code"),
state: event.url.searchParams.get("state"),
code: url.searchParams.get("code"),
state: url.searchParams.get("state"),
},
});
@ -18,7 +18,9 @@ export const load = (async (event) => {
...resp,
};
} catch (e) {
return { error: e };
console.log(e);
return { error: e as APIError };
}
}) 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 = () => {
themeStore.set(theme === "dark" ? "light" : "dark")
themeStore.set(theme === "dark" ? "light" : "dark");
};
const toggleMenu = () => {
showMenu = !showMenu;
@ -78,15 +78,21 @@
<NavbarToggler on:click={toggleMenu} />
<Collapse isOpen={showMenu} navbar expand="lg">
<Nav class="ms-auto" navbar>
<NavItem>
{#if currentUser}
{#if currentUser}
<NavItem>
<NavLink href="/@{currentUser.name}">@{currentUser.name}</NavLink>
</NavItem>
<NavItem>
<NavLink href="/settings">Settings</NavLink>
<NavLink href="/logout">Log out</NavLink>
{:else}
<NavLink href="/login">Log in</NavLink>
{/if}
</NavItem>
</NavItem>
<NavItem>
<NavLink href="/auth/logout">Log out</NavLink>
</NavItem>
{:else}
<NavItem>
<NavLink href="/auth/login">Log in</NavLink>
</NavItem>
{/if}
<NavItem>
<NavLink
on:click={() => toggleTheme()}

View file

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