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
@@ -0,0 +1,64 @@
1
+ import { error, json } from '@sveltejs/kit'
2
+ import type { RequestHandler } from './$types'
3
+ import type { MailDataRequired } from '@sendgrid/mail'
4
+ import jwt from 'jsonwebtoken'
5
+ import { JWT_SECRET, DOMAIN, SENDGRID_SENDER } from '$env/static/private'
6
+ import { query } from '$lib/server/db'
7
+ import { sendMessage } from '$lib/server/sendgrid'
8
+
9
+ export const POST: RequestHandler = async event => {
10
+ let body: { email?: string; password?: string; firstName?: string; lastName?: string }
11
+ try {
12
+ body = await event.request.json()
13
+ } catch {
14
+ error(400, 'Invalid request body.')
15
+ }
16
+
17
+ if (!body.email || !body.password || !body.firstName || !body.lastName)
18
+ error(400, 'Please supply all required fields: email, password, first and last name.')
19
+
20
+ let result
21
+ try {
22
+ const sql = `SELECT register($1) AS "authenticationResult";`
23
+ result = await query(sql, [JSON.stringify(body)])
24
+ } catch {
25
+ error(503, 'Could not communicate with database.')
26
+ }
27
+
28
+ const { authenticationResult }: { authenticationResult: AuthenticationResult } = result.rows[0]
29
+
30
+ if (!authenticationResult.user)
31
+ error(authenticationResult.statusCode, authenticationResult.status)
32
+
33
+ // The DB auto-creates a session, but the user must verify email before logging in. Clean it up.
34
+ try {
35
+ await query(`CALL delete_session($1);`, [authenticationResult.sessionId])
36
+ } catch (err) {
37
+ console.error('Failed to delete pre-verification session:', err)
38
+ }
39
+
40
+ const token = jwt.sign(
41
+ { subject: authenticationResult.user.id, purpose: 'verify-email' },
42
+ JWT_SECRET as jwt.Secret,
43
+ { expiresIn: '24h' }
44
+ )
45
+
46
+ const message: MailDataRequired = {
47
+ to: { email: body.email },
48
+ from: SENDGRID_SENDER,
49
+ subject: 'Verify your email address',
50
+ categories: ['account'],
51
+ html: `
52
+ <p>Thanks for registering! Please verify your email address by clicking the link below:</p>
53
+ <p><a href="${DOMAIN}/auth/verify/${token}">Verify my email address</a></p>
54
+ <p>This link expires in 24 hours. If you did not register, you can safely ignore this email.</p>
55
+ `
56
+ }
57
+
58
+ await sendMessage(message)
59
+
60
+ return json({
61
+ message: 'Registration successful. Please check your email to verify your account.',
62
+ emailVerification: true
63
+ })
64
+ }
@@ -18,6 +18,13 @@ export const PUT: RequestHandler = async event => {
18
18
  const sql = `CALL reset_password($1, $2);`
19
19
  await query(sql, [userId, password])
20
20
 
21
+ // Invalidate all existing sessions so the old password can no longer be used
22
+ try {
23
+ await query(`CALL delete_session($1);`, [userId])
24
+ } catch (err) {
25
+ console.error('Failed to invalidate sessions after password reset:', err)
26
+ }
27
+
21
28
  return json({
22
29
  message: 'Password successfully reset.'
23
30
  })
@@ -2,7 +2,7 @@
2
2
  import type { PageData } from './$types'
3
3
  import { onMount } from 'svelte'
4
4
  import { goto } from '$app/navigation'
5
- import { toast } from '../../../../stores'
5
+ import { appState } from '$lib/app-state.svelte'
6
6
  import { focusOnFirstError } from '$lib/focus'
7
7
 
8
8
  interface Props {
@@ -12,11 +12,17 @@
12
12
  let { data }: Props = $props()
13
13
 
14
14
  let focusedField: HTMLInputElement | undefined = $state()
15
+ let formEl: HTMLFormElement | undefined = $state()
15
16
  let password = $state('')
16
17
  let confirmPassword: HTMLInputElement | undefined = $state()
17
18
  let message = $state('')
19
+ let submitted = $state(false)
20
+ let passwordMismatch = $state(false)
21
+ let loading = $state(false)
18
22
 
19
23
  onMount(() => {
24
+ // Remove the token from the URL to prevent it appearing in logs and Referer headers
25
+ history.replaceState('', document.title, '/auth/reset')
20
26
  focusedField?.focus()
21
27
  })
22
28
 
@@ -27,40 +33,48 @@
27
33
 
28
34
  const resetPassword = async () => {
29
35
  message = ''
30
- const form = document.getElementById('reset') as HTMLFormElement
36
+ submitted = false
37
+ passwordMismatch = false
38
+ const form = formEl!
31
39
 
32
40
  if (!passwordMatch()) {
33
- confirmPassword?.classList.add('is-invalid')
41
+ passwordMismatch = true
42
+ return
34
43
  }
35
44
 
36
45
  if (form.checkValidity()) {
37
- const url = `/auth/reset`
38
- const res = await fetch(url, {
39
- method: 'PUT',
40
- headers: {
41
- 'Content-Type': 'application/json'
42
- },
43
- body: JSON.stringify({
44
- token: data.token,
45
- password
46
+ loading = true
47
+ try {
48
+ const url = `/auth/reset`
49
+ const res = await fetch(url, {
50
+ method: 'PUT',
51
+ headers: {
52
+ 'Content-Type': 'application/json'
53
+ },
54
+ body: JSON.stringify({
55
+ token: data.token,
56
+ password
57
+ })
46
58
  })
47
- })
48
59
 
49
- if (res.ok) {
50
- $toast = {
51
- title: 'Password Reset Succesful',
52
- body: 'Your password was reset. Please login.',
53
- isOpen: true
54
- }
60
+ if (res.ok) {
61
+ appState.toast = {
62
+ title: 'Password Reset Successful',
63
+ body: 'Your password was reset. Please login.',
64
+ isOpen: true
65
+ }
55
66
 
56
- goto('/login')
57
- } else {
58
- const body = await res.json()
59
- console.log('Failed reset', body)
60
- message = body.message
67
+ goto('/login')
68
+ } else {
69
+ const body = await res.json()
70
+ console.log('Failed reset', body)
71
+ message = body.message
72
+ }
73
+ } finally {
74
+ loading = false
61
75
  }
62
76
  } else {
63
- form.classList.add('was-validated')
77
+ submitted = true
64
78
  focusOnFirstError(form)
65
79
  }
66
80
  }
@@ -70,62 +84,66 @@
70
84
  <title>New Password</title>
71
85
  </svelte:head>
72
86
 
73
- <div class="d-flex justify-content-center mt-5">
74
- <div class="card login">
75
- <div class="card-body">
76
- <form id="reset" autocomplete="on" novalidate>
77
- <h4><strong>New Password</strong></h4>
78
- <p>Please provide a new password.</p>
79
- <div class="mb-3">
80
- <label class="form-label" for="password">Password</label>
81
- <input
82
- class="form-control"
83
- id="password"
84
- type="password"
85
- bind:value={password}
86
- bind:this={focusedField}
87
- minlength="8"
88
- maxlength="80"
89
- placeholder="Password"
90
- autocomplete="new-password"
91
- />
92
- <div class="invalid-feedback">Password with 8 chars or more required</div>
93
- <div class="form-text">
94
- Password minimum length 8, must have one capital letter, 1 number, and one unique
95
- character.
96
- </div>
97
- </div>
98
- <div class="mb-3">
99
- <label class="form-label" for="passwordConfirm">Password (retype)</label>
100
- <input
101
- class="form-control"
102
- id="passwordConfirm"
103
- type="password"
104
- required={!!password}
105
- bind:this={confirmPassword}
106
- minlength="8"
107
- maxlength="80"
108
- placeholder="Password (again)"
109
- autocomplete="new-password"
110
- />
111
- <div class="invalid-feedback">Passwords must match</div>
112
- </div>
113
-
114
- {#if message}
115
- <p class="text-danger">{message}</p>
116
- {/if}
117
- <div class="d-grid gap-2">
118
- <button onclick={resetPassword} type="button" class="btn btn-primary btn-lg"
119
- >Send Email</button
120
- >
121
- </div>
122
- </form>
123
- </div>
124
- </div>
125
- </div>
126
-
127
- <style>
128
- .card-body {
129
- width: 25rem;
130
- }
131
- </style>
87
+ <form
88
+ bind:this={formEl}
89
+ autocomplete="on"
90
+ novalidate
91
+ class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
92
+ class:submitted
93
+ onsubmit={(e) => { e.preventDefault(); resetPassword() }}
94
+ >
95
+ <h4>New Password</h4>
96
+ <p>Please provide a new password.</p>
97
+
98
+ <label class="tw:block tw:text-sm tw:font-medium" for="password">
99
+ Password
100
+ <input
101
+ class="form-input-validated"
102
+ id="password"
103
+ type="password"
104
+ bind:value={password}
105
+ bind:this={focusedField}
106
+ required
107
+ minlength="8"
108
+ maxlength="80"
109
+ placeholder="Password"
110
+ autocomplete="new-password"
111
+ />
112
+ <span class="form-error">Password with 8 chars or more required</span>
113
+ <span class="tw:text-xs tw:text-gray-500">
114
+ Minimum 8 characters, one capital letter, one number, one special character.
115
+ </span>
116
+ </label>
117
+
118
+ <label class="tw:block tw:text-sm tw:font-medium" for="passwordConfirm">
119
+ Password (retype)
120
+ <input
121
+ class="form-input"
122
+ class:tw:border-red-500={passwordMismatch}
123
+ id="passwordConfirm"
124
+ type="password"
125
+ required={!!password}
126
+ bind:this={confirmPassword}
127
+ minlength="8"
128
+ maxlength="80"
129
+ placeholder="Password (again)"
130
+ autocomplete="new-password"
131
+ />
132
+ {#if passwordMismatch}
133
+ <span class="tw:text-xs tw:text-red-600 tw:mt-0.5">Passwords must match</span>
134
+ {/if}
135
+ </label>
136
+
137
+ {#if message}
138
+ <p class="tw:text-red-600">{message}</p>
139
+ {/if}
140
+
141
+ <button
142
+ type="submit"
143
+ class="btn-primary"
144
+ disabled={loading}
145
+ >
146
+ {loading ? 'Resetting...' : 'Reset Password'}
147
+ </button>
148
+ </form>
149
+
@@ -0,0 +1,48 @@
1
+ import { redirect } from '@sveltejs/kit'
2
+ import type { RequestHandler } from './$types'
3
+ import type { JwtPayload } from 'jsonwebtoken'
4
+ import jwt from 'jsonwebtoken'
5
+ import { JWT_SECRET } from '$env/static/private'
6
+ import { query } from '$lib/server/db'
7
+
8
+ // Handles email verification links sent after registration
9
+ export const GET: RequestHandler = async event => {
10
+ const { token } = event.params
11
+ const { cookies } = event
12
+
13
+ // Verify the JWT — extract userId only if token is valid and correct purpose
14
+ let userId: string | undefined
15
+ try {
16
+ const decoded = jwt.verify(token, JWT_SECRET as jwt.Secret) as JwtPayload
17
+ if (decoded.purpose === 'verify-email' && decoded.subject) {
18
+ userId = decoded.subject
19
+ }
20
+ } catch {
21
+ // Expired or tampered token — fall through to redirect below
22
+ }
23
+
24
+ if (!userId) redirect(302, '/login?error=invalid-token')
25
+
26
+ // Mark email as verified and create a session atomically
27
+ let sessionId: string | undefined
28
+ try {
29
+ const { rows } = await query<{ verify_email_and_create_session: string }>(
30
+ `SELECT verify_email_and_create_session($1)`,
31
+ [userId]
32
+ )
33
+ sessionId = rows[0]?.verify_email_and_create_session
34
+ } catch (err) {
35
+ console.error('Email verification DB error:', err)
36
+ }
37
+
38
+ if (!sessionId) redirect(302, '/login?error=verification-failed')
39
+
40
+ cookies.set('session', sessionId, {
41
+ httpOnly: true,
42
+ sameSite: 'lax',
43
+ secure: true,
44
+ path: '/'
45
+ })
46
+
47
+ redirect(302, '/')
48
+ }
@@ -1,12 +1,15 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte'
3
3
  import { goto } from '$app/navigation'
4
- import { toast } from '../../stores'
4
+ import { appState } from '$lib/app-state.svelte'
5
5
  import { focusOnFirstError } from '$lib/focus'
6
6
 
7
7
  let focusedField: HTMLInputElement | undefined = $state()
8
+ let formEl: HTMLFormElement | undefined = $state()
8
9
  let email: string = $state('')
9
10
  let message: string = $state('')
11
+ let submitted = $state(false)
12
+ let loading = $state(false)
10
13
 
11
14
  onMount(() => {
12
15
  focusedField?.focus()
@@ -14,31 +17,36 @@
14
17
 
15
18
  const sendPasswordReset = async () => {
16
19
  message = ''
17
- const form = document.getElementById('forgot') as HTMLFormElement
20
+ const form = formEl!
18
21
 
19
22
  if (form.checkValidity()) {
20
23
  if (email.toLowerCase().includes('gmail.com')) {
21
24
  return (message = 'Gmail passwords must be reset on Manage Your Google Account.')
22
25
  }
23
- const url = `/auth/forgot`
24
- const res = await fetch(url, {
25
- method: 'POST',
26
- headers: {
27
- 'Content-Type': 'application/json'
28
- },
29
- body: JSON.stringify({ email })
30
- })
26
+ loading = true
27
+ try {
28
+ const url = `/auth/forgot`
29
+ const res = await fetch(url, {
30
+ method: 'POST',
31
+ headers: {
32
+ 'Content-Type': 'application/json'
33
+ },
34
+ body: JSON.stringify({ email })
35
+ })
31
36
 
32
- if (res.ok) {
33
- $toast = {
34
- title: 'Password Reset',
35
- body: 'Please check your inbox for a password reset email (junk mail, too).',
36
- isOpen: true
37
+ if (res.ok) {
38
+ appState.toast = {
39
+ title: 'Password Reset',
40
+ body: 'Please check your inbox for a password reset email (junk mail, too).',
41
+ isOpen: true
42
+ }
43
+ return goto('/')
37
44
  }
38
- return goto('/')
45
+ } finally {
46
+ loading = false
39
47
  }
40
48
  } else {
41
- form.classList.add('was-validated')
49
+ submitted = true
42
50
  focusOnFirstError(form)
43
51
  }
44
52
  }
@@ -48,41 +56,43 @@
48
56
  <title>Forgot Password</title>
49
57
  </svelte:head>
50
58
 
51
- <div class="d-flex justify-content-center mt-5">
52
- <div class="card">
53
- <div class="card-body">
54
- <form id="forgot" autocomplete="on" novalidate>
55
- <h4><strong>Forgot password</strong></h4>
56
- <p>Hey, you're human. We get it.</p>
57
- <div class="mb-3">
58
- <label class="form-label" for="email">Email</label>
59
- <input
60
- bind:this={focusedField}
61
- bind:value={email}
62
- type="email"
63
- id="email"
64
- class="form-control"
65
- required
66
- placeholder="Email"
67
- autocomplete="email"
68
- />
69
- <div class="invalid-feedback">Email address required</div>
70
- </div>
71
- {#if message}
72
- <p class="text-danger">{message}</p>
73
- {/if}
74
- <div class="d-grid gap-2">
75
- <button onclick={sendPasswordReset} type="button" class="btn btn-primary btn-lg"
76
- >Send Email</button
77
- >
78
- </div>
79
- </form>
80
- </div>
81
- </div>
82
- </div>
59
+ <form
60
+ bind:this={formEl}
61
+ autocomplete="on"
62
+ novalidate
63
+ class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
64
+ class:submitted
65
+ onsubmit={(e) => { e.preventDefault(); sendPasswordReset() }}
66
+ >
67
+ <h4>Forgot password</h4>
68
+ <p>Hey, you're human. We get it.</p>
69
+
70
+ <label class="tw:block tw:text-sm tw:font-medium" for="email">
71
+ Email
72
+ <input
73
+ bind:this={focusedField}
74
+ bind:value={email}
75
+ type="email"
76
+ id="email"
77
+ class="form-input-validated"
78
+ required
79
+ placeholder="Email"
80
+ autocomplete="email"
81
+ />
82
+ <span class="form-error">Email address required</span>
83
+ </label>
84
+
85
+ {#if message}
86
+ <p class="tw:text-red-600">{message}</p>
87
+ {/if}
88
+
89
+ <button
90
+ type="submit"
91
+ class="btn-primary"
92
+ disabled={loading}
93
+ >
94
+ {loading ? 'Sending...' : 'Send Email'}
95
+ </button>
96
+ </form>
97
+
83
98
 
84
- <style>
85
- .card-body {
86
- width: 25rem;
87
- }
88
- </style>
@@ -0,0 +1,63 @@
1
+ @import 'tailwindcss' prefix(tw);
2
+
3
+ @theme {
4
+ --font-sans: 'Avenir Next', 'Avenir', 'Myriad Pro', 'Helvetica Neue', sans-serif;
5
+ }
6
+
7
+ @layer base {
8
+ * {
9
+ -webkit-font-smoothing: antialiased;
10
+ -moz-osx-font-smoothing: grayscale;
11
+ }
12
+
13
+ html,
14
+ :host {
15
+ @apply tw:font-sans tw:text-gray-800;
16
+ }
17
+ }
18
+
19
+ @layer components {
20
+ .form-input,
21
+ .form-input-validated {
22
+ display: block;
23
+ width: 100%;
24
+ margin-top: 0.25rem;
25
+ border-radius: var(--radius, 0.25rem);
26
+ border: 1px solid var(--color-gray-300, #d1d5db);
27
+ padding: 0.375rem 0.75rem;
28
+ font-size: var(--text-sm, 0.875rem);
29
+ line-height: var(--tw-leading-sm, 1.25rem);
30
+ }
31
+ .form-input:focus,
32
+ .form-input-validated:focus {
33
+ outline: none;
34
+ box-shadow: 0 0 0 2px var(--color-blue-500, #3b82f6);
35
+ }
36
+ /* peer class is applied at the HTML element level and cannot be set via CSS */
37
+ .submitted .form-input-validated:invalid {
38
+ border-color: var(--color-red-500, #ef4444);
39
+ }
40
+ .form-error {
41
+ display: none;
42
+ font-size: var(--text-xs, 0.75rem);
43
+ color: var(--color-red-600, #dc2626);
44
+ margin-top: 0.125rem;
45
+ }
46
+ .submitted .form-input-validated:invalid ~ .form-error {
47
+ display: block;
48
+ }
49
+ .btn-primary {
50
+ display: block;
51
+ width: 100%;
52
+ border-radius: var(--radius, 0.25rem);
53
+ background-color: var(--color-blue-600, #2563eb);
54
+ padding: 0.5rem 1rem;
55
+ font-weight: 600;
56
+ color: white;
57
+ border: none;
58
+ cursor: pointer;
59
+ }
60
+ .btn-primary:hover {
61
+ background-color: var(--color-blue-700, #1d4ed8);
62
+ }
63
+ }
@@ -0,0 +1,9 @@
1
+ import { redirect } from '@sveltejs/kit'
2
+ import type { PageServerLoad } from './$types'
3
+
4
+ export const load: PageServerLoad = ({ locals }) => {
5
+ if (locals.user) {
6
+ redirect(302, '/')
7
+ }
8
+ return {}
9
+ }