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

@@ -31,8 +31,9 @@ func main() {
APIKey: os.Getenv("MEILI_MASTER_KEY"), APIKey: os.Getenv("MEILI_MASTER_KEY"),
}) })
app.OnRecordAfterCreateRequest("users").Add(func(e *core.RecordCreateEvent) error { app.OnModelAfterCreate("users").Add(func(e *core.ModelEvent) error {
userId := e.Record.GetId() record := e.Model.(*models.Record)
userId := record.GetId()
searchRules := map[string]interface{}{ searchRules := map[string]interface{}{
"cities500": map[string]string{}, "cities500": map[string]string{},
@@ -44,9 +45,9 @@ func main() {
if token, err := generateMeilisearchToken(searchRules, client); err != nil { if token, err := generateMeilisearchToken(searchRules, client); err != nil {
return err return err
} else { } else {
e.Record.Set("token", token) record.Set("token", token)
if err := app.Dao().SaveRecord(e.Record); err != nil { if err := app.Dao().SaveRecord(record); err != nil {
return err return err
} }
} }

View File

@@ -87,6 +87,11 @@
animation: spinner 1.2s linear infinite; 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 { @keyframes spinner {
0% { 0% {
transform: rotate(0deg); transform: rotate(0deg);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { pb } from "$lib/pocketbase"; import { pb } from "$lib/pocketbase";
import { ClientResponseError } from "pocketbase"; import { ClientResponseError, type AuthMethodsList } from "pocketbase";
import { writable, type Writable } from "svelte/store"; import { writable, type Writable } from "svelte/store";
export type User = { 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) { export async function login(user: User) {
const r = await fetch('/api/v1/auth/login', { const r = await fetch('/api/v1/auth/login', {
method: 'POST', 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() { export async function logout() {
pb.authStore.clear(); pb.authStore.clear();
} }

View File

@@ -1,6 +1,7 @@
const privateRoutes = [ const privateRoutes = [
"/profile", "/profile",
"/lists", "/lists",
"/trail/edit/new",
] ]
export function isRouteProtected(path: string) { 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 { show_toast } from "$lib/stores/toast_store";
import { login, type User } from "$lib/stores/user_store"; import { login, type User } from "$lib/stores/user_store";
import { createForm } from "$lib/vendor/svelte-form-lib"; import { createForm } from "$lib/vendor/svelte-form-lib";
import { ClientResponseError } from "pocketbase"; import { ClientResponseError, type AuthProviderInfo } from "pocketbase";
import { _ } from "svelte-i18n"; import { _ } from "svelte-i18n";
import { object, string } from "yup"; import { object, string } from "yup";
let loading: boolean = false; let loading: boolean = false;
const redirectURL = "/redirect";
const authProviders = $page.data.authMethods.authProviders;
const { form, errors, handleChange, handleSubmit } = createForm<User>({ const { form, errors, handleChange, handleSubmit } = createForm<User>({
initialValues: { initialValues: {
id: "", id: "",
@@ -52,6 +56,13 @@
} }
}, },
}); });
function setProvider(provider: AuthProviderInfo) {
localStorage.setItem(
"provider",
JSON.stringify(provider),
);
}
</script> </script>
<svelte:head> <svelte:head>
@@ -59,7 +70,7 @@
</svelte:head> </svelte:head>
<main class="flex justify-center"> <main class="flex justify-center">
<form <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} on:submit={handleSubmit}
> >
{#if $theme == "light"} {#if $theme == "light"}
@@ -99,5 +110,28 @@
></span ></span
> >
{/if} {/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> </form>
</main> </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>