sveltekit-auth-example 5.0.4 → 5.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +9 -3
- package/{.env.sample → .env.example} +1 -0
- package/.prettierignore +1 -1
- package/.vscode/mcp.json +13 -0
- package/.vscode/settings.json +7 -5
- package/.yarn/releases/yarn-4.13.0.cjs +940 -0
- package/.yarnrc.yml +1 -1
- package/AGENTS.md +23 -0
- package/CHANGELOG.md +8 -0
- package/README.md +2 -3
- package/db_create.sql +98 -49
- package/{eslint.config.js → eslint.config.mjs} +4 -3
- package/package.json +34 -32
- package/playwright.config.ts +24 -0
- package/prettier.config.mjs +14 -5
- package/src/app.d.ts +1 -1
- package/src/app.html +1 -1
- package/src/hooks.server.ts +47 -9
- package/src/lib/app-state.svelte.ts +19 -0
- package/src/lib/auth-redirect.ts +25 -0
- package/src/lib/google.ts +7 -26
- package/src/lib/server/db.ts +63 -10
- package/src/lib/server/sendgrid.ts +5 -9
- package/src/routes/+error.svelte +3 -3
- package/src/routes/+layout.svelte +91 -125
- package/src/routes/api/v1/user/+server.ts +16 -0
- package/src/routes/auth/[slug]/+server.ts +5 -56
- package/src/routes/auth/forgot/+server.ts +6 -1
- package/src/routes/auth/google/+server.ts +1 -1
- package/src/routes/auth/login/+server.ts +68 -0
- package/src/routes/auth/logout/+server.ts +19 -0
- package/src/routes/auth/register/+server.ts +64 -0
- package/src/routes/auth/reset/+server.ts +7 -0
- package/src/routes/auth/reset/[token]/+page.svelte +102 -84
- package/src/routes/auth/verify/[token]/+server.ts +48 -0
- package/src/routes/forgot/+page.svelte +64 -54
- package/src/routes/layout.css +63 -0
- package/src/routes/login/+page.server.ts +9 -0
- package/src/routes/login/+page.svelte +73 -115
- package/src/routes/profile/+page.svelte +174 -123
- package/src/routes/register/+page.svelte +147 -125
- package/src/service-worker.ts +22 -4
- package/svelte.config.js +13 -1
- package/tsconfig.json +3 -1
- package/vite.config.ts +5 -1
- package/.yarn/releases/yarn-4.9.2.cjs +0 -942
- package/src/stores.ts +0 -13
package/src/lib/google.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { page } from '$app/stores'
|
|
2
|
-
import { goto } from '$app/navigation'
|
|
3
1
|
import { PUBLIC_GOOGLE_CLIENT_ID } from '$env/static/public'
|
|
4
|
-
import {
|
|
2
|
+
import { appState } from '$lib/app-state.svelte'
|
|
3
|
+
import { redirectAfterLogin } from '$lib/auth-redirect'
|
|
5
4
|
|
|
6
5
|
export function renderGoogleButton() {
|
|
7
6
|
const btn = document.getElementById('googleButton')
|
|
@@ -10,26 +9,19 @@ export function renderGoogleButton() {
|
|
|
10
9
|
type: 'standard',
|
|
11
10
|
theme: 'filled_blue',
|
|
12
11
|
size: 'large',
|
|
13
|
-
width:
|
|
12
|
+
width: btn.offsetWidth || 400
|
|
14
13
|
})
|
|
15
14
|
}
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
export function initializeGoogleAccounts() {
|
|
19
|
-
|
|
20
|
-
const unsubscribe = googleInitialized.subscribe(value => {
|
|
21
|
-
initialized = value
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
if (!initialized) {
|
|
18
|
+
if (!appState.googleInitialized) {
|
|
25
19
|
google.accounts.id.initialize({
|
|
26
20
|
client_id: PUBLIC_GOOGLE_CLIENT_ID,
|
|
27
21
|
callback: googleCallback
|
|
28
22
|
})
|
|
29
|
-
|
|
30
|
-
googleInitialized.set(true)
|
|
23
|
+
appState.googleInitialized = true
|
|
31
24
|
}
|
|
32
|
-
unsubscribe()
|
|
33
25
|
|
|
34
26
|
async function googleCallback(response: google.accounts.id.CredentialResponse) {
|
|
35
27
|
const res = await fetch('/auth/google', {
|
|
@@ -42,19 +34,8 @@ export function initializeGoogleAccounts() {
|
|
|
42
34
|
|
|
43
35
|
if (res.ok) {
|
|
44
36
|
const fromEndpoint = await res.json()
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
let referrer
|
|
49
|
-
const unsubscribe = page.subscribe(p => {
|
|
50
|
-
referrer = p.url.searchParams.get('referrer')
|
|
51
|
-
})
|
|
52
|
-
unsubscribe()
|
|
53
|
-
|
|
54
|
-
if (referrer) return goto(referrer)
|
|
55
|
-
if (role === 'teacher') return goto('/teachers')
|
|
56
|
-
if (role === 'admin') return goto('/admin')
|
|
57
|
-
if (location.pathname === '/login') goto('/') // logged in so go home
|
|
37
|
+
appState.user = fromEndpoint.user
|
|
38
|
+
redirectAfterLogin(fromEndpoint.user)
|
|
58
39
|
}
|
|
59
40
|
}
|
|
60
41
|
}
|
package/src/lib/server/db.ts
CHANGED
|
@@ -1,16 +1,69 @@
|
|
|
1
|
-
|
|
2
|
-
import type { QueryResult } from 'pg'
|
|
1
|
+
import type { QueryResult, QueryResultRow } from 'pg'
|
|
3
2
|
import pg from 'pg'
|
|
4
|
-
import {
|
|
3
|
+
import { env } from '$env/dynamic/private'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generic function signature for executing a typed SQL query.
|
|
7
|
+
*
|
|
8
|
+
* @template T The row type returned from the database, extending QueryResultRow.
|
|
9
|
+
* @param sql The parameterized SQL query string.
|
|
10
|
+
* @param params Optional positional parameters to bind to the query.
|
|
11
|
+
* @returns A promise resolving to the typed QueryResult.
|
|
12
|
+
*/
|
|
13
|
+
type QueryFunction = <T extends QueryResultRow>(
|
|
14
|
+
sql: string,
|
|
15
|
+
params?: (string | number | boolean | object | null)[],
|
|
16
|
+
name?: string
|
|
17
|
+
) => Promise<QueryResult<T>>
|
|
18
|
+
|
|
19
|
+
let queryFn: QueryFunction
|
|
5
20
|
|
|
6
21
|
const pool = new pg.Pool({
|
|
7
22
|
max: 10, // default
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
23
|
+
idleTimeoutMillis: 10000,
|
|
24
|
+
connectionTimeoutMillis: 5000,
|
|
25
|
+
connectionString: env.DATABASE_URL,
|
|
26
|
+
ssl: env.DATABASE_SSL === 'true' ? { rejectUnauthorized: false } : false
|
|
13
27
|
})
|
|
14
28
|
|
|
15
|
-
|
|
16
|
-
|
|
29
|
+
pool.on('error', (err: Error) => {
|
|
30
|
+
console.error('Unexpected error on idle client', err)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
queryFn = <T extends QueryResultRow>(
|
|
34
|
+
sql: string,
|
|
35
|
+
params?: (string | number | boolean | object | null)[],
|
|
36
|
+
name?: string
|
|
37
|
+
) => pool.query<T>(name ? { name, text: sql, values: params } : { text: sql, values: params })
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Executes a parameterized SQL query against the PostgreSQL database.
|
|
41
|
+
*
|
|
42
|
+
* @template T - The expected shape of rows returned by the query. Must extend QueryResultRow.
|
|
43
|
+
* @param sql - The SQL query string. Use $1, $2, etc. for parameterized queries.
|
|
44
|
+
* @param params - Optional array of parameter values to bind to the query placeholders.
|
|
45
|
+
* @returns A Promise resolving to a PostgreSQL QueryResult containing typed rows and metadata.
|
|
46
|
+
* @throws {Error} If the database connection fails or the query is invalid.
|
|
47
|
+
*
|
|
48
|
+
* @example
|
|
49
|
+
* ```typescript
|
|
50
|
+
* // Simple query without parameters
|
|
51
|
+
* const result = await query('SELECT * FROM users');
|
|
52
|
+
* console.log(result.rows);
|
|
53
|
+
*
|
|
54
|
+
* // Parameterized query with type safety
|
|
55
|
+
* const result = await query<User>('SELECT * FROM users WHERE id = $1', [userId]);
|
|
56
|
+
* console.log(result.rows[0].name); // TypeScript knows this is a User
|
|
57
|
+
*
|
|
58
|
+
* // Insert with multiple parameters
|
|
59
|
+
* await query(
|
|
60
|
+
* 'INSERT INTO products (name, price) VALUES ($1, $2)',
|
|
61
|
+
* ['Widget', 29.99]
|
|
62
|
+
* );
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export const query = <T extends QueryResultRow = any>(
|
|
66
|
+
sql: string,
|
|
67
|
+
params?: (string | number | boolean | object | null)[],
|
|
68
|
+
name?: string
|
|
69
|
+
): Promise<QueryResult<T>> => queryFn<T>(sql, params, name)
|
|
@@ -4,14 +4,10 @@ import { env } from '$env/dynamic/private'
|
|
|
4
4
|
|
|
5
5
|
export const sendMessage = async (message: Partial<MailDataRequired>) => {
|
|
6
6
|
const { SENDGRID_SENDER, SENDGRID_KEY } = env
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
...message
|
|
12
|
-
}
|
|
13
|
-
await sgMail.send(completeMessage)
|
|
14
|
-
} catch (errSendingMail) {
|
|
15
|
-
console.error(errSendingMail)
|
|
7
|
+
sgMail.setApiKey(SENDGRID_KEY)
|
|
8
|
+
const completeMessage = <MailDataRequired>{
|
|
9
|
+
from: SENDGRID_SENDER, // default sender can be altered
|
|
10
|
+
...message
|
|
16
11
|
}
|
|
12
|
+
await sgMail.send(completeMessage)
|
|
17
13
|
}
|
package/src/routes/+error.svelte
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import { onMount } from 'svelte'
|
|
3
3
|
import type { LayoutServerData } from './$types'
|
|
4
4
|
import { goto, beforeNavigate } from '$app/navigation'
|
|
5
|
-
import {
|
|
5
|
+
import { appState } from '$lib/app-state.svelte'
|
|
6
6
|
import { initializeGoogleAccounts } from '$lib/google'
|
|
7
7
|
|
|
8
|
-
import '
|
|
8
|
+
import './layout.css'
|
|
9
9
|
|
|
10
10
|
interface Props {
|
|
11
11
|
data: LayoutServerData
|
|
@@ -14,167 +14,133 @@
|
|
|
14
14
|
|
|
15
15
|
let { data, children }: Props = $props()
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
$effect(() => {
|
|
18
|
+
appState.user = data.user
|
|
19
|
+
})
|
|
20
20
|
|
|
21
|
-
let
|
|
21
|
+
let navOpen = $state(false)
|
|
22
|
+
let dropdownOpen = $state(false)
|
|
23
|
+
let dropdownEl: HTMLDivElement | undefined = $state()
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
function handleWindowClick(e: MouseEvent) {
|
|
26
|
+
if (dropdownOpen && dropdownEl && !dropdownEl.contains(e.target as Node)) {
|
|
27
|
+
dropdownOpen = false
|
|
28
|
+
}
|
|
29
|
+
}
|
|
25
30
|
|
|
31
|
+
beforeNavigate(() => {
|
|
32
|
+
navOpen = false
|
|
33
|
+
dropdownOpen = false
|
|
34
|
+
const expirationDate = appState.user?.expires ? new Date(appState.user.expires) : undefined
|
|
26
35
|
if (expirationDate && expirationDate < new Date()) {
|
|
27
36
|
console.log('Login session expired.')
|
|
28
|
-
|
|
37
|
+
appState.user = undefined
|
|
29
38
|
}
|
|
30
39
|
})
|
|
31
40
|
|
|
32
|
-
onMount(
|
|
41
|
+
onMount(() => {
|
|
33
42
|
initializeGoogleAccounts()
|
|
34
|
-
|
|
35
|
-
await import('bootstrap/js/dist/collapse') // lots of ways to load Bootstrap but prefer this approach to avoid SSR issues
|
|
36
|
-
await import('bootstrap/js/dist/dropdown')
|
|
37
|
-
Toast = (await import('bootstrap/js/dist/toast')).default
|
|
38
|
-
|
|
39
|
-
if (!$loginSession) google.accounts.id.prompt()
|
|
43
|
+
if (!appState.user) google.accounts.id.prompt()
|
|
40
44
|
})
|
|
41
45
|
|
|
42
46
|
async function logout(event: MouseEvent) {
|
|
43
47
|
event.preventDefault()
|
|
44
|
-
|
|
45
|
-
const url = '/auth/logout'
|
|
46
|
-
const res = await fetch(url, {
|
|
47
|
-
method: 'POST'
|
|
48
|
-
})
|
|
48
|
+
const res = await fetch('/auth/logout', { method: 'POST' })
|
|
49
49
|
if (res.ok) {
|
|
50
|
-
|
|
50
|
+
appState.user = undefined
|
|
51
51
|
goto('/login')
|
|
52
52
|
} else console.error(`Logout not successful: ${res.statusText} (${res.status})`)
|
|
53
53
|
}
|
|
54
|
+
</script>
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
if (open) {
|
|
57
|
-
const toastDiv = document.getElementById('authToast') as HTMLDivElement
|
|
58
|
-
const t = new Toast(toastDiv)
|
|
59
|
-
t.show()
|
|
60
|
-
}
|
|
61
|
-
}
|
|
56
|
+
<svelte:window onclick={handleWindowClick} />
|
|
62
57
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
</script>
|
|
58
|
+
<nav class="tw:bg-gray-100 tw:border-b tw:border-gray-200">
|
|
59
|
+
<div class="tw:mx-auto tw:max-w-5xl tw:px-4 tw:flex tw:items-center tw:justify-between tw:h-14">
|
|
60
|
+
<a class="tw:font-semibold tw:text-gray-800 tw:no-underline" href="/">SvelteKit-Auth-Example</a>
|
|
67
61
|
|
|
68
|
-
|
|
69
|
-
<div class="container">
|
|
70
|
-
<a class="navbar-brand" href="/">SvelteKit-Auth-Example</a>
|
|
62
|
+
<!-- Mobile toggle -->
|
|
71
63
|
<button
|
|
72
|
-
class="
|
|
73
|
-
type="button"
|
|
74
|
-
data-bs-toggle="collapse"
|
|
75
|
-
data-bs-target="#navbarMain"
|
|
76
|
-
aria-controls="navbarMain"
|
|
77
|
-
aria-expanded="false"
|
|
64
|
+
class="tw:sm:hidden tw:p-2 tw:rounded tw:text-gray-600 hover:tw:bg-gray-200"
|
|
78
65
|
aria-label="Toggle navigation"
|
|
66
|
+
onclick={() => (navOpen = !navOpen)}
|
|
79
67
|
>
|
|
80
|
-
<
|
|
68
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="tw:h-5 tw:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
69
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
|
70
|
+
</svg>
|
|
81
71
|
</button>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
<
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"
|
|
118
|
-
/>
|
|
119
|
-
</svg>
|
|
120
|
-
{$loginSession.firstName}
|
|
121
|
-
</a>
|
|
122
|
-
<ul class="dropdown-menu">
|
|
123
|
-
<li>
|
|
124
|
-
<a class="dropdown-item" href="/profile">Profile</a>
|
|
125
|
-
</li>
|
|
72
|
+
|
|
73
|
+
<!-- Nav links -->
|
|
74
|
+
<div class="tw:hidden tw:sm:flex tw:items-center tw:gap-6 {navOpen ? '!tw:flex tw:flex-col tw:absolute tw:top-14 tw:left-0 tw:right-0 tw:bg-gray-100 tw:p-4 tw:border-b tw:border-gray-200' : ''}">
|
|
75
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/">Home</a>
|
|
76
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/info">Info</a>
|
|
77
|
+
|
|
78
|
+
{#if appState.user?.role === 'admin'}
|
|
79
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/admin">Admin</a>
|
|
80
|
+
{/if}
|
|
81
|
+
{#if appState.user && appState.user.role !== 'student'}
|
|
82
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/teachers">Teachers</a>
|
|
83
|
+
{/if}
|
|
84
|
+
|
|
85
|
+
{#if appState.user}
|
|
86
|
+
<!-- User dropdown -->
|
|
87
|
+
<div class="tw:relative" bind:this={dropdownEl}>
|
|
88
|
+
<button
|
|
89
|
+
class="tw:flex tw:items-center tw:gap-1 tw:text-sm tw:text-gray-700 hover:tw:text-gray-900 tw:bg-transparent tw:border-0 tw:cursor-pointer"
|
|
90
|
+
onclick={() => (dropdownOpen = !dropdownOpen)}
|
|
91
|
+
aria-expanded={dropdownOpen}
|
|
92
|
+
aria-haspopup="true"
|
|
93
|
+
>
|
|
94
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" class="tw:relative tw:top-[-1.5px]">
|
|
95
|
+
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
|
|
96
|
+
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z" />
|
|
97
|
+
</svg>
|
|
98
|
+
{appState.user?.firstName}
|
|
99
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
|
100
|
+
<path d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z"/>
|
|
101
|
+
</svg>
|
|
102
|
+
</button>
|
|
103
|
+
{#if dropdownOpen}
|
|
104
|
+
<ul class="tw:absolute tw:right-0 tw:mt-1 tw:w-36 tw:rounded tw:border tw:border-gray-200 tw:bg-white tw:shadow-md tw:py-1 tw:z-50 tw:list-none">
|
|
105
|
+
<li><a class="tw:block tw:px-4 tw:py-2 tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:bg-gray-100" href="/profile">Profile</a></li>
|
|
106
|
+
{#if appState.user?.id !== 0}
|
|
126
107
|
<li>
|
|
127
|
-
<
|
|
108
|
+
<button
|
|
128
109
|
onclick={logout}
|
|
129
|
-
class="
|
|
130
|
-
|
|
131
|
-
href={'#'}>Logout</a
|
|
132
|
-
>
|
|
110
|
+
class="tw:block tw:w-full tw:text-left tw:px-4 tw:py-2 tw:text-sm tw:text-gray-700 tw:bg-transparent tw:border-0 tw:cursor-pointer hover:tw:bg-gray-100"
|
|
111
|
+
>Logout</button>
|
|
133
112
|
</li>
|
|
113
|
+
{/if}
|
|
134
114
|
</ul>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
{/if}
|
|
141
|
-
</ul>
|
|
115
|
+
{/if}
|
|
116
|
+
</div>
|
|
117
|
+
{:else}
|
|
118
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/login">Login</a>
|
|
119
|
+
{/if}
|
|
142
120
|
</div>
|
|
143
121
|
</div>
|
|
144
122
|
</nav>
|
|
145
123
|
|
|
146
|
-
<main class="
|
|
124
|
+
<main class="tw:mx-auto tw:max-w-5xl tw:px-4 tw:py-6">
|
|
147
125
|
{@render children?.()}
|
|
126
|
+
</main>
|
|
148
127
|
|
|
128
|
+
<!-- Toast notification -->
|
|
129
|
+
{#if appState.toast.isOpen}
|
|
149
130
|
<div
|
|
150
|
-
id="authToast"
|
|
151
|
-
class="toast position-fixed top-0 end-0 m-3"
|
|
152
131
|
role="alert"
|
|
153
132
|
aria-live="assertive"
|
|
154
133
|
aria-atomic="true"
|
|
134
|
+
class="tw:fixed tw:top-4 tw:right-4 tw:z-50 tw:min-w-64 tw:rounded tw:shadow-lg tw:border tw:border-gray-200 tw:bg-white tw:overflow-hidden"
|
|
155
135
|
>
|
|
156
|
-
<div class="
|
|
157
|
-
<strong class="
|
|
158
|
-
<button
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
136
|
+
<div class="tw:flex tw:items-center tw:justify-between tw:bg-blue-600 tw:px-4 tw:py-2">
|
|
137
|
+
<strong class="tw:text-white tw:text-sm">{appState.toast.title}</strong>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
aria-label="Close"
|
|
141
|
+
class="tw:text-white tw:bg-transparent tw:border-0 tw:cursor-pointer tw:text-lg tw:leading-none"
|
|
142
|
+
onclick={() => (appState.toast = { ...appState.toast, isOpen: false })}>×</button>
|
|
162
143
|
</div>
|
|
144
|
+
<div class="tw:px-4 tw:py-3 tw:text-sm">{appState.toast.body}</div>
|
|
163
145
|
</div>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
<style global>
|
|
167
|
-
* {
|
|
168
|
-
-webkit-font-smoothing: antialiased;
|
|
169
|
-
-moz-osx-font-smoothing: grayscale;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
.toast {
|
|
173
|
-
z-index: 9999;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
.avatar {
|
|
177
|
-
position: relative;
|
|
178
|
-
top: -1.5px;
|
|
179
|
-
}
|
|
180
|
-
</style>
|
|
146
|
+
{/if}
|
|
@@ -18,3 +18,19 @@ export const PUT: RequestHandler = async event => {
|
|
|
18
18
|
message: 'Successfully updated user profile.'
|
|
19
19
|
})
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
export const DELETE: RequestHandler = async event => {
|
|
23
|
+
const { user } = event.locals
|
|
24
|
+
|
|
25
|
+
if (!user) error(401, 'Unauthorized')
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
// Deleting the user cascades to sessions via ON DELETE CASCADE
|
|
29
|
+
await query(`CALL delete_user($1);`, [user.id])
|
|
30
|
+
} catch {
|
|
31
|
+
error(503, 'Could not communicate with database.')
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
event.cookies.delete('session', { path: '/' })
|
|
35
|
+
return json({ message: 'Account successfully deleted.' })
|
|
36
|
+
}
|
|
@@ -1,59 +1,8 @@
|
|
|
1
|
-
import { error
|
|
1
|
+
import { error } from '@sveltejs/kit'
|
|
2
2
|
import type { RequestHandler } from './$types'
|
|
3
|
-
import { query } from '$lib/server/db'
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
let result
|
|
10
|
-
let sql
|
|
11
|
-
|
|
12
|
-
try {
|
|
13
|
-
switch (slug) {
|
|
14
|
-
case 'logout':
|
|
15
|
-
if (event.locals.user) {
|
|
16
|
-
// else they are logged out / session ended
|
|
17
|
-
sql = `CALL delete_session($1);`
|
|
18
|
-
result = await query(sql, [event.locals.user.id])
|
|
19
|
-
}
|
|
20
|
-
cookies.delete('session', { path: '/' })
|
|
21
|
-
return json({ message: 'Logout successful.' })
|
|
22
|
-
|
|
23
|
-
case 'login':
|
|
24
|
-
sql = `SELECT authenticate($1) AS "authenticationResult";`
|
|
25
|
-
break
|
|
26
|
-
case 'register':
|
|
27
|
-
sql = `SELECT register($1) AS "authenticationResult";`
|
|
28
|
-
break
|
|
29
|
-
default:
|
|
30
|
-
error(404, 'Invalid endpoint.')
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// Only /auth/login and /auth/register at this point
|
|
34
|
-
const body = await event.request.json()
|
|
35
|
-
|
|
36
|
-
// While client checks for these to be non-null, register() in the database does not
|
|
37
|
-
if (slug == 'register' && (!body.email || !body.password || !body.firstName || !body.lastName))
|
|
38
|
-
error(400, 'Please supply all required fields: email, password, first and last name.')
|
|
39
|
-
|
|
40
|
-
result = await query(sql, [JSON.stringify(body)])
|
|
41
|
-
} catch (err) {
|
|
42
|
-
error(503, 'Could not communicate with database.')
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const { authenticationResult }: { authenticationResult: AuthenticationResult } = result.rows[0]
|
|
46
|
-
|
|
47
|
-
if (!authenticationResult.user)
|
|
48
|
-
// includes when a user tries to register an existing email account with wrong password
|
|
49
|
-
error(authenticationResult.statusCode, authenticationResult.status)
|
|
50
|
-
|
|
51
|
-
// Ensures hooks.server.ts:handle() will not delete session cookie
|
|
52
|
-
event.locals.user = authenticationResult.user
|
|
53
|
-
cookies.set('session', authenticationResult.sessionId, {
|
|
54
|
-
httpOnly: true,
|
|
55
|
-
sameSite: 'lax',
|
|
56
|
-
path: '/'
|
|
57
|
-
})
|
|
58
|
-
return json({ message: authenticationResult.status, user: authenticationResult.user })
|
|
4
|
+
// Specific auth routes (login, register, logout) have their own +server.ts files
|
|
5
|
+
// and take precedence over this dynamic segment. Any unrecognized path falls here.
|
|
6
|
+
export const POST: RequestHandler = async () => {
|
|
7
|
+
error(404, 'Invalid endpoint.')
|
|
59
8
|
}
|
|
@@ -30,7 +30,12 @@ export const POST: RequestHandler = async event => {
|
|
|
30
30
|
new password with a confirmation then redirect you to your login page.
|
|
31
31
|
`
|
|
32
32
|
}
|
|
33
|
-
|
|
33
|
+
try {
|
|
34
|
+
await sendMessage(message)
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error('Failed to send password reset email:', err)
|
|
37
|
+
// Still return 204 to avoid leaking whether the email exists in our system
|
|
38
|
+
}
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
return new Response(undefined, { status: 204 })
|
|
@@ -52,7 +52,7 @@ export const POST: RequestHandler = async event => {
|
|
|
52
52
|
// Prevent hooks.server.ts's handler() from deleting cookie thinking no one has authenticated
|
|
53
53
|
event.locals.user = userSession.user
|
|
54
54
|
|
|
55
|
-
cookies.set('session', userSession.id, { httpOnly: true, sameSite: 'lax', path: '/' })
|
|
55
|
+
cookies.set('session', userSession.id, { httpOnly: true, sameSite: 'lax', secure: true, path: '/' })
|
|
56
56
|
return json({ message: 'Successful Google Sign-In.', user: userSession.user })
|
|
57
57
|
} catch (err) {
|
|
58
58
|
let message = ''
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { error, json } from '@sveltejs/kit'
|
|
2
|
+
import type { RequestHandler } from './$types'
|
|
3
|
+
import { query } from '$lib/server/db'
|
|
4
|
+
|
|
5
|
+
// In-memory failed-attempt tracker for account lockout (per email)
|
|
6
|
+
// For production use a shared store like Redis.
|
|
7
|
+
const failedAttempts = new Map<string, { count: number; lockedUntil: number }>()
|
|
8
|
+
|
|
9
|
+
const MAX_FAILURES = 5
|
|
10
|
+
const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes
|
|
11
|
+
|
|
12
|
+
export const POST: RequestHandler = async event => {
|
|
13
|
+
const { cookies } = event
|
|
14
|
+
|
|
15
|
+
let body: { email?: string; password?: string }
|
|
16
|
+
try {
|
|
17
|
+
body = await event.request.json()
|
|
18
|
+
} catch {
|
|
19
|
+
error(400, 'Invalid request body.')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const email = body.email?.toLowerCase() ?? ''
|
|
23
|
+
|
|
24
|
+
// Check per-email account lockout
|
|
25
|
+
const attempts = failedAttempts.get(email)
|
|
26
|
+
if (attempts && Date.now() < attempts.lockedUntil) {
|
|
27
|
+
error(429, 'Account temporarily locked due to too many failed attempts. Try again later.')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let result
|
|
31
|
+
try {
|
|
32
|
+
const sql = `SELECT authenticate($1) AS "authenticationResult";`
|
|
33
|
+
result = await query(sql, [JSON.stringify(body)])
|
|
34
|
+
} catch {
|
|
35
|
+
error(503, 'Could not communicate with database.')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { authenticationResult }: { authenticationResult: AuthenticationResult } = result.rows[0]
|
|
39
|
+
|
|
40
|
+
if (!authenticationResult.user) {
|
|
41
|
+
// Track failed attempt for lockout
|
|
42
|
+
if (email) {
|
|
43
|
+
const existing = failedAttempts.get(email)
|
|
44
|
+
if (existing) {
|
|
45
|
+
existing.count++
|
|
46
|
+
if (existing.count >= MAX_FAILURES) {
|
|
47
|
+
existing.lockedUntil = Date.now() + LOCKOUT_MS
|
|
48
|
+
existing.count = 0
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
failedAttempts.set(email, { count: 1, lockedUntil: 0 })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
error(authenticationResult.statusCode, authenticationResult.status)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Clear lockout tracker on successful login
|
|
58
|
+
failedAttempts.delete(email)
|
|
59
|
+
|
|
60
|
+
event.locals.user = authenticationResult.user
|
|
61
|
+
cookies.set('session', authenticationResult.sessionId, {
|
|
62
|
+
httpOnly: true,
|
|
63
|
+
sameSite: 'lax',
|
|
64
|
+
secure: true,
|
|
65
|
+
path: '/'
|
|
66
|
+
})
|
|
67
|
+
return json({ message: authenticationResult.status, user: authenticationResult.user })
|
|
68
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { json } from '@sveltejs/kit'
|
|
2
|
+
import type { RequestHandler } from './$types'
|
|
3
|
+
import { query } from '$lib/server/db'
|
|
4
|
+
|
|
5
|
+
export const POST: RequestHandler = async event => {
|
|
6
|
+
const { cookies } = event
|
|
7
|
+
|
|
8
|
+
if (event.locals.user) {
|
|
9
|
+
try {
|
|
10
|
+
await query(`CALL delete_session($1);`, [event.locals.user.id])
|
|
11
|
+
} catch (err) {
|
|
12
|
+
console.error('Failed to delete session from database:', err)
|
|
13
|
+
// Best effort — still clear the cookie below
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
cookies.delete('session', { path: '/' })
|
|
18
|
+
return json({ message: 'Logout successful.' })
|
|
19
|
+
}
|