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
@@ -1,11 +1,11 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte'
3
- import { goto } from '$app/navigation'
4
- import { loginSession } from '../../stores'
5
3
  import { focusOnFirstError } from '$lib/focus'
6
4
  import { initializeGoogleAccounts, renderGoogleButton } from '$lib/google'
7
5
 
8
6
  let focusedField: HTMLInputElement | undefined = $state()
7
+ // Pattern stored as a variable to avoid Svelte parsing `{8,}` as a template expression
8
+ const passwordPattern = '(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}'
9
9
 
10
10
  let user: User = $state({
11
11
  id: 0,
@@ -18,13 +18,21 @@
18
18
  })
19
19
  let confirmPassword: HTMLInputElement | undefined = $state()
20
20
  let message = $state('')
21
+ let submitted = $state(false)
22
+ let passwordMismatch = $state(false)
23
+ let loading = $state(false)
24
+ let emailVerificationSent = $state(false)
25
+
26
+ let formEl: HTMLFormElement | undefined = $state()
21
27
 
22
28
  async function register() {
23
- const form = document.getElementById('register') as HTMLFormElement
29
+ const form = formEl!
24
30
  message = ''
31
+ submitted = false
32
+ passwordMismatch = false
25
33
 
26
34
  if (!passwordMatch()) {
27
- confirmPassword?.classList.add('is-invalid')
35
+ passwordMismatch = true
28
36
  return
29
37
  }
30
38
 
@@ -38,7 +46,7 @@
38
46
  }
39
47
  }
40
48
  } else {
41
- form.classList.add('was-validated')
49
+ submitted = true
42
50
  focusOnFirstError(form)
43
51
  }
44
52
  }
@@ -51,6 +59,7 @@
51
59
  })
52
60
 
53
61
  async function registerLocal(user: User) {
62
+ loading = true
54
63
  try {
55
64
  const res = await fetch('/auth/register', {
56
65
  method: 'POST',
@@ -59,22 +68,23 @@
59
68
  'Content-Type': 'application/json'
60
69
  }
61
70
  })
71
+ const fromEndpoint = await res.json()
62
72
  if (!res.ok) {
63
- if (res.status == 401)
64
- // user already existed and passwords didn't match (otherwise, we login the user)
65
- throw new Error('Sorry, that username is already in use.')
66
- throw new Error(res.statusText) // should only occur if there's a database error
73
+ if (res.status == 409)
74
+ throw new Error('Sorry, that email address is already in use.')
75
+ throw new Error(fromEndpoint.message || res.statusText)
76
+ }
77
+ if (fromEndpoint.emailVerification) {
78
+ emailVerificationSent = true
79
+ return
67
80
  }
68
-
69
- // res.ok
70
- const fromEndpoint = await res.json()
71
- loginSession.set(fromEndpoint.user) // update store so user is logged in
72
- goto('/')
73
81
  } catch (err) {
74
82
  console.error('Register error', err)
75
83
  if (err instanceof Error) {
76
84
  throw new Error(err.message)
77
85
  }
86
+ } finally {
87
+ loading = false
78
88
  }
79
89
  }
80
90
 
@@ -89,116 +99,128 @@
89
99
  <title>Register</title>
90
100
  </svelte:head>
91
101
 
92
- <div class="d-flex justify-content-center my-3">
93
- <div class="card login">
94
- <div class="card-body">
95
- <h4><strong>Register</strong></h4>
96
- <p>Welcome to our community.</p>
97
- {#if user}
98
- <form id="register" autocomplete="on" novalidate class="mt-3">
99
- <div class="mb-3">
100
- <div id="googleButton"></div>
101
- </div>
102
- <div class="mb-3">
103
- <label class="form-label" for="email">Email</label>
104
- <input
105
- bind:this={focusedField}
106
- type="email"
107
- class="form-control"
108
- bind:value={user.email}
109
- required
110
- placeholder="Email"
111
- id="email"
112
- autocomplete="email"
113
- />
114
- <div class="invalid-feedback">Email address required</div>
115
- </div>
116
- <div class="mb-3">
117
- <label class="form-label" for="password">Password</label>
118
- <input
119
- type="password"
120
- id="password"
121
- class="form-control"
122
- bind:value={user.password}
123
- required
124
- minlength="8"
125
- maxlength="80"
126
- placeholder="Password"
127
- autocomplete="new-password"
128
- />
129
- <div class="invalid-feedback">Password with 8 chars or more required</div>
130
- <div class="form-text">
131
- Password minimum length 8, must have one capital letter, 1 number, and one unique
132
- character.
133
- </div>
134
- </div>
135
- <div class="mb-3">
136
- <label class="form-label" for="password">Confirm password</label>
137
- <input
138
- type="password"
139
- id="password"
140
- class="form-control"
141
- bind:this={confirmPassword}
142
- required
143
- minlength="8"
144
- maxlength="80"
145
- placeholder="Password (again)"
146
- autocomplete="new-password"
147
- />
148
- <div class="form-text">
149
- Password minimum length 8, must have one capital letter, 1 number, and one unique
150
- character.
151
- </div>
152
- </div>
153
- <div class="mb-3">
154
- <label class="form-label" for="firstName">First name</label>
155
- <input
156
- bind:value={user.firstName}
157
- class="form-control"
158
- id="firstName"
159
- placeholder="First name"
160
- required
161
- autocomplete="given-name"
162
- />
163
- <div class="invalid-feedback">First name required</div>
164
- </div>
165
- <div class="mb-3">
166
- <label class="form-label" for="lastName">Last name</label>
167
- <input
168
- bind:value={user.lastName}
169
- class="form-control"
170
- id="lastName"
171
- placeholder="Last name"
172
- required
173
- autocomplete="family-name"
174
- />
175
- <div class="invalid-feedback">Last name required</div>
176
- </div>
177
- <div class="mb-3">
178
- <label class="form-label" for="phone">Phone</label>
179
- <input
180
- type="tel"
181
- bind:value={user.phone}
182
- id="phone"
183
- class="form-control"
184
- placeholder="Phone"
185
- autocomplete="tel-local"
186
- />
187
- </div>
188
-
189
- {#if message}
190
- <p class="text-danger">{message}</p>
191
- {/if}
192
-
193
- <button onclick={register} type="button" class="btn btn-primary btn-lg">Register</button>
194
- </form>
195
- {/if}
196
- </div>
102
+ {#if emailVerificationSent}
103
+ <div class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4">
104
+ <h4>Check your email</h4>
105
+ <p>We've sent a verification link to your email address. Please check your inbox (and junk folder) to complete your registration.</p>
106
+ <a href="/login" class="btn-primary tw:block tw:text-center tw:no-underline">Back to login</a>
197
107
  </div>
198
- </div>
108
+ {:else}
109
+ <form
110
+ bind:this={formEl}
111
+ autocomplete="on"
112
+ novalidate
113
+ class="tw:mx-auto tw:my-8 tw:max-w-sm tw:space-y-4"
114
+ class:submitted
115
+ onsubmit={(e) => { e.preventDefault(); register() }}
116
+ >
117
+ <h4>Register</h4>
118
+ <p>Welcome to our community.</p>
199
119
 
200
- <style>
201
- .card-body {
202
- width: 25rem;
203
- }
204
- </style>
120
+ <div id="googleButton" class="tw:w-full"></div>
121
+
122
+ <label class="tw:block tw:text-sm tw:font-medium" for="email">
123
+ Email
124
+ <input
125
+ bind:this={focusedField}
126
+ type="email"
127
+ class="form-input-validated"
128
+ bind:value={user.email}
129
+ required
130
+ placeholder="Email"
131
+ id="email"
132
+ autocomplete="email"
133
+ />
134
+ <span class="form-error">Email address required</span>
135
+ </label>
136
+
137
+ <label class="tw:block tw:text-sm tw:font-medium" for="password">
138
+ Password
139
+ <input
140
+ type="password"
141
+ id="password"
142
+ class="form-input-validated"
143
+ bind:value={user.password}
144
+ required
145
+ minlength="8"
146
+ maxlength="80"
147
+ pattern={passwordPattern}
148
+ placeholder="Password"
149
+ autocomplete="new-password"
150
+ />
151
+ <span class="form-error">Must be 8+ characters with a capital letter, number, and special character</span>
152
+ <span class="tw:text-xs tw:text-gray-500">
153
+ Minimum 8 characters, one capital letter, one number, one special character.
154
+ </span>
155
+ </label>
156
+
157
+ <label class="tw:block tw:text-sm tw:font-medium" for="confirmPassword">
158
+ Confirm password
159
+ <input
160
+ type="password"
161
+ id="confirmPassword"
162
+ class="form-input"
163
+ class:tw:border-red-500={passwordMismatch}
164
+ bind:this={confirmPassword}
165
+ required
166
+ minlength="8"
167
+ maxlength="80"
168
+ placeholder="Password (again)"
169
+ autocomplete="new-password"
170
+ />
171
+ {#if passwordMismatch}
172
+ <span class="tw:text-xs tw:text-red-600 tw:mt-0.5">Passwords must match</span>
173
+ {/if}
174
+ </label>
175
+
176
+ <label class="tw:block tw:text-sm tw:font-medium" for="firstName">
177
+ First name
178
+ <input
179
+ bind:value={user.firstName}
180
+ class="form-input-validated"
181
+ id="firstName"
182
+ placeholder="First name"
183
+ required
184
+ autocomplete="given-name"
185
+ />
186
+ <span class="form-error">First name required</span>
187
+ </label>
188
+
189
+ <label class="tw:block tw:text-sm tw:font-medium" for="lastName">
190
+ Last name
191
+ <input
192
+ bind:value={user.lastName}
193
+ class="form-input-validated"
194
+ id="lastName"
195
+ placeholder="Last name"
196
+ required
197
+ autocomplete="family-name"
198
+ />
199
+ <span class="form-error">Last name required</span>
200
+ </label>
201
+
202
+ <label class="tw:block tw:text-sm tw:font-medium" for="phone">
203
+ Phone
204
+ <input
205
+ type="tel"
206
+ bind:value={user.phone}
207
+ id="phone"
208
+ class="form-input"
209
+ placeholder="Phone"
210
+ autocomplete="tel-local"
211
+ />
212
+ </label>
213
+
214
+ {#if message}
215
+ <p class="tw:text-red-600">{message}</p>
216
+ {/if}
217
+
218
+ <button
219
+ type="submit"
220
+ class="btn-primary"
221
+ disabled={loading}
222
+ >
223
+ {loading ? 'Creating account...' : 'Register'}
224
+ </button>
225
+ </form>
226
+ {/if}
@@ -40,13 +40,23 @@ sw.addEventListener('fetch', event => {
40
40
  // ignore POST requests etc
41
41
  if (event.request.method !== 'GET') return
42
42
 
43
+ const url = new URL(event.request.url)
44
+
45
+ // Don't intercept API, auth, or non-HTTP requests — let them go straight to the network
46
+ const bypass =
47
+ url.pathname.startsWith('/api') ||
48
+ url.pathname.startsWith('/auth') ||
49
+ (url.protocol !== 'http:' && url.protocol !== 'https:')
50
+
51
+ if (bypass) return
52
+
43
53
  async function respond() {
44
- const url = new URL(event.request.url)
45
54
  const cache = await caches.open(CACHE)
46
55
 
47
56
  // `build`/`files` can always be served from the cache
48
57
  if (ASSETS.includes(url.pathname)) {
49
- return cache.match(url.pathname)
58
+ const response = await cache.match(url.pathname)
59
+ if (response) return response
50
60
  }
51
61
 
52
62
  // for everything else, try the network first, but
@@ -54,13 +64,21 @@ sw.addEventListener('fetch', event => {
54
64
  try {
55
65
  const response = await fetch(event.request)
56
66
 
67
+ // if we're offline, fetch can return a value that is not a Response
68
+ // instead of throwing - and we can't pass this non-Response to respondWith
69
+ if (!(response instanceof Response)) {
70
+ throw new Error('invalid response from fetch')
71
+ }
72
+
57
73
  if (response.status === 200) {
58
74
  cache.put(event.request, response.clone())
59
75
  }
60
76
 
61
77
  return response
62
- } catch {
63
- return cache.match(event.request)
78
+ } catch (err) {
79
+ const response = await cache.match(event.request)
80
+ if (response) return response
81
+ throw err
64
82
  }
65
83
  }
66
84
 
package/svelte.config.js CHANGED
@@ -15,7 +15,19 @@ if (!production) baseCsp.push('ws://localhost:3000')
15
15
 
16
16
  /** @type {import('@sveltejs/kit').Config} */
17
17
  const config = {
18
- preprocess: vitePreprocess(),
18
+ preprocess: [
19
+ vitePreprocess(),
20
+ {
21
+ name: 'announcer-styles-to-tailwind',
22
+ markup: ({ content: code }) => {
23
+ code = code.replace(
24
+ /(<div id="svelte-announcer"[^>]*?)\s+style="[^"]*"/,
25
+ '$1 class="tw:absolute tw:left-0 tw:top-0 tw:[clip:rect(0,0,0,0)] tw:[clip-path:inset(50%)] tw:overflow-hidden tw:whitespace-nowrap tw:w-px tw:h-px"'
26
+ )
27
+ return { code }
28
+ }
29
+ }
30
+ ],
19
31
 
20
32
  kit: {
21
33
  adapter: adapter({
package/tsconfig.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "extends": "./.svelte-kit/tsconfig.json",
3
3
  "compilerOptions": {
4
+ "rewriteRelativeImportExtensions": true,
4
5
  "allowJs": true,
5
6
  "checkJs": true,
6
7
  "esModuleInterop": true,
@@ -8,6 +9,7 @@
8
9
  "resolveJsonModule": true,
9
10
  "skipLibCheck": true,
10
11
  "sourceMap": true,
11
- "strict": true
12
+ "strict": true,
13
+ "moduleResolution": "bundler"
12
14
  }
13
15
  }
package/vite.config.ts CHANGED
@@ -1,11 +1,15 @@
1
1
  import { sveltekit } from '@sveltejs/kit/vite'
2
2
  import { defineConfig } from 'vite'
3
+ import tailwindcss from '@tailwindcss/vite'
3
4
 
4
5
  export default defineConfig({
5
6
  build: {
6
7
  sourcemap: true
7
8
  },
8
- plugins: [sveltekit()],
9
+ plugins: [sveltekit(), tailwindcss()],
10
+ test: {
11
+ include: ['src/**/*.unit.test.ts', 'tests/**/*.unit.test.ts']
12
+ },
9
13
  server: {
10
14
  host: 'localhost',
11
15
  port: 3000,