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.
Files changed (47) hide show
  1. package/.editorconfig +9 -3
  2. package/{.env.sample → .env.example} +1 -0
  3. package/.prettierignore +1 -1
  4. package/.vscode/mcp.json +13 -0
  5. package/.vscode/settings.json +7 -5
  6. package/.yarn/releases/yarn-4.13.0.cjs +940 -0
  7. package/.yarnrc.yml +1 -1
  8. package/AGENTS.md +23 -0
  9. package/CHANGELOG.md +8 -0
  10. package/README.md +2 -3
  11. package/db_create.sql +98 -49
  12. package/{eslint.config.js → eslint.config.mjs} +4 -3
  13. package/package.json +34 -32
  14. package/playwright.config.ts +24 -0
  15. package/prettier.config.mjs +14 -5
  16. package/src/app.d.ts +1 -1
  17. package/src/app.html +1 -1
  18. package/src/hooks.server.ts +47 -9
  19. package/src/lib/app-state.svelte.ts +19 -0
  20. package/src/lib/auth-redirect.ts +25 -0
  21. package/src/lib/google.ts +7 -26
  22. package/src/lib/server/db.ts +63 -10
  23. package/src/lib/server/sendgrid.ts +5 -9
  24. package/src/routes/+error.svelte +3 -3
  25. package/src/routes/+layout.svelte +91 -125
  26. package/src/routes/api/v1/user/+server.ts +16 -0
  27. package/src/routes/auth/[slug]/+server.ts +5 -56
  28. package/src/routes/auth/forgot/+server.ts +6 -1
  29. package/src/routes/auth/google/+server.ts +1 -1
  30. package/src/routes/auth/login/+server.ts +68 -0
  31. package/src/routes/auth/logout/+server.ts +19 -0
  32. package/src/routes/auth/register/+server.ts +64 -0
  33. package/src/routes/auth/reset/+server.ts +7 -0
  34. package/src/routes/auth/reset/[token]/+page.svelte +102 -84
  35. package/src/routes/auth/verify/[token]/+server.ts +48 -0
  36. package/src/routes/forgot/+page.svelte +64 -54
  37. package/src/routes/layout.css +63 -0
  38. package/src/routes/login/+page.server.ts +9 -0
  39. package/src/routes/login/+page.svelte +73 -115
  40. package/src/routes/profile/+page.svelte +174 -123
  41. package/src/routes/register/+page.svelte +147 -125
  42. package/src/service-worker.ts +22 -4
  43. package/svelte.config.js +13 -1
  44. package/tsconfig.json +3 -1
  45. package/vite.config.ts +5 -1
  46. package/.yarn/releases/yarn-4.9.2.cjs +0 -942
  47. 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 { googleInitialized, loginSession } from '../stores'
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: 367
12
+ width: btn.offsetWidth || 400
14
13
  })
15
14
  }
16
15
  }
17
16
 
18
17
  export function initializeGoogleAccounts() {
19
- let initialized = false
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
- loginSession.set(fromEndpoint.user) // update loginSession store
46
- const { role } = fromEndpoint.user
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
  }
@@ -1,16 +1,69 @@
1
- /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import type { QueryResult } from 'pg'
1
+ import type { QueryResult, QueryResultRow } from 'pg'
3
2
  import pg from 'pg'
4
- import { DATABASE_URL } from '$env/static/private'
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
- connectionString: DATABASE_URL,
9
- ssl: {
10
- // If your postgresql.conf does not have `ssl = on`, remove the entire ssl property or you will get an error
11
- rejectUnauthorized: false
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
- type PostgresQueryResult = (sql: string, params?: any[]) => Promise<QueryResult<any>>
16
- export const query: PostgresQueryResult = (sql, params?) => pool.query(sql, params)
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
- try {
8
- sgMail.setApiKey(SENDGRID_KEY)
9
- const completeMessage = <MailDataRequired>{
10
- from: SENDGRID_SENDER, // default sender can be altered
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
  }
@@ -1,6 +1,6 @@
1
1
  <script lang="ts" module>
2
- import { page } from '$app/stores'
2
+ import { page } from '$app/state'
3
3
  </script>
4
4
 
5
- <h1>{$page.status}</h1>
6
- <h4>{$page.error?.message}</h4>
5
+ <h1>{page.status}</h1>
6
+ <h4>{page.error?.message}</h4>
@@ -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 { loginSession, toast } from '../stores'
5
+ import { appState } from '$lib/app-state.svelte'
6
6
  import { initializeGoogleAccounts } from '$lib/google'
7
7
 
8
- import 'bootstrap/scss/bootstrap.scss' // preferred way to load Bootstrap SCSS for hot module reloading
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
- // If returning from different website, runs once (as it's an SPA) to restore user session if session cookie is still valid
18
- const { user } = data
19
- $loginSession = user
17
+ $effect(() => {
18
+ appState.user = data.user
19
+ })
20
20
 
21
- let Toast: any
21
+ let navOpen = $state(false)
22
+ let dropdownOpen = $state(false)
23
+ let dropdownEl: HTMLDivElement | undefined = $state()
22
24
 
23
- beforeNavigate(() => {
24
- let expirationDate = $loginSession?.expires ? new Date($loginSession.expires) : undefined
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
- $loginSession = null
37
+ appState.user = undefined
29
38
  }
30
39
  })
31
40
 
32
- onMount(async () => {
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
- // Request server delete httpOnly cookie called loginSession
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
- loginSession.set(undefined) // delete loginSession.user from
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
- const openToast = (open: boolean) => {
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
- $effect(() => {
64
- openToast($toast.isOpen)
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
- <nav class="navbar navbar-expand-lg navbar-light bg-light">
69
- <div class="container">
70
- <a class="navbar-brand" href="/">SvelteKit-Auth-Example</a>
62
+ <!-- Mobile toggle -->
71
63
  <button
72
- class="navbar-toggler"
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
- <span class="navbar-toggler-icon"></span>
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
- <div class="collapse navbar-collapse" id="navbarMain">
83
- <ul class="navbar-nav me-5">
84
- <li class="nav-item"><a class="nav-link active" aria-current="page" href="/">Home</a></li>
85
- <li class="nav-item"><a class="nav-link" href="/info">Info</a></li>
86
-
87
- {#if $loginSession}
88
- {#if $loginSession.role == 'admin'}
89
- <li class="nav-item"><a class="nav-link" href="/admin">Admin</a></li>
90
- {/if}
91
- {#if $loginSession.role != 'student'}
92
- <li class="nav-item"><a class="nav-link" href="/teachers">Teachers</a></li>
93
- {/if}
94
- {/if}
95
- </ul>
96
- <ul class="navbar-nav">
97
- {#if $loginSession}
98
- <li class="nav-item dropdown">
99
- <a
100
- class="nav-link dropdown-toggle"
101
- href={'#'}
102
- role="button"
103
- data-bs-toggle="dropdown"
104
- aria-expanded="false"
105
- >
106
- <svg
107
- xmlns="http://www.w3.org/2000/svg"
108
- width="16"
109
- height="16"
110
- fill="currentColor"
111
- class="avatar"
112
- viewBox="0 0 16 16"
113
- >
114
- <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
115
- <path
116
- fill-rule="evenodd"
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
- <a
108
+ <button
128
109
  onclick={logout}
129
- class="dropdown-item"
130
- class:d-none={!$loginSession || $loginSession.id === 0}
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
- </li>
136
- {:else}
137
- <li class="nav-item">
138
- <a class="nav-link" href="/login">Login</a>
139
- </li>
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="container">
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="toast-header bg-primary text-white">
157
- <strong class="me-auto">{$toast.title}</strong>
158
- <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
159
- </div>
160
- <div class="toast-body">
161
- {$toast.body}
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 })}>&times;</button>
162
143
  </div>
144
+ <div class="tw:px-4 tw:py-3 tw:text-sm">{appState.toast.body}</div>
163
145
  </div>
164
- </main>
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, json } from '@sveltejs/kit'
1
+ import { error } from '@sveltejs/kit'
2
2
  import type { RequestHandler } from './$types'
3
- import { query } from '$lib/server/db'
4
3
 
5
- export const POST: RequestHandler = async event => {
6
- const { cookies } = event
7
- const { slug } = event.params
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
- sendMessage(message)
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
+ }