adds oauth support
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ę",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const privateRoutes = [
|
||||
"/profile",
|
||||
"/lists",
|
||||
"/trail/edit/new",
|
||||
]
|
||||
|
||||
export function isRouteProtected(path: string) {
|
||||
|
||||
55
web/src/routes/api/v1/auth/oauth/+server.ts
Normal file
55
web/src/routes/api/v1/auth/oauth/+server.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
9
web/src/routes/login/+page.ts
Normal file
9
web/src/routes/login/+page.ts
Normal 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 }
|
||||
};
|
||||
72
web/src/routes/login/redirect/+page.svelte
Normal file
72
web/src/routes/login/redirect/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user