sveltekit-auth-example 5.1.0 → 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/db_create.sql CHANGED
@@ -76,6 +76,7 @@ CREATE TABLE IF NOT EXISTS public.users (
76
76
  first_name persons_name,
77
77
  last_name persons_name,
78
78
  opt_out boolean NOT NULL DEFAULT false,
79
+ email_verified boolean NOT NULL DEFAULT false,
79
80
  phone phone_number,
80
81
  CONSTRAINT users_pkey PRIMARY KEY (id),
81
82
  CONSTRAINT users_email_unique UNIQUE (email)
@@ -123,37 +124,28 @@ AS $BODY$
123
124
  DECLARE
124
125
  input_email text := trim(input->>'email');
125
126
  input_password text := input->>'password';
127
+ v_user users%ROWTYPE;
126
128
  BEGIN
127
129
  IF input_email IS NULL OR input_password IS NULL THEN
128
- response := json_build_object('statusCode', 400, 'status', 'Please provide an email address and password to authenticate.', 'user', NULL);
129
- RETURN;
130
+ response := json_build_object('statusCode', 400, 'status', 'Please provide an email address and password to authenticate.', 'user', NULL, 'sessionId', NULL);
131
+ RETURN;
130
132
  END IF;
131
133
 
132
- WITH user_authenticated AS (
133
- SELECT users.id, role, first_name, last_name, phone, opt_out
134
- FROM users
135
- WHERE email = input_email AND password = crypt(input_password, password) LIMIT 1
136
- )
137
- SELECT json_build_object(
138
- 'statusCode', CASE WHEN EXISTS (SELECT 1 FROM user_authenticated) THEN 200 ELSE 401 END,
139
- 'status', CASE WHEN EXISTS (SELECT 1 FROM user_authenticated)
140
- THEN 'Login successful.'
141
- ELSE 'Invalid username/password combination.'
142
- END,
143
- 'user', CASE WHEN EXISTS (SELECT 1 FROM user_authenticated)
144
- THEN (SELECT json_build_object(
145
- 'id', user_authenticated.id,
146
- 'role', user_authenticated.role,
147
- 'email', input_email,
148
- 'firstName', user_authenticated.first_name,
149
- 'lastName', user_authenticated.last_name,
150
- 'phone', user_authenticated.phone,
151
- 'optOut', user_authenticated.opt_out)
152
- FROM user_authenticated)
153
- ELSE NULL
154
- END,
155
- 'sessionId', (SELECT create_session(user_authenticated.id) FROM user_authenticated)
156
- ) INTO response;
134
+ SELECT * INTO v_user FROM users
135
+ WHERE email = input_email AND password = crypt(input_password, password) LIMIT 1;
136
+
137
+ IF NOT FOUND THEN
138
+ response := json_build_object('statusCode', 401, 'status', 'Invalid username/password combination.', 'user', NULL, 'sessionId', NULL);
139
+ ELSIF NOT v_user.email_verified THEN
140
+ response := json_build_object('statusCode', 403, 'status', 'Please verify your email address before logging in.', 'user', NULL, 'sessionId', NULL);
141
+ ELSE
142
+ response := json_build_object(
143
+ 'statusCode', 200,
144
+ 'status', 'Login successful.',
145
+ 'user', json_build_object('id', v_user.id, 'role', v_user.role, 'email', input_email, 'firstName', v_user.first_name, 'lastName', v_user.last_name, 'phone', v_user.phone, 'optOut', v_user.opt_out),
146
+ 'sessionId', create_session(v_user.id)
147
+ );
148
+ END IF;
157
149
  END;
158
150
  $BODY$;
159
151
 
@@ -197,6 +189,23 @@ $BODY$;
197
189
 
198
190
  ALTER FUNCTION public.get_session(uuid) OWNER TO auth;
199
191
 
192
+ CREATE OR REPLACE FUNCTION public.verify_email_and_create_session(input_id integer)
193
+ RETURNS uuid
194
+ LANGUAGE 'plpgsql'
195
+ COST 100
196
+ VOLATILE PARALLEL UNSAFE
197
+ AS $BODY$
198
+ DECLARE
199
+ session_id uuid;
200
+ BEGIN
201
+ UPDATE users SET email_verified = true WHERE id = input_id;
202
+ SELECT create_session(input_id) INTO session_id;
203
+ RETURN session_id;
204
+ END;
205
+ $BODY$;
206
+
207
+ ALTER FUNCTION public.verify_email_and_create_session(integer) OWNER TO auth;
208
+
200
209
  CREATE OR REPLACE FUNCTION public.register(
201
210
  input json,
202
211
  OUT user_session json)
@@ -242,10 +251,12 @@ DECLARE
242
251
  input_first_name varchar(20) := TRIM((input->>'firstName')::varchar);
243
252
  input_last_name varchar(20) := TRIM((input->>'lastName')::varchar);
244
253
  BEGIN
254
+ -- Google verifies email ownership; mark user as verified on every sign-in
255
+ UPDATE users SET email_verified = true WHERE email = input_email;
245
256
  SELECT json_build_object('id', create_session(users.id), 'user', json_build_object('id', users.id, 'role', users.role, 'email', input_email, 'firstName', users.first_name, 'lastName', users.last_name, 'phone', users.phone)) INTO user_session FROM users WHERE email = input_email;
246
257
  IF NOT FOUND THEN
247
- INSERT INTO users(role, email, first_name, last_name)
248
- VALUES('student', input_email, input_first_name, input_last_name)
258
+ INSERT INTO users(role, email, first_name, last_name, email_verified)
259
+ VALUES('student', input_email, input_first_name, input_last_name, true)
249
260
  RETURNING
250
261
  json_build_object(
251
262
  'id', create_session(users.id),
@@ -263,6 +274,14 @@ CREATE PROCEDURE public.delete_session(input_id integer)
263
274
  DELETE FROM sessions WHERE user_id = input_id;
264
275
  $$;
265
276
 
277
+ CREATE OR REPLACE PROCEDURE public.delete_user(input_id integer)
278
+ LANGUAGE sql
279
+ AS $$
280
+ DELETE FROM users WHERE id = input_id;
281
+ $$;
282
+
283
+ ALTER PROCEDURE public.delete_user(integer) OWNER TO auth;
284
+
266
285
  CREATE OR REPLACE PROCEDURE public.reset_password(IN input_id integer, IN input_password text)
267
286
  LANGUAGE plpgsql
268
287
  AS $procedure$
@@ -287,14 +306,15 @@ DECLARE
287
306
  input_phone varchar(23) := TRIM((input->>'phone')::varchar);
288
307
  BEGIN
289
308
  IF input_id = 0 THEN
290
- INSERT INTO users (role, email, password, first_name, last_name, phone)
309
+ INSERT INTO users (role, email, password, first_name, last_name, phone, email_verified)
291
310
  VALUES (
292
311
  input_role, input_email, crypt(input_password, gen_salt('bf', 8)),
293
- input_first_name, input_last_name, input_phone);
312
+ input_first_name, input_last_name, input_phone, true);
294
313
  ELSE
295
314
  UPDATE users SET
296
315
  role = input_role,
297
316
  email = input_email,
317
+ email_verified = true,
298
318
  password = CASE WHEN input_password = ''
299
319
  THEN password -- leave as is (we are updating fields other than the password)
300
320
  ELSE crypt(input_password, gen_salt('bf', 8))
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sveltekit-auth-example",
3
3
  "description": "SvelteKit Authentication Example",
4
- "version": "5.1.0",
4
+ "version": "5.1.1",
5
5
  "author": "Nate Stuyvesant",
6
6
  "license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
7
7
  "repository": {
package/src/app.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // and what to do when importing types
6
6
  declare namespace App {
7
7
  interface Locals {
8
- user: User
8
+ user: User | undefined
9
9
  }
10
10
 
11
11
  // interface Platform {}
@@ -1,16 +1,42 @@
1
1
  import type { Handle, RequestEvent } from '@sveltejs/kit'
2
+ import { error } from '@sveltejs/kit'
2
3
  import { query } from '$lib/server/db'
3
4
 
5
+ // In-memory IP-based rate limiter for sensitive auth endpoints.
6
+ // For multi-instance deployments, replace with a shared store like Redis.
7
+ const ipRateLimit = new Map<string, { count: number; resetAt: number }>()
8
+ const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000 // 15 minutes
9
+ const RATE_LIMIT_MAX_REQUESTS = 20
10
+ const RATE_LIMITED_PATHS = new Set(['/auth/login', '/auth/register', '/auth/forgot'])
11
+
12
+ function checkRateLimit(ip: string): boolean {
13
+ const now = Date.now()
14
+ const entry = ipRateLimit.get(ip)
15
+ if (!entry || now > entry.resetAt) {
16
+ ipRateLimit.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS })
17
+ return true
18
+ }
19
+ if (entry.count >= RATE_LIMIT_MAX_REQUESTS) return false
20
+ entry.count++
21
+ return true
22
+ }
23
+
24
+ // Periodically clean up expired entries to prevent unbounded memory growth
25
+ setInterval(() => {
26
+ const now = Date.now()
27
+ for (const [key, value] of ipRateLimit) {
28
+ if (now > value.resetAt) ipRateLimit.delete(key)
29
+ }
30
+ }, 60 * 60 * 1000)
31
+
4
32
  // Attach authorization to each server request (role may have changed)
5
33
  async function attachUserToRequestEvent(sessionId: string, event: RequestEvent) {
6
- const { rows } = await query('SELECT * FROM get_session($1::uuid)', [sessionId], 'get-session')
7
- if (rows?.length > 0) {
8
- event.locals.user = <User>rows[0].get_session
9
- }
34
+ const result = await query('SELECT * FROM get_session($1::uuid)', [sessionId], 'get-session')
35
+ event.locals.user = result.rows[0]?.get_session // undefined if not found
10
36
  }
11
37
 
12
38
  // Invoked for each endpoint called and initially for SSR router
13
- export const handle: Handle = async ({ event, resolve }) => {
39
+ export const handle = (async ({ event, resolve }) => {
14
40
  const { cookies, url } = event
15
41
 
16
42
  // Skip auth overhead for static asset requests
@@ -18,6 +44,14 @@ export const handle: Handle = async ({ event, resolve }) => {
18
44
  return resolve(event)
19
45
  }
20
46
 
47
+ // Rate limit sensitive auth endpoints by IP
48
+ if (RATE_LIMITED_PATHS.has(url.pathname)) {
49
+ const ip = event.getClientAddress()
50
+ if (!checkRateLimit(ip)) {
51
+ error(429, 'Too many requests. Please try again later.')
52
+ }
53
+ }
54
+
21
55
  // before endpoint or page is called
22
56
  const sessionId = cookies.get('session')
23
57
  if (sessionId) {
@@ -31,4 +65,4 @@ export const handle: Handle = async ({ event, resolve }) => {
31
65
  // after endpoint or page is called
32
66
 
33
67
  return response
34
- }
68
+ }) satisfies Handle
@@ -0,0 +1,19 @@
1
+ interface Toast {
2
+ title: string
3
+ body: string
4
+ isOpen: boolean
5
+ }
6
+
7
+ class AppState {
8
+ /** Currently logged-in user, undefined when not authenticated */
9
+ user = $state<User | undefined>(undefined)
10
+
11
+ /** Toast notification displayed at the top-right of the screen */
12
+ toast = $state<Toast>({ title: '', body: '', isOpen: false })
13
+
14
+ /** Whether the Google Identity Services SDK has been initialized */
15
+ googleInitialized = $state(false)
16
+ }
17
+
18
+ /** Singleton application state — import this throughout the app */
19
+ export const appState = new AppState()
@@ -0,0 +1,25 @@
1
+ import { goto } from '$app/navigation'
2
+ import { page } from '$app/state'
3
+
4
+ /**
5
+ * Redirect the user to the appropriate page after a successful login,
6
+ * respecting an optional ?referrer= query parameter.
7
+ */
8
+ export function redirectAfterLogin(user: User): void {
9
+ if (!user) return
10
+ const referrer = page.url.searchParams.get('referrer')
11
+ if (referrer) {
12
+ goto(referrer)
13
+ return
14
+ }
15
+ switch (user.role) {
16
+ case 'teacher':
17
+ goto('/teachers')
18
+ break
19
+ case 'admin':
20
+ goto('/admin')
21
+ break
22
+ default:
23
+ goto('/')
24
+ }
25
+ }
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
  }
@@ -64,5 +64,6 @@ queryFn = <T extends QueryResultRow>(
64
64
  */
65
65
  export const query = <T extends QueryResultRow = any>(
66
66
  sql: string,
67
- params?: (string | number | boolean | object | null)[]
68
- ): Promise<QueryResult<T>> => queryFn<T>(sql, params)
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
  }
@@ -2,7 +2,7 @@
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
8
  import './layout.css'
@@ -15,37 +15,46 @@
15
15
  let { data, children }: Props = $props()
16
16
 
17
17
  $effect(() => {
18
- $loginSession = data.user
18
+ appState.user = data.user
19
19
  })
20
20
 
21
21
  let navOpen = $state(false)
22
22
  let dropdownOpen = $state(false)
23
+ let dropdownEl: HTMLDivElement | undefined = $state()
24
+
25
+ function handleWindowClick(e: MouseEvent) {
26
+ if (dropdownOpen && dropdownEl && !dropdownEl.contains(e.target as Node)) {
27
+ dropdownOpen = false
28
+ }
29
+ }
23
30
 
24
31
  beforeNavigate(() => {
25
32
  navOpen = false
26
33
  dropdownOpen = false
27
- const expirationDate = $loginSession?.expires ? new Date($loginSession.expires) : undefined
34
+ const expirationDate = appState.user?.expires ? new Date(appState.user.expires) : undefined
28
35
  if (expirationDate && expirationDate < new Date()) {
29
36
  console.log('Login session expired.')
30
- $loginSession = null
37
+ appState.user = undefined
31
38
  }
32
39
  })
33
40
 
34
41
  onMount(() => {
35
42
  initializeGoogleAccounts()
36
- if (!$loginSession) google.accounts.id.prompt()
43
+ if (!appState.user) google.accounts.id.prompt()
37
44
  })
38
45
 
39
46
  async function logout(event: MouseEvent) {
40
47
  event.preventDefault()
41
48
  const res = await fetch('/auth/logout', { method: 'POST' })
42
49
  if (res.ok) {
43
- loginSession.set(undefined)
50
+ appState.user = undefined
44
51
  goto('/login')
45
52
  } else console.error(`Logout not successful: ${res.statusText} (${res.status})`)
46
53
  }
47
54
  </script>
48
55
 
56
+ <svelte:window onclick={handleWindowClick} />
57
+
49
58
  <nav class="tw:bg-gray-100 tw:border-b tw:border-gray-200">
50
59
  <div class="tw:mx-auto tw:max-w-5xl tw:px-4 tw:flex tw:items-center tw:justify-between tw:h-14">
51
60
  <a class="tw:font-semibold tw:text-gray-800 tw:no-underline" href="/">SvelteKit-Auth-Example</a>
@@ -66,35 +75,41 @@
66
75
  <a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/">Home</a>
67
76
  <a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/info">Info</a>
68
77
 
69
- {#if $loginSession?.role === 'admin'}
78
+ {#if appState.user?.role === 'admin'}
70
79
  <a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/admin">Admin</a>
71
80
  {/if}
72
- {#if $loginSession && $loginSession.role !== 'student'}
81
+ {#if appState.user && appState.user.role !== 'student'}
73
82
  <a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/teachers">Teachers</a>
74
83
  {/if}
75
84
 
76
- {#if $loginSession}
85
+ {#if appState.user}
77
86
  <!-- User dropdown -->
78
- <div class="tw:relative">
87
+ <div class="tw:relative" bind:this={dropdownEl}>
79
88
  <button
80
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"
81
90
  onclick={() => (dropdownOpen = !dropdownOpen)}
82
91
  aria-expanded={dropdownOpen}
92
+ aria-haspopup="true"
83
93
  >
84
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]">
85
95
  <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
86
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" />
87
97
  </svg>
88
- {$loginSession.firstName}
98
+ {appState.user?.firstName}
89
99
  <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
90
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"/>
91
101
  </svg>
92
102
  </button>
93
103
  {#if dropdownOpen}
94
- <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 tw:p-0">
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">
95
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>
96
- {#if $loginSession.id !== 0}
97
- <li><a onclick={logout} 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="#">Logout</a></li>
106
+ {#if appState.user?.id !== 0}
107
+ <li>
108
+ <button
109
+ onclick={logout}
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>
112
+ </li>
98
113
  {/if}
99
114
  </ul>
100
115
  {/if}
@@ -111,7 +126,7 @@
111
126
  </main>
112
127
 
113
128
  <!-- Toast notification -->
114
- {#if $toast.isOpen}
129
+ {#if appState.toast.isOpen}
115
130
  <div
116
131
  role="alert"
117
132
  aria-live="assertive"
@@ -119,13 +134,13 @@
119
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"
120
135
  >
121
136
  <div class="tw:flex tw:items-center tw:justify-between tw:bg-blue-600 tw:px-4 tw:py-2">
122
- <strong class="tw:text-white tw:text-sm">{$toast.title}</strong>
137
+ <strong class="tw:text-white tw:text-sm">{appState.toast.title}</strong>
123
138
  <button
124
139
  type="button"
125
140
  aria-label="Close"
126
141
  class="tw:text-white tw:bg-transparent tw:border-0 tw:cursor-pointer tw:text-lg tw:leading-none"
127
- onclick={() => ($toast = { ...$toast, isOpen: false })}>&times;</button>
142
+ onclick={() => (appState.toast = { ...appState.toast, isOpen: false })}>&times;</button>
128
143
  </div>
129
- <div class="tw:px-4 tw:py-3 tw:text-sm">{$toast.body}</div>
144
+ <div class="tw:px-4 tw:py-3 tw:text-sm">{appState.toast.body}</div>
130
145
  </div>
131
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 = ''