adds oauth support

This commit is contained in:
Christian Beutel
2024-03-29 13:13:23 +01:00
parent 28c6261e94
commit 033343e684
15 changed files with 226 additions and 12 deletions

View File

@@ -87,6 +87,11 @@
animation: spinner 1.2s linear infinite;
}
.spinner-dark:after {
border: 6px solid rgba(var(--primary));
border-color: rgba(var(--primary)) transparent rgba(var(--primary)) transparent;
}
@keyframes spinner {
0% {
transform: rotate(0deg);

View File

@@ -1,6 +1,5 @@
<script lang="ts">
import type { SummitLog } from "$lib/models/summit_log";
import { parse } from "date-fns";
import { _ } from "svelte-i18n";
import Dropdown, { type DropdownItem } from "../base/dropdown.svelte";
@@ -16,10 +15,11 @@
<div class="p-4 my-2 border border-input-border rounded-xl">
<div class="flex justify-between items-center mb-2">
<h5 class="font-medium mr-2">
{parse(log.date, "yyyy-MM-dd", new Date()).toLocaleDateString(
undefined,
{ month: "2-digit", day: "2-digit", year: "numeric" },
)}
{new Date(log.date).toLocaleDateString(undefined, {
month: "2-digit",
day: "2-digit",
year: "numeric",
})}
</h5>
{#if mode == "edit"}

View File

@@ -89,6 +89,7 @@
"no-results": "Keine Ergebnisse gefunden",
"not-a-valid-email-address": "Keine gültige Email-Adresse",
"not-completed": "Nicht abgeschlossen",
"or": "oder",
"password": "Passwort",
"photos": "Fotos",
"pick-a-trail": "Route auswählen",

View File

@@ -89,6 +89,7 @@
"no-results": "No results found",
"not-a-valid-email-address": "Not a valid email address",
"not-completed": "Not completed",
"or": "or",
"password": "Password",
"photos": "Photos",
"pick-a-trail": "Pick a trail",

View File

@@ -89,6 +89,7 @@
"no-results": "Pas de résultat",
"not-a-valid-email-address": "Adresse email invalide",
"not-completed": "Pas terminé",
"or": "ou",
"password": "Mot de passe",
"photos": "Photos",
"pick-a-trail": "Choisir un itinéraire",

View File

@@ -89,6 +89,7 @@
"no-results": "Geen resultaten gevonden",
"not-a-valid-email-address": "Geen geldig e-mail adres",
"not-completed": "Niet voltooid",
"or": "of",
"password": "Wachtwoord",
"photos": "Foto's",
"pick-a-trail": "Kies een route",

View File

@@ -89,6 +89,7 @@
"no-results": "Brak wyników",
"not-a-valid-email-address": "Nieprawidłowy adres email",
"not-completed": "Nie dokończono",
"or": "lub",
"password": "Hasło",
"photos": "Zdjęcia",
"pick-a-trail": "Wybierz ścieżkę",

View File

@@ -89,6 +89,7 @@
"no-results": "Nenhum resultado encontrado",
"not-a-valid-email-address": "Não um endereço de e-mail válido",
"not-completed": "Não preenchido",
"or": "ou",
"password": "Senha",
"photos": "Fotos",
"pick-a-trail": "Escolha uma trilha",

View File

@@ -1,5 +1,5 @@
import { pb } from "$lib/pocketbase";
import { ClientResponseError } from "pocketbase";
import { ClientResponseError, type AuthMethodsList } from "pocketbase";
import { writable, type Writable } from "svelte/store";
export type User = {
@@ -30,6 +30,20 @@ export async function users_create(user: User) {
}
export async function users_auth_methods(f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response> = fetch): Promise<AuthMethodsList> {
const r = await f('/api/v1/auth/oauth', {
method: 'GET',
})
if (r.ok) {
return await r.json()
} else {
throw new ClientResponseError(await r.json())
}
}
export async function login(user: User) {
const r = await fetch('/api/v1/auth/login', {
method: 'POST',
@@ -44,6 +58,23 @@ export async function login(user: User) {
}
export async function oauth_login(data: { name: string, code: string, codeVerifier: string }) {
const r = await fetch('/api/v1/auth/oauth', {
method: 'POST',
body: JSON.stringify(data)
})
console.log(r);
if (r.ok) {
pb.authStore.loadFromCookie(document.cookie)
} else {
throw new ClientResponseError(await r.json())
}
}
export async function logout() {
pb.authStore.clear();
}

View File

@@ -1,6 +1,7 @@
const privateRoutes = [
"/profile",
"/lists",
"/trail/edit/new",
]
export function isRouteProtected(path: string) {

View File

@@ -0,0 +1,55 @@
import { env } from "$env/dynamic/public";
import { env as private_env } from "$env/dynamic/private";
import { pb } from "$lib/pocketbase";
import { error, json, type RequestEvent } from "@sveltejs/kit";
const redirectURL = private_env.ORIGIN + "/login/redirect"
export async function GET(event: RequestEvent) {
try {
const r = await pb.collection('users').listAuthMethods();
for (const provider of r.authProviders) {
const imageURL = `${env.PUBLIC_POCKETBASE_URL}/_/images/oauth2/${provider.name}.svg`
provider['img' as keyof typeof provider] = await imageUrlToBase64(imageURL, event.fetch);
provider['url' as keyof typeof provider] = `${provider.authUrl}${redirectURL}`
}
return json(r)
} catch (e: any) {
throw error(e.status, e);
}
}
export async function POST(event: RequestEvent) {
const data = await event.request.json();
try {
const r = await pb.collection('users').authWithOAuth2Code(
data.name,
data.code,
data.codeVerifier,
redirectURL,
)
return json(r)
} catch (e: any) {
throw error(e.status, e);
}
}
async function imageUrlToBase64(url: string, f: (url: RequestInfo | URL, config?: RequestInit) => Promise<Response>) {
try {
const response = await f(url);
if (!response.ok) {
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
}
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const base64DataUrl = `data:${response.headers.get('content-type') || 'image/png'};base64,${buffer.toString('base64')}`;
return base64DataUrl;
} catch (error) {
console.error('Error fetching image:', error);
throw error;
}
}

View File

@@ -10,11 +10,15 @@
import { show_toast } from "$lib/stores/toast_store";
import { login, type User } from "$lib/stores/user_store";
import { createForm } from "$lib/vendor/svelte-form-lib";
import { ClientResponseError } from "pocketbase";
import { ClientResponseError, type AuthProviderInfo } from "pocketbase";
import { _ } from "svelte-i18n";
import { object, string } from "yup";
let loading: boolean = false;
const redirectURL = "/redirect";
const authProviders = $page.data.authMethods.authProviders;
const { form, errors, handleChange, handleSubmit } = createForm<User>({
initialValues: {
id: "",
@@ -52,6 +56,13 @@
}
},
});
function setProvider(provider: AuthProviderInfo) {
localStorage.setItem(
"provider",
JSON.stringify(provider),
);
}
</script>
<svelte:head>
@@ -59,7 +70,7 @@
</svelte:head>
<main class="flex justify-center">
<form
class="login-panel max-w-md border border-input-border rounded-xl p-8 flex flex-col justify-center items-center gap-8 w-[28rem] mt-8"
class="login-panel max-w-md border border-input-border rounded-xl p-8 flex flex-col justify-center items-center gap-4 w-[28rem] mt-8"
on:submit={handleSubmit}
>
{#if $theme == "light"}
@@ -99,5 +110,28 @@
></span
>
{/if}
{#if authProviders.length}
<div class="flex gap-4 items-center w-full">
<hr class="basis-full border-input-border" />
<span class="text-gray-500 uppercase">{$_("or")}</span>
<hr class="basis-full border-input-border" />
</div>
<div class="w-80 space-y-4">
{#each authProviders as provider}
<a
href={provider.url}
class="btn-secondary inline-flex min-w-full justify-center"
on:click={() => setProvider(provider)}
>
<img
class="w-5 aspect-square mr-4"
src={provider.img}
alt="Provider logo"
/>
Login with {provider.displayName}
</a>
{/each}
</div>
{/if}
</form>
</main>

View File

@@ -0,0 +1,9 @@
import { users_auth_methods } from "$lib/stores/user_store";
import { type Load } from "@sveltejs/kit";
export const load: Load = async ({ fetch }) => {
const authMethods = await users_auth_methods(fetch)
return { authMethods: authMethods }
};

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import { oauth_login } from "$lib/stores/user_store";
import type { AuthProviderInfo } from "pocketbase";
import { onMount } from "svelte";
let error = $page.url.searchParams.get("error");
let errorDescription = $page.url.searchParams.get("error_description");
const errorURI = $page.url.searchParams.get("error_uri");
const state = $page.url.searchParams.get("state");
const code = $page.url.searchParams.get("code");
onMount(async () => {
if (error || !state || !code) {
return;
}
const providerData = localStorage.getItem("provider");
if (!providerData) {
error = "missing_provider";
errorDescription =
"No OAuth provider was specified in local storage.";
return;
}
const provider: AuthProviderInfo = JSON.parse(providerData);
if (provider.state !== state) {
error = "mismacthed_provider";
errorDescription =
"OAuth provider does not match the one defined in local storage.";
return;
}
oauth_login({
name: provider.name,
code: code,
codeVerifier: provider.codeVerifier,
})
.then(() => {
goto("/");
})
.catch((e) => {
error = "oauth_error";
errorDescription = e.toString();
});
});
</script>
<main
class="flex items-center justify-center"
style="min-height: calc(100vh - 388px)"
>
{#if error}
<div
class="rounded-xl bg-input-background-error border border-red-400 p-6 max-w-xl space-y-4"
>
<h5 class="text-xl font-semibold">{error}</h5>
<p>{errorDescription}</p>
{#if errorURI}
<p><a class="underline" href={errorURI}>More Info</a></p>
{/if}
</div>
{:else}
<div class="max-w-fit space-y-4 text-center">
<div class="spinner spinner-dark"></div>
<h5 class="text-xl font-semibold">Authenticating...</h5>
</div>
{/if}
</main>