adds oauth support
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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ę",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
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 { 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>
|
||||||
|
|||||||
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