mirror of
				https://github.com/bartvdbraak/omnidash.git
				synced 2025-10-29 23:49:12 +00:00 
			
		
		
		
	feat: oauth login user navigation and settings layout
This commit is contained in:
		
							parent
							
								
									b4b2eb9055
								
							
						
					
					
						commit
						e8ac1ac2f7
					
				
					 17 changed files with 333 additions and 328 deletions
				
			
		
							
								
								
									
										2
									
								
								apps/web/src/app.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								apps/web/src/app.d.ts
									
										
									
									
										vendored
									
									
								
							|  | @ -5,6 +5,8 @@ declare global { | |||
| 	namespace App { | ||||
| 		interface Locals { | ||||
| 			pocketBase: PocketBase; | ||||
| 			id: string; | ||||
| 			email: string; | ||||
| 		} | ||||
| 		// interface Error {}
 | ||||
| 		// interface PageData {}
 | ||||
|  |  | |||
|  | @ -1,22 +1,65 @@ | |||
| import { type Handle } from '@sveltejs/kit'; | ||||
| // import { type Handle } from '@sveltejs/kit';
 | ||||
| // import PocketBase from 'pocketbase';
 | ||||
| // import { pb } from '$lib/pocketbase';
 | ||||
| // import { SERVER_PB } from '$env/static/private';
 | ||||
| 
 | ||||
| // /** @type {import('@sveltejs/kit').Handle} */
 | ||||
| // export const handle: Handle = async ({ event, resolve }) => {
 | ||||
| // 	event.locals.pocketBase = new PocketBase(SERVER_PB);
 | ||||
| 
 | ||||
| // 	pb.set(event.locals.pocketBase);
 | ||||
| 
 | ||||
| // 	event.locals.pocketBase.authStore.loadFromCookie(event.request.headers.get('cookie') ?? '');
 | ||||
| 
 | ||||
| // 	const response = await resolve(event);
 | ||||
| 
 | ||||
| // 	response.headers.set(
 | ||||
| // 		'set-cookie',
 | ||||
| // 		event.locals.pocketBase.authStore.exportToCookie({ secure: false })
 | ||||
| // 	);
 | ||||
| 
 | ||||
| // 	return response;
 | ||||
| // };
 | ||||
| 
 | ||||
| import { redirect, type Handle } from '@sveltejs/kit'; | ||||
| import PocketBase from 'pocketbase'; | ||||
| import { pb } from '$lib/pocketbase'; | ||||
| import { PUBLIC_CLIENT_PB } from '$env/static/public'; | ||||
| import { building } from '$app/environment'; | ||||
| import { SERVER_PB } from '$env/static/private'; | ||||
| 
 | ||||
| /** @type {import('@sveltejs/kit').Handle} */ | ||||
| export const handle: Handle = async ({ event, resolve }) => { | ||||
| 	event.locals.pocketBase = new PocketBase(PUBLIC_CLIENT_PB); | ||||
| 	event.locals.id = ''; | ||||
| 	event.locals.email = ''; | ||||
| 	event.locals.pocketBase = new PocketBase(SERVER_PB); | ||||
| 
 | ||||
| 	pb.set(event.locals.pocketBase); | ||||
| 	const isAuth: boolean = event.url.pathname === '/auth'; | ||||
| 	if (isAuth || building) { | ||||
| 		event.cookies.set('pb_auth', '', { path: '/' }); | ||||
| 		return await resolve(event); | ||||
| 	} | ||||
| 
 | ||||
| 	event.locals.pocketBase.authStore.loadFromCookie(event.request.headers.get('cookie') ?? ''); | ||||
| 	const pb_auth = event.request.headers.get('cookie') ?? ''; | ||||
| 	event.locals.pocketBase.authStore.loadFromCookie(pb_auth); | ||||
| 
 | ||||
| 	if (!event.locals.pocketBase.authStore.isValid) { | ||||
| 		console.log('Session expired'); | ||||
| 		throw redirect(303, '/auth'); | ||||
| 	} | ||||
| 	try { | ||||
| 		const auth = await event.locals.pocketBase | ||||
| 			.collection('users') | ||||
| 			.authRefresh<{ id: string; email: string }>(); | ||||
| 		event.locals.id = auth.record.id; | ||||
| 		event.locals.email = auth.record.email; | ||||
| 	} catch (_) { | ||||
| 		throw redirect(303, '/auth'); | ||||
| 	} | ||||
| 
 | ||||
| 	if (!event.locals.id) { | ||||
| 		throw redirect(303, '/auth'); | ||||
| 	} | ||||
| 
 | ||||
| 	const response = await resolve(event); | ||||
| 
 | ||||
| 	response.headers.set( | ||||
| 		'set-cookie', | ||||
| 		event.locals.pocketBase.authStore.exportToCookie({ secure: false }) | ||||
| 	); | ||||
| 
 | ||||
| 	const cookie = event.locals.pocketBase.authStore.exportToCookie({ sameSite: 'lax' }); | ||||
| 	response.headers.append('set-cookie', cookie); | ||||
| 	return response; | ||||
| }; | ||||
|  |  | |||
|  | @ -1,9 +1,9 @@ | |||
| <script> | ||||
| 	export let colours = { | ||||
| 		topleft: '#FF6C22', | ||||
| 		topright: '#FF9209', | ||||
| 		bottomleft: '#2B3499', | ||||
| 		bottomright: '#FFD099' | ||||
| 		topLeft: '#FF6C22', | ||||
| 		topRight: '#FF9209', | ||||
| 		bottomLeft: '#FFD099', | ||||
| 		bottomRight: '#2B3499' | ||||
| 	}; | ||||
| </script> | ||||
| 
 | ||||
|  | @ -17,8 +17,8 @@ | |||
| 	stroke-linecap="round" | ||||
| 	stroke-linejoin="round" | ||||
| > | ||||
| 	<rect width="7" height="9" x="3" y="3" rx="1" stroke={colours.topleft} /> | ||||
| 	<rect width="7" height="5" x="14" y="3" rx="1" stroke={colours.topright} /> | ||||
| 	<rect width="7" height="9" x="14" y="12" rx="1" stroke={colours.bottomleft} /> | ||||
| 	<rect width="7" height="5" x="3" y="16" rx="1" stroke={colours.bottomright} /> | ||||
| 	<rect width="7" height="9" x="3" y="3" rx="1" stroke={colours.topLeft} /> | ||||
| 	<rect width="7" height="5" x="14" y="3" rx="1" stroke={colours.topRight} /> | ||||
| 	<rect width="7" height="5" x="3" y="16" rx="1" stroke={colours.bottomLeft} /> | ||||
| 	<rect width="7" height="9" x="14" y="12" rx="1" stroke={colours.bottomRight} /> | ||||
| </svg> | ||||
|  |  | |||
|  | @ -21,7 +21,7 @@ | |||
| 		<DropdownMenu.Content class="w-56" align="end"> | ||||
| 			<DropdownMenu.Label class="font-normal"> | ||||
| 				<div class="flex flex-col space-y-1"> | ||||
| 					<p class="text-sm font-medium leading-none">{user?.name}</p> | ||||
| 					<p class="text-sm font-medium leading-none">{user?.name || user?.username}</p> | ||||
| 					<p class="text-xs leading-none text-muted-foreground">{user?.email}</p> | ||||
| 				</div> | ||||
| 			</DropdownMenu.Label> | ||||
|  | @ -34,7 +34,6 @@ | |||
| 			</DropdownMenu.Label> | ||||
| 			<DropdownMenu.Group> | ||||
| 				<DropdownMenu.Item href="/settings">Profile</DropdownMenu.Item> | ||||
| 				<DropdownMenu.Item href="/settings/account">Account</DropdownMenu.Item> | ||||
| 				<DropdownMenu.Item href="/settings/appearance">Appearance</DropdownMenu.Item> | ||||
| 				<DropdownMenu.Item href="/settings/notifications">Notifications</DropdownMenu.Item> | ||||
| 			</DropdownMenu.Group> | ||||
|  |  | |||
|  | @ -7,11 +7,6 @@ interface NavConfig { | |||
| 
 | ||||
| export const navConfig: NavConfig = { | ||||
| 	mainNav: [ | ||||
| 		{ | ||||
| 			title: 'Home', | ||||
| 			href: '/', | ||||
| 			always: true | ||||
| 		}, | ||||
| 		{ | ||||
| 			title: 'Dashboard', | ||||
| 			href: '/dashboard', | ||||
|  |  | |||
							
								
								
									
										44
									
								
								apps/web/src/routes/(auth)/auth/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										44
									
								
								apps/web/src/routes/(auth)/auth/+page.server.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,44 @@ | |||
| import { error, redirect, type Cookies } from '@sveltejs/kit'; | ||||
| import type { Actions } from './$types'; | ||||
| 
 | ||||
| export const actions = { | ||||
| 	login: async ({ | ||||
| 		request, | ||||
| 		locals, | ||||
| 		cookies | ||||
| 	}: { | ||||
| 		request: Request; | ||||
| 		locals: App.Locals; | ||||
| 		cookies: Cookies; | ||||
| 	}) => { | ||||
| 		const body = Object.fromEntries(await request.formData()); | ||||
| 		try { | ||||
| 			const email = body.email.toString(); | ||||
| 			const password = body.password.toString(); | ||||
| 			await locals.pocketBase.collection('users').authWithPassword(email, password); | ||||
| 			if (!locals.pocketBase?.authStore?.model?.verified) { | ||||
| 				locals.pocketBase.authStore.clear(); | ||||
| 				return { | ||||
| 					notVerified: true | ||||
| 				}; | ||||
| 			} | ||||
| 		} catch (err) { | ||||
| 			console.log('Error: ', err); | ||||
| 			throw error(500, 'Something went wrong logging in'); | ||||
| 		} | ||||
| 		cookies.set('pb_auth', JSON.stringify({ token: locals.pocketBase.authStore.token }), { | ||||
| 			path: '/' | ||||
| 		}); | ||||
| 
 | ||||
| 		throw redirect(303, '/'); | ||||
| 	}, | ||||
| 	oauth2: async ({ request, cookies }) => { | ||||
| 		const form = await request.formData(); | ||||
| 		const token = form.get('token'); | ||||
| 		if (!token || typeof token !== 'string') { | ||||
| 			throw redirect(303, '/auth'); | ||||
| 		} | ||||
| 		cookies.set('pb_auth', JSON.stringify({ token: token }), { path: '/' }); | ||||
| 		throw redirect(303, '/'); | ||||
| 	} | ||||
| } satisfies Actions; | ||||
							
								
								
									
										187
									
								
								apps/web/src/routes/(auth)/auth/+page.svelte
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										187
									
								
								apps/web/src/routes/(auth)/auth/+page.svelte
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,187 @@ | |||
| <script lang="ts"> | ||||
| 	import { enhance } from '$app/forms'; | ||||
| 	import { Icons } from '$lib/components/site/icons'; | ||||
| 	import { Button } from '$lib/components/ui/button'; | ||||
| 	import { Input } from '$lib/components/ui/input'; | ||||
| 	import { Label } from '$lib/components/ui/label'; | ||||
| 	import * as Alert from '$lib/components/ui/alert'; | ||||
| 	import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; | ||||
| 	import { cn } from '$lib/utils'; | ||||
| 	import { ChevronDown } from 'radix-icons-svelte'; | ||||
| 	import Separator from '$lib/components/ui/separator/separator.svelte'; | ||||
| 	import type { PageData } from './$types'; | ||||
| 	import PocketBase from 'pocketbase'; | ||||
| 	import { PUBLIC_CLIENT_PB } from '$env/static/public'; | ||||
| 
 | ||||
| 	let isLoading = false; | ||||
| 	export let form; | ||||
| 	export let data: PageData; | ||||
| 	const { providers } = data; | ||||
| 	const providersWithIcons = providers.map((provider) => ({ | ||||
| 		...provider, | ||||
| 		/* eslint-disable  @typescript-eslint/no-explicit-any */ | ||||
| 		icon: (Icons as { [key: string]: any })[provider.name] || undefined | ||||
| 	})); | ||||
| 	let currentProvider = providersWithIcons[0]; | ||||
| 
 | ||||
| 	const pb = new PocketBase(PUBLIC_CLIENT_PB); | ||||
| 	let oauth2Form: HTMLFormElement; | ||||
| 	async function loginWithOauth2(provider: string) { | ||||
| 		try { | ||||
| 			await pb.collection('users').authWithOAuth2({ provider }); | ||||
| 			const input = document.createElement('input'); | ||||
| 			input.type = 'hidden'; | ||||
| 			input.name = 'token'; | ||||
| 			input.value = pb.authStore.token; | ||||
| 			oauth2Form.appendChild(input); | ||||
| 			oauth2Form.submit(); | ||||
| 		} catch (err) { | ||||
| 			console.error(err); | ||||
| 		} | ||||
| 	} | ||||
| </script> | ||||
| 
 | ||||
| <div class="lg:p-8"> | ||||
| 	<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"> | ||||
| 		<div class="flex flex-col space-y-2 text-center"> | ||||
| 			<h1 class="text-2xl font-semibold tracking-tight">Log into your account</h1> | ||||
| 			<p class="text-sm text-muted-foreground"> | ||||
| 				Enter your email and password below to log into your account | ||||
| 			</p> | ||||
| 		</div> | ||||
| 		<div class={cn('grid gap-6')} {...$$restProps}> | ||||
| 			<form | ||||
| 				method="POST" | ||||
| 				action="?/login" | ||||
| 				use:enhance={() => { | ||||
| 					isLoading = true; | ||||
| 				}} | ||||
| 			> | ||||
| 				<div class="grid gap-2"> | ||||
| 					<div class="grid gap-1"> | ||||
| 						<Label class="sr-only" for="email">Email</Label> | ||||
| 						<Input | ||||
| 							id="email" | ||||
| 							name="email" | ||||
| 							placeholder="name@example.com" | ||||
| 							type="email" | ||||
| 							autocapitalize="none" | ||||
| 							autocomplete="email" | ||||
| 							autocorrect="off" | ||||
| 							disabled={isLoading} | ||||
| 						/> | ||||
| 					</div> | ||||
| 					<div class="grid gap-1"> | ||||
| 						<Label class="sr-only" for="password">Password</Label> | ||||
| 						<Input | ||||
| 							id="password" | ||||
| 							name="password" | ||||
| 							type="password" | ||||
| 							disabled={isLoading} | ||||
| 							placeholder="Password" | ||||
| 						/> | ||||
| 					</div> | ||||
| 					<Button type="submit" disabled={isLoading}> | ||||
| 						{#if isLoading} | ||||
| 							<Icons.spinner class="mr-2 h-4 w-4 animate-spin" /> | ||||
| 						{/if} | ||||
| 						Sign In | ||||
| 					</Button> | ||||
| 				</div> | ||||
| 				{#if form?.notVerified} | ||||
| 					<Alert.Root> | ||||
| 						<Alert.Title></Alert.Title> | ||||
| 						<Alert.Description>You must verify your email before you can login.</Alert.Description> | ||||
| 					</Alert.Root> | ||||
| 				{/if} | ||||
| 			</form> | ||||
| 		</div> | ||||
| 
 | ||||
| 		<form | ||||
| 			method="POST" | ||||
| 			action="?/oauth2" | ||||
| 			bind:this={oauth2Form} | ||||
| 			use:enhance={() => { | ||||
| 				isLoading = true; | ||||
| 			}} | ||||
| 		> | ||||
| 			{#if providers.length} | ||||
| 				<div class="relative"> | ||||
| 					<div class="absolute inset-0 flex items-center"> | ||||
| 						<span class="w-full border-t" /> | ||||
| 					</div> | ||||
| 					<div class="relative flex justify-center text-xs uppercase"> | ||||
| 						<span class="bg-background px-2 py-6 text-muted-foreground"> Or continue with </span> | ||||
| 					</div> | ||||
| 				</div> | ||||
| 				<div | ||||
| 					class="flex items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" | ||||
| 				> | ||||
| 					<input type="hidden" name="provider" bind:value={currentProvider.name} /> | ||||
| 					<div class="flex w-full items-center justify-center space-x-2"> | ||||
| 						<Button | ||||
| 							on:click={() => loginWithOauth2(currentProvider.name)} | ||||
| 							name={currentProvider.name} | ||||
| 							variant="ghost" | ||||
| 							class="w-full" | ||||
| 							disabled={isLoading} | ||||
| 						> | ||||
| 							{#if isLoading} | ||||
| 								<Icons.spinner class="mr-2 h-4 w-4 animate-spin" /> | ||||
| 							{:else if currentProvider.icon === undefined} | ||||
| 								<img | ||||
| 									src={`${PUBLIC_CLIENT_PB}/_/images/oauth2/${currentProvider.name}.svg`} | ||||
| 									alt={currentProvider.name} | ||||
| 									class="mr-2 h-4 w-4" | ||||
| 								/> | ||||
| 							{:else} | ||||
| 								<svelte:component this={currentProvider.icon} class="mr-2 h-4 w-4" /> | ||||
| 							{/if} | ||||
| 							{currentProvider.displayName} | ||||
| 						</Button> | ||||
| 					</div> | ||||
| 					{#if providers.length > 1} | ||||
| 						<div class="flex items-center space-x-2"> | ||||
| 							<Separator orientation="vertical" class="h-[20px] bg-secondary" /> | ||||
| 							<div class="flex items-center space-x-2"> | ||||
| 								<DropdownMenu.Root> | ||||
| 									<DropdownMenu.Trigger asChild let:builder> | ||||
| 										<Button builders={[builder]} variant="ghost" class="px-2 shadow-none"> | ||||
| 											<ChevronDown class="h-4 w-4" /> | ||||
| 										</Button> | ||||
| 									</DropdownMenu.Trigger> | ||||
| 									<DropdownMenu.Content class="" align="center"> | ||||
| 										<DropdownMenu.Label class="sr-only">Login Providers</DropdownMenu.Label> | ||||
| 										{#each providersWithIcons as provider} | ||||
| 											{#if provider.name !== currentProvider.name} | ||||
| 												<DropdownMenu.Item | ||||
| 													class="flex justify-center" | ||||
| 													on:click={() => (currentProvider = provider)} | ||||
| 												> | ||||
| 													{#if provider.icon === undefined} | ||||
| 														<img | ||||
| 															src={`${PUBLIC_CLIENT_PB}/_/images/oauth2/${provider.name}.svg`} | ||||
| 															alt={provider.name} | ||||
| 															class="mr-2 h-4 w-4" | ||||
| 														/> | ||||
| 													{:else} | ||||
| 														<svelte:component this={provider.icon} class="mr-2 h-4 w-4" /> | ||||
| 													{/if} | ||||
| 													{provider.displayName} | ||||
| 												</DropdownMenu.Item> | ||||
| 											{/if} | ||||
| 										{/each} | ||||
| 									</DropdownMenu.Content> | ||||
| 								</DropdownMenu.Root> | ||||
| 							</div> | ||||
| 						</div> | ||||
| 					{/if} | ||||
| 				</div> | ||||
| 			{/if} | ||||
| 		</form> | ||||
| 	</div> | ||||
| 	<p class="px-8 text-center text-xs text-muted-foreground"> | ||||
| 		Don't have an account? <a class="text-primary underline" href="/register">Sign up.</a> <br /> | ||||
| 		Forgot password? <a class="text-primary underline" href="/reset-password">Reset password.</a> | ||||
| 	</p> | ||||
| </div> | ||||
|  | @ -1,31 +0,0 @@ | |||
| import { error, redirect } from '@sveltejs/kit'; | ||||
| import type { Actions } from './$types'; | ||||
| 
 | ||||
| export const actions: Actions = { | ||||
| 	login: async ({ request, locals }: { request: Request; locals: App.Locals }) => { | ||||
| 		const body = Object.fromEntries(await request.formData()); | ||||
| 
 | ||||
| 		try { | ||||
| 			const email = body.email.toString(); | ||||
| 			const password = body.password.toString(); | ||||
| 			await locals.pocketBase.collection('users').authWithPassword(email, password); | ||||
| 			if (!locals.pocketBase?.authStore?.model?.verified) { | ||||
| 				locals.pocketBase.authStore.clear(); | ||||
| 				return { | ||||
| 					notVerified: true | ||||
| 				}; | ||||
| 			} | ||||
| 		} catch (err) { | ||||
| 			console.log('Error: ', err); | ||||
| 			throw error(500, 'Something went wrong logging in'); | ||||
| 		} | ||||
| 
 | ||||
| 		throw redirect(303, '/'); | ||||
| 	}, | ||||
| 	// TODO: Implement Oauth2 Auth
 | ||||
| 	oauth2: async ({ request, locals }: { request: Request; locals: App.Locals }) => { | ||||
| 		const body = Object.fromEntries(await request.formData()); | ||||
| 		const provider = body.provider.toString(); | ||||
| 		await locals.pocketBase.collection('users').authWithOAuth2({ provider: provider }); | ||||
| 	} | ||||
| }; | ||||
|  | @ -1,168 +0,0 @@ | |||
| <script lang="ts"> | ||||
| 	import { enhance } from '$app/forms'; | ||||
| 	import { Icons } from '$lib/components/site/icons'; | ||||
| 	import { Button } from '$lib/components/ui/button'; | ||||
| 	import { Input } from '$lib/components/ui/input'; | ||||
| 	import { Label } from '$lib/components/ui/label'; | ||||
| 	import * as Alert from '$lib/components/ui/alert'; | ||||
| 	import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; | ||||
| 	import { cn } from '$lib/utils'; | ||||
| 	import { ChevronDown } from 'radix-icons-svelte'; | ||||
| 	import Separator from '$lib/components/ui/separator/separator.svelte'; | ||||
| 	import type { PageData } from './$types'; | ||||
| 	import { PUBLIC_CLIENT_PB } from '$env/static/public'; | ||||
| 
 | ||||
| 	let isLoading = false; | ||||
| 	export let form; | ||||
| 	export let data: PageData; | ||||
| 	const { providers } = data; | ||||
| 	const providersWithIcons = providers.map((provider) => ({ | ||||
| 		...provider, | ||||
| 		/* eslint-disable  @typescript-eslint/no-explicit-any */ | ||||
| 		icon: (Icons as { [key: string]: any })[provider.name] || undefined | ||||
| 	})); | ||||
| 	let currentProvider = providersWithIcons[0]; | ||||
| </script> | ||||
| 
 | ||||
| <div class="lg:p-8"> | ||||
| 	<div class="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]"> | ||||
| 		<div class="flex flex-col space-y-2 text-center"> | ||||
| 			<h1 class="text-2xl font-semibold tracking-tight">Log into your account</h1> | ||||
| 			<p class="text-muted-foreground text-sm"> | ||||
| 				Enter your email and password below to log into your account | ||||
| 			</p> | ||||
| 		</div> | ||||
| 		<div class={cn('grid gap-6')} {...$$restProps}> | ||||
| 			<form | ||||
| 				method="POST" | ||||
| 				action="?/login" | ||||
| 				use:enhance={() => { | ||||
| 					isLoading = true; | ||||
| 				}} | ||||
| 			> | ||||
| 				<div class="grid gap-2"> | ||||
| 					<div class="grid gap-1"> | ||||
| 						<Label class="sr-only" for="email">Email</Label> | ||||
| 						<Input | ||||
| 							id="email" | ||||
| 							name="email" | ||||
| 							placeholder="name@example.com" | ||||
| 							type="email" | ||||
| 							autocapitalize="none" | ||||
| 							autocomplete="email" | ||||
| 							autocorrect="off" | ||||
| 							disabled={isLoading} | ||||
| 						/> | ||||
| 					</div> | ||||
| 					<div class="grid gap-1"> | ||||
| 						<Label class="sr-only" for="password">Password</Label> | ||||
| 						<Input | ||||
| 							id="password" | ||||
| 							name="password" | ||||
| 							type="password" | ||||
| 							disabled={isLoading} | ||||
| 							placeholder="Password" | ||||
| 						/> | ||||
| 					</div> | ||||
| 					<Button type="submit" disabled={isLoading}> | ||||
| 						{#if isLoading} | ||||
| 							<Icons.spinner class="mr-2 h-4 w-4 animate-spin" /> | ||||
| 						{/if} | ||||
| 						Sign In | ||||
| 					</Button> | ||||
| 				</div> | ||||
| 				{#if form?.notVerified} | ||||
| 					<Alert.Root> | ||||
| 						<Alert.Title></Alert.Title> | ||||
| 						<Alert.Description>You must verify your email before you can login.</Alert.Description> | ||||
| 					</Alert.Root> | ||||
| 				{/if} | ||||
| 			</form> | ||||
| 			<form | ||||
| 				method="POST" | ||||
| 				action="?/oauth2" | ||||
| 				use:enhance={() => { | ||||
| 					isLoading = true; | ||||
| 				}} | ||||
| 			> | ||||
| 				{#if providers.length} | ||||
| 					<div class="relative"> | ||||
| 						<div class="absolute inset-0 flex items-center"> | ||||
| 							<span class="w-full border-t" /> | ||||
| 						</div> | ||||
| 						<div class="relative flex justify-center text-xs uppercase"> | ||||
| 							<span class="bg-background text-muted-foreground px-2 py-6"> Or continue with </span> | ||||
| 						</div> | ||||
| 					</div> | ||||
| 					<div | ||||
| 						class="focus-visible:ring-ring border-input hover:bg-accent hover:text-accent-foreground flex items-center justify-between whitespace-nowrap rounded-md border bg-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50" | ||||
| 					> | ||||
| 						<input type="hidden" name="provider" bind:value={currentProvider.name} /> | ||||
| 						<div class="flex w-full items-center justify-center space-x-2"> | ||||
| 							<Button | ||||
| 								type="submit" | ||||
| 								name={currentProvider.name} | ||||
| 								variant="ghost" | ||||
| 								class="w-full" | ||||
| 								disabled={isLoading} | ||||
| 							> | ||||
| 								{#if isLoading} | ||||
| 									<Icons.spinner class="mr-2 h-4 w-4 animate-spin" /> | ||||
| 								{:else if currentProvider.icon === undefined} | ||||
| 									<img | ||||
| 										src={`${PUBLIC_CLIENT_PB}/_/images/oauth2/${currentProvider.name}.svg`} | ||||
| 										alt={currentProvider.name} | ||||
| 										class="mr-2 h-4 w-4" | ||||
| 									/> | ||||
| 								{:else} | ||||
| 									<svelte:component this={currentProvider.icon} class="mr-2 h-4 w-4" /> | ||||
| 								{/if} | ||||
| 								{currentProvider.displayName} | ||||
| 							</Button> | ||||
| 						</div> | ||||
| 						{#if providers.length > 1} | ||||
| 							<div class="flex items-center space-x-2"> | ||||
| 								<Separator orientation="vertical" class="bg-secondary h-[20px]" /> | ||||
| 								<div class="flex items-center space-x-2"> | ||||
| 									<DropdownMenu.Root> | ||||
| 										<DropdownMenu.Trigger asChild let:builder> | ||||
| 											<Button builders={[builder]} variant="ghost" class="px-2 shadow-none"> | ||||
| 												<ChevronDown class="h-4 w-4" /> | ||||
| 											</Button> | ||||
| 										</DropdownMenu.Trigger> | ||||
| 										<DropdownMenu.Content class="" align="center"> | ||||
| 											<DropdownMenu.Label class="sr-only">Login Providers</DropdownMenu.Label> | ||||
| 											{#each providersWithIcons as provider} | ||||
| 												{#if provider.name !== currentProvider.name} | ||||
| 													<DropdownMenu.Item | ||||
| 														class="flex justify-center" | ||||
| 														on:click={() => (currentProvider = provider)} | ||||
| 													> | ||||
| 														{#if provider.icon === undefined} | ||||
| 															<img | ||||
| 																src={`${PUBLIC_CLIENT_PB}/_/images/oauth2/${provider.name}.svg`} | ||||
| 																alt={provider.name} | ||||
| 																class="mr-2 h-4 w-4" | ||||
| 															/> | ||||
| 														{:else} | ||||
| 															<svelte:component this={provider.icon} class="mr-2 h-4 w-4" /> | ||||
| 														{/if} | ||||
| 														{provider.displayName} | ||||
| 													</DropdownMenu.Item> | ||||
| 												{/if} | ||||
| 											{/each} | ||||
| 										</DropdownMenu.Content> | ||||
| 									</DropdownMenu.Root> | ||||
| 								</div> | ||||
| 							</div> | ||||
| 						{/if} | ||||
| 					</div> | ||||
| 				{/if} | ||||
| 			</form> | ||||
| 		</div> | ||||
| 		<p class="text-muted-foreground px-8 text-center text-sm"> | ||||
| 			Don't have an account? <a class="text-primary underline" href="/register">Sign up.</a> <br /> | ||||
| 			Forgot password? <a class="text-primary underline" href="/reset-password">Reset password.</a> | ||||
| 		</p> | ||||
| 	</div> | ||||
| </div> | ||||
|  | @ -7,10 +7,6 @@ | |||
| 			title: 'Profile', | ||||
| 			href: '/settings' | ||||
| 		}, | ||||
| 		{ | ||||
| 			title: 'Account', | ||||
| 			href: '/settings/account' | ||||
| 		}, | ||||
| 		{ | ||||
| 			title: 'Appearance', | ||||
| 			href: '/settings/appearance' | ||||
|  |  | |||
|  | @ -9,8 +9,8 @@ | |||
| 
 | ||||
| <div class="space-y-6"> | ||||
| 	<div> | ||||
| 		<h3 class="text-lg font-medium">Profile</h3> | ||||
| 		<p class="text-sm text-muted-foreground">This is how others will see you on the site.</p> | ||||
| 		<h3 class="text-lg font-medium">Account</h3> | ||||
| 		<p class="text-sm text-muted-foreground">Update your account and profile settings.</p> | ||||
| 	</div> | ||||
| 	<Separator /> | ||||
| 	<ProfileForm data={form} {user} /> | ||||
|  |  | |||
|  | @ -1,24 +0,0 @@ | |||
| import { superValidate } from 'sveltekit-superforms/server'; | ||||
| import type { PageServerLoad } from './$types'; | ||||
| import { accountFormSchema } from './account-form.svelte'; | ||||
| import { fail, type Actions } from '@sveltejs/kit'; | ||||
| 
 | ||||
| export const load: PageServerLoad = async () => { | ||||
| 	return { | ||||
| 		form: await superValidate(accountFormSchema) | ||||
| 	}; | ||||
| }; | ||||
| 
 | ||||
| export const actions: Actions = { | ||||
| 	default: async (event) => { | ||||
| 		const form = await superValidate(event, accountFormSchema); | ||||
| 		if (!form.valid) { | ||||
| 			return fail(400, { | ||||
| 				form | ||||
| 			}); | ||||
| 		} | ||||
| 		return { | ||||
| 			form | ||||
| 		}; | ||||
| 	} | ||||
| }; | ||||
|  | @ -1,19 +0,0 @@ | |||
| <script lang="ts"> | ||||
| 	import { Separator } from '$lib/components/ui/separator'; | ||||
| 	import AccountForm from './account-form.svelte'; | ||||
| 	import type { PageData } from './$types'; | ||||
| 
 | ||||
| 	export let data: PageData; | ||||
| 	export let { form, user } = data; | ||||
| </script> | ||||
| 
 | ||||
| <div class="space-y-6"> | ||||
| 	<div> | ||||
| 		<h3 class="text-lg font-medium">Account</h3> | ||||
| 		<p class="text-sm text-muted-foreground"> | ||||
| 			Update your account settings. Set your preferred language and timezone. | ||||
| 		</p> | ||||
| 	</div> | ||||
| 	<Separator /> | ||||
| 	<AccountForm data={form} {user} /> | ||||
| </div> | ||||
|  | @ -1,45 +0,0 @@ | |||
| <script lang="ts" context="module"> | ||||
| 	import { z } from 'zod'; | ||||
| 
 | ||||
| 	export const accountFormSchema = z.object({ | ||||
| 		name: z | ||||
| 			.string({ | ||||
| 				required_error: 'Required.' | ||||
| 			}) | ||||
| 			.min(2, 'Name must be at least 2 characters.') | ||||
| 			.max(30, 'Name must not be longer than 30 characters') | ||||
| 	}); | ||||
| 
 | ||||
| 	export type AccountFormSchema = typeof accountFormSchema; | ||||
| </script> | ||||
| 
 | ||||
| <script lang="ts"> | ||||
| 	import * as Form from '$lib/components/ui/form'; | ||||
| 	import type { SuperValidated } from 'sveltekit-superforms'; | ||||
| 	import { dev } from '$app/environment'; | ||||
| 	import type { LayoutData } from '../$types'; | ||||
| 
 | ||||
| 	export let data: SuperValidated<AccountFormSchema>; | ||||
| 	export let user: LayoutData['user']; | ||||
| </script> | ||||
| 
 | ||||
| <Form.Root | ||||
| 	method="POST" | ||||
| 	class="space-y-8" | ||||
| 	let:config | ||||
| 	schema={accountFormSchema} | ||||
| 	form={data} | ||||
| 	debug={dev ? true : false} | ||||
| > | ||||
| 	<Form.Item> | ||||
| 		<Form.Field name="name" {config}> | ||||
| 			<Form.Label>Name</Form.Label> | ||||
| 			<Form.Input placeholder={user?.name} /> | ||||
| 			<Form.Description> | ||||
| 				This is the name that will be displayed on your profile and in emails. | ||||
| 			</Form.Description> | ||||
| 			<Form.Validation /> | ||||
| 		</Form.Field> | ||||
| 	</Form.Item> | ||||
| 	<Form.Button>Update account</Form.Button> | ||||
| </Form.Root> | ||||
|  | @ -1,6 +1,12 @@ | |||
| <script lang="ts" context="module"> | ||||
| 	import { z } from 'zod'; | ||||
| 	export const profileFormSchema = z.object({ | ||||
| 		name: z | ||||
| 			.string({ | ||||
| 				required_error: 'Required.' | ||||
| 			}) | ||||
| 			.min(2, 'Name must be at least 2 characters.') | ||||
| 			.max(30, 'Name must not be longer than 30 characters'), | ||||
| 		username: z | ||||
| 			.string() | ||||
| 			.min(2, 'Username must be at least 2 characters.') | ||||
|  | @ -32,6 +38,16 @@ | |||
| > | ||||
| 	<div class="grid grid-cols-[1fr,16rem] gap-4"> | ||||
| 		<div> | ||||
| 			<Form.Item> | ||||
| 				<Form.Field name="name" {config}> | ||||
| 					<Form.Label>Name</Form.Label> | ||||
| 					<Form.Input placeholder={user?.name} /> | ||||
| 					<Form.Description> | ||||
| 						This is the name that will be displayed on your profile and in emails. | ||||
| 					</Form.Description> | ||||
| 					<Form.Validation /> | ||||
| 				</Form.Field> | ||||
| 			</Form.Item> | ||||
| 			<Form.Item> | ||||
| 				<Form.Field {config} name="username"> | ||||
| 					<Form.Label>Username</Form.Label> | ||||
|  | @ -60,7 +76,7 @@ | |||
| 				<Form.Label>Profile Picture</Form.Label> | ||||
| 				<Avatar.Root class="aspect-square h-auto w-full"> | ||||
| 					<Avatar.Image src={user?.avatarUrl} alt={user?.name} /> | ||||
| 					<Avatar.Fallback>{user?.initials}</Avatar.Fallback> | ||||
| 					<Avatar.Fallback class="text-8xl">{user?.initials}</Avatar.Fallback> | ||||
| 				</Avatar.Root> | ||||
| 				<Form.Input type="file" placeholder={user?.email} /> | ||||
| 				<Form.Description> | ||||
|  |  | |||
|  | @ -3,18 +3,26 @@ import type { LayoutServerLoad } from './$types'; | |||
| const fullNameToInitials = (fullName: string) => | ||||
| 	fullName | ||||
| 		.split(' ') | ||||
| 		.filter((word) => word) | ||||
| 		.map((word) => word[0].toUpperCase()) | ||||
| 		.slice(0, 2) | ||||
| 		.join(''); | ||||
| 
 | ||||
| export const load: LayoutServerLoad = async ({ locals }: { locals: App.Locals }) => { | ||||
| export const load: LayoutServerLoad = async ({ | ||||
| 	locals, | ||||
| 	url | ||||
| }: { | ||||
| 	locals: App.Locals; | ||||
| 	url: { pathname: string }; | ||||
| }) => { | ||||
| 	const user = locals.pocketBase.authStore.model; | ||||
| 	if (user) { | ||||
| 		user.avatarUrl = locals.pocketBase.getFileUrl(user, user.avatar); | ||||
| 		user.initials = fullNameToInitials(user.name); | ||||
| 		user.initials = fullNameToInitials(user.name || user.username); | ||||
| 	} | ||||
| 
 | ||||
| 	return { | ||||
| 		url: url.pathname, | ||||
| 		authenticated: locals.pocketBase.authStore.isValid, | ||||
| 		user, | ||||
| 		providers: (await locals.pocketBase.collection('users').listAuthMethods()).authProviders | ||||
|  |  | |||
|  | @ -3,9 +3,9 @@ | |||
| 	import { Metadata, SiteFooter, SiteNavBar, TailwindIndicator } from '$lib/components/site'; | ||||
| 	import { ModeWatcher } from 'mode-watcher'; | ||||
| 	import '../app.pcss'; | ||||
| 	import { fade } from 'svelte/transition'; | ||||
| 	import type { LayoutData } from './$types'; | ||||
| 	import DataIndicator from '$lib/components/site/data-indicator.svelte'; | ||||
| 	import { fly } from 'svelte/transition'; | ||||
| 
 | ||||
| 	export let data: LayoutData; | ||||
| </script> | ||||
|  | @ -17,9 +17,11 @@ | |||
| <div class="relative flex min-h-screen flex-col" id="page"> | ||||
| 	<SiteNavBar authenticated={data.authenticated} user={data.user} /> | ||||
| 	<main class="container relative flex-1"> | ||||
| 		<div in:fade={{ duration: 200, delay: 100 }} out:fade={{ duration: 100 }}> | ||||
| 			<slot /> | ||||
| 		</div> | ||||
| 		{#key `/${data.url.split('/')[1]}`} | ||||
| 			<div in:fly={{ x: -200, duration: 200, delay: 100 }} out:fly={{ x: 200, duration: 100 }}> | ||||
| 				<slot /> | ||||
| 			</div> | ||||
| 		{/key} | ||||
| 	</main> | ||||
| 	<SiteFooter /> | ||||
| 	{#if dev} | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue