sveltekit-auth-example 5.5.0 → 5.6.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 (43) hide show
  1. package/CHANGELOG.md +19 -1
  2. package/README.md +12 -12
  3. package/db_create.sh +13 -0
  4. package/db_create.sql +15 -376
  5. package/db_schema.sql +369 -0
  6. package/package.json +5 -2
  7. package/src/app.d.ts +22 -1
  8. package/src/hooks.server.ts +47 -12
  9. package/src/lib/app-state.svelte.ts +8 -0
  10. package/src/lib/auth-redirect.ts +4 -0
  11. package/src/lib/focus.ts +8 -0
  12. package/src/lib/google.ts +17 -0
  13. package/src/lib/server/db.ts +9 -0
  14. package/src/lib/server/email/index.ts +1 -0
  15. package/src/lib/server/email/mfa-code.ts +22 -0
  16. package/src/lib/server/email/password-reset.ts +6 -0
  17. package/src/lib/server/email/verify-email.ts +6 -0
  18. package/src/lib/server/sendgrid.ts +9 -0
  19. package/src/routes/+layout.server.ts +10 -1
  20. package/src/routes/+layout.svelte +103 -28
  21. package/src/routes/admin/+page.server.ts +8 -0
  22. package/src/routes/api/v1/user/+server.ts +20 -0
  23. package/src/routes/auth/[slug]/+server.ts +9 -2
  24. package/src/routes/auth/forgot/+server.ts +10 -0
  25. package/src/routes/auth/google/+server.ts +35 -4
  26. package/src/routes/auth/login/+server.ts +67 -10
  27. package/src/routes/auth/logout/+server.ts +10 -0
  28. package/src/routes/auth/mfa/+server.ts +75 -0
  29. package/src/routes/auth/register/+server.ts +21 -1
  30. package/src/routes/auth/reset/+server.ts +15 -0
  31. package/src/routes/auth/reset/[token]/+page.svelte +16 -8
  32. package/src/routes/auth/reset/[token]/+page.ts +8 -0
  33. package/src/routes/auth/verify/[token]/+server.ts +12 -1
  34. package/src/routes/forgot/+page.svelte +13 -8
  35. package/src/routes/layout.css +3 -3
  36. package/src/routes/login/+page.server.ts +8 -0
  37. package/src/routes/login/+page.svelte +222 -77
  38. package/src/routes/profile/+page.server.ts +9 -0
  39. package/src/routes/profile/+page.svelte +32 -12
  40. package/src/routes/register/+page.server.ts +8 -1
  41. package/src/routes/register/+page.svelte +160 -122
  42. package/src/routes/teachers/+page.server.ts +9 -0
  43. package/src/service-worker.ts +17 -1
@@ -25,6 +25,10 @@
25
25
 
26
26
  let formEl: HTMLFormElement | undefined = $state()
27
27
 
28
+ /**
29
+ * Validates the registration form and, if valid, delegates to {@link registerLocal}.
30
+ * Checks that passwords match before form validation runs.
31
+ */
28
32
  async function register() {
29
33
  const form = formEl!
30
34
  message = ''
@@ -57,6 +61,16 @@
57
61
  focusedField?.focus()
58
62
  })
59
63
 
64
+ /**
65
+ * POSTs the new user data to `/auth/register`.
66
+ *
67
+ * The server ignores the `role` field and always assigns the lowest privilege
68
+ * (`student`). On success with `emailVerification: true`, sets
69
+ * `emailVerificationSent` to show the confirmation message instead of the form.
70
+ *
71
+ * @param user - The user object collected from the registration form.
72
+ * @throws {Error} With a user-friendly message on HTTP errors.
73
+ */
60
74
  async function registerLocal(user: User) {
61
75
  loading = true
62
76
  try {
@@ -69,8 +83,7 @@
69
83
  })
70
84
  const fromEndpoint = await res.json()
71
85
  if (!res.ok) {
72
- if (res.status == 409)
73
- throw new Error('Sorry, that email address is already in use.')
86
+ if (res.status == 409) throw new Error('Sorry, that email address is already in use.')
74
87
  throw new Error(fromEndpoint.message || res.statusText)
75
88
  }
76
89
  if (fromEndpoint.emailVerification) {
@@ -87,6 +100,7 @@
87
100
  }
88
101
  }
89
102
 
103
+ /** Returns `true` if the password and confirm-password fields match. */
90
104
  const passwordMatch = () => {
91
105
  if (!user) return false // placate TypeScript
92
106
  if (!user.password) user.password = ''
@@ -101,138 +115,162 @@
101
115
  {#if emailVerificationSent}
102
116
  <div class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4">
103
117
  <h4>Check your email</h4>
104
- <p>We've sent a verification link to your email address. Please check your inbox (and junk folder) to complete your registration.</p>
118
+ <p>
119
+ We've sent a verification link to your email address. Please check your inbox (and junk
120
+ folder) to complete your registration.
121
+ </p>
105
122
  <a href="/login" class="btn-primary tw:block tw:text-center tw:no-underline">Back to login</a>
106
123
  </div>
107
124
  {:else}
108
- <form
109
- bind:this={formEl}
110
- autocomplete="on"
111
- novalidate
112
- class="tw:mx-auto tw:my-8 tw:max-w-sm tw:space-y-4"
113
- class:submitted
114
- onsubmit={(e) => { e.preventDefault(); register() }}
115
- >
116
- <h4>Register</h4>
125
+ <form
126
+ bind:this={formEl}
127
+ autocomplete="on"
128
+ novalidate
129
+ class="tw:mx-auto tw:my-8 tw:max-w-sm tw:space-y-4"
130
+ class:submitted
131
+ onsubmit={e => {
132
+ e.preventDefault()
133
+ register()
134
+ }}
135
+ >
136
+ <h4>Register</h4>
117
137
  <p>Welcome to our community.</p>
118
138
 
119
139
  <div class="tw:relative tw:w-full">
120
140
  <!-- Real Google button: invisible but receives clicks -->
121
- <div id="googleButton" class="tw:opacity-0 tw:w-full"></div>
141
+ <div id="googleButton" class="tw:w-full tw:opacity-0"></div>
122
142
  <!-- Visual overlay: looks good, no pointer events -->
123
- <div class="tw:pointer-events-none tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:gap-3 tw:rounded tw:border tw:border-gray-300 tw:bg-white tw:text-sm tw:font-medium tw:text-gray-700 tw:dark:bg-gray-800 tw:dark:border-gray-600 tw:dark:text-gray-200">
124
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
125
- <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
126
- <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
127
- <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
128
- <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
143
+ <div
144
+ class="tw:pointer-events-none tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:gap-3 tw:rounded tw:border tw:border-gray-300 tw:bg-white tw:text-sm tw:font-medium tw:text-gray-700 tw:dark:border-gray-600 tw:dark:bg-gray-800 tw:dark:text-gray-200"
145
+ >
146
+ <svg
147
+ xmlns="http://www.w3.org/2000/svg"
148
+ viewBox="0 0 24 24"
149
+ width="18"
150
+ height="18"
151
+ aria-hidden="true"
152
+ >
153
+ <path
154
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
155
+ fill="#4285F4"
156
+ />
157
+ <path
158
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
159
+ fill="#34A853"
160
+ />
161
+ <path
162
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
163
+ fill="#FBBC05"
164
+ />
165
+ <path
166
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
167
+ fill="#EA4335"
168
+ />
129
169
  </svg>
130
170
  Sign in with Google
131
171
  </div>
132
172
  </div>
133
173
 
134
- <label class="tw:block tw:text-sm tw:font-medium" for="email">
135
- Email
136
- <input
137
- bind:this={focusedField}
138
- type="email"
139
- class="form-input-validated"
140
- bind:value={user.email}
141
- required
142
- placeholder="Email"
143
- id="email"
144
- autocomplete="email"
145
- />
146
- <span class="form-error">Email address required</span>
147
- </label>
148
-
149
- <label class="tw:block tw:text-sm tw:font-medium" for="password">
150
- Password
151
- <input
152
- type="password"
153
- id="password"
154
- class="form-input-validated"
155
- bind:value={user.password}
156
- required
157
- minlength="8"
158
- maxlength="80"
159
- pattern={passwordPattern}
160
- placeholder="Password"
161
- autocomplete="new-password"
162
- />
163
- <span class="form-error">Must be 8+ characters with a capital letter, number, and special character</span>
164
- <span class="tw:text-xs tw:text-gray-500">
165
- Minimum 8 characters, one capital letter, one number, one special character.
166
- </span>
167
- </label>
168
-
169
- <label class="tw:block tw:text-sm tw:font-medium" for="confirmPassword">
170
- Confirm password
171
- <input
172
- type="password"
173
- id="confirmPassword"
174
- class="form-input"
175
- class:tw:border-red-500={passwordMismatch}
176
- bind:this={confirmPassword}
177
- required
178
- minlength="8"
179
- maxlength="80"
180
- placeholder="Password (again)"
181
- autocomplete="new-password"
182
- />
183
- {#if passwordMismatch}
184
- <span class="tw:text-xs tw:text-red-600 tw:mt-0.5">Passwords must match</span>
174
+ <label class="tw:block tw:text-sm tw:font-medium" for="email">
175
+ Email
176
+ <input
177
+ bind:this={focusedField}
178
+ type="email"
179
+ class="form-input-validated"
180
+ bind:value={user.email}
181
+ required
182
+ placeholder="Email"
183
+ id="email"
184
+ autocomplete="email"
185
+ />
186
+ <span class="form-error">Email address required</span>
187
+ </label>
188
+
189
+ <label class="tw:block tw:text-sm tw:font-medium" for="password">
190
+ Password
191
+ <input
192
+ type="password"
193
+ id="password"
194
+ class="form-input-validated"
195
+ bind:value={user.password}
196
+ required
197
+ minlength="8"
198
+ maxlength="80"
199
+ pattern={passwordPattern}
200
+ placeholder="Password"
201
+ autocomplete="new-password"
202
+ />
203
+ <span class="form-error"
204
+ >Must be 8+ characters with a capital letter, number, and special character</span
205
+ >
206
+ <span class="tw:text-xs tw:text-gray-500">
207
+ Minimum 8 characters, one capital letter, one number, one special character.
208
+ </span>
209
+ </label>
210
+
211
+ <label class="tw:block tw:text-sm tw:font-medium" for="confirmPassword">
212
+ Confirm password
213
+ <input
214
+ type="password"
215
+ id="confirmPassword"
216
+ class="form-input"
217
+ class:tw:border-red-500={passwordMismatch}
218
+ bind:this={confirmPassword}
219
+ required
220
+ minlength="8"
221
+ maxlength="80"
222
+ placeholder="Password (again)"
223
+ autocomplete="new-password"
224
+ />
225
+ {#if passwordMismatch}
226
+ <span class="tw:mt-0.5 tw:text-xs tw:text-red-600">Passwords must match</span>
227
+ {/if}
228
+ </label>
229
+
230
+ <label class="tw:block tw:text-sm tw:font-medium" for="firstName">
231
+ First name
232
+ <input
233
+ bind:value={user.firstName}
234
+ class="form-input-validated"
235
+ id="firstName"
236
+ placeholder="First name"
237
+ required
238
+ autocomplete="given-name"
239
+ />
240
+ <span class="form-error">First name required</span>
241
+ </label>
242
+
243
+ <label class="tw:block tw:text-sm tw:font-medium" for="lastName">
244
+ Last name
245
+ <input
246
+ bind:value={user.lastName}
247
+ class="form-input-validated"
248
+ id="lastName"
249
+ placeholder="Last name"
250
+ required
251
+ autocomplete="family-name"
252
+ />
253
+ <span class="form-error">Last name required</span>
254
+ </label>
255
+
256
+ <label class="tw:block tw:text-sm tw:font-medium" for="phone">
257
+ Phone
258
+ <input
259
+ type="tel"
260
+ bind:value={user.phone}
261
+ id="phone"
262
+ class="form-input"
263
+ placeholder="Phone"
264
+ autocomplete="tel-local"
265
+ />
266
+ </label>
267
+
268
+ {#if message}
269
+ <p class="tw:text-red-600">{message}</p>
185
270
  {/if}
186
- </label>
187
-
188
- <label class="tw:block tw:text-sm tw:font-medium" for="firstName">
189
- First name
190
- <input
191
- bind:value={user.firstName}
192
- class="form-input-validated"
193
- id="firstName"
194
- placeholder="First name"
195
- required
196
- autocomplete="given-name"
197
- />
198
- <span class="form-error">First name required</span>
199
- </label>
200
-
201
- <label class="tw:block tw:text-sm tw:font-medium" for="lastName">
202
- Last name
203
- <input
204
- bind:value={user.lastName}
205
- class="form-input-validated"
206
- id="lastName"
207
- placeholder="Last name"
208
- required
209
- autocomplete="family-name"
210
- />
211
- <span class="form-error">Last name required</span>
212
- </label>
213
-
214
- <label class="tw:block tw:text-sm tw:font-medium" for="phone">
215
- Phone
216
- <input
217
- type="tel"
218
- bind:value={user.phone}
219
- id="phone"
220
- class="form-input"
221
- placeholder="Phone"
222
- autocomplete="tel-local"
223
- />
224
- </label>
225
-
226
- {#if message}
227
- <p class="tw:text-red-600">{message}</p>
228
- {/if}
229
-
230
- <button
231
- type="submit"
232
- class="btn-primary"
233
- disabled={loading}
234
- >
235
- {loading ? 'Creating account...' : 'Register'}
236
- </button>
237
- </form>
271
+
272
+ <button type="submit" class="btn-primary" disabled={loading}>
273
+ {loading ? 'Creating account...' : 'Register'}
274
+ </button>
275
+ </form>
238
276
  {/if}
@@ -1,6 +1,15 @@
1
1
  import { redirect } from '@sveltejs/kit'
2
2
  import type { PageServerLoad } from './$types'
3
3
 
4
+ /**
5
+ * Page server load function for the teachers route.
6
+ *
7
+ * Restricts access to users with the `teacher` or `admin` role. Unauthenticated
8
+ * or unauthorized users are redirected to the login page with a `referrer`
9
+ * parameter so they are returned here after logging in.
10
+ *
11
+ * @returns An object with a placeholder `message` for teacher/admin-only server content.
12
+ */
4
13
  export const load: PageServerLoad = async ({ locals }) => {
5
14
  const { user } = locals
6
15
  const authorized = ['admin', 'teacher']
@@ -7,7 +7,7 @@ import { build, files, version } from '$service-worker'
7
7
 
8
8
  const sw = self as unknown as ServiceWorkerGlobalScope
9
9
 
10
- // Create a unique cache name for this deployment
10
+ /** Unique cache name for this deployment, keyed by the SvelteKit build version. */
11
11
  const CACHE = `cache-${version}`
12
12
 
13
13
  const ASSETS = [
@@ -15,6 +15,10 @@ const ASSETS = [
15
15
  ...files // everything in `static`
16
16
  ]
17
17
 
18
+ /**
19
+ * On install, opens the versioned cache and pre-caches all build artifacts
20
+ * and static files so they are available offline.
21
+ */
18
22
  sw.addEventListener('install', event => {
19
23
  // Create a new cache and add all files to it
20
24
  async function addFilesToCache() {
@@ -25,6 +29,10 @@ sw.addEventListener('install', event => {
25
29
  event.waitUntil(addFilesToCache())
26
30
  })
27
31
 
32
+ /**
33
+ * On activate, removes all caches from previous deployments, keeping only
34
+ * the cache for the current build version.
35
+ */
28
36
  sw.addEventListener('activate', event => {
29
37
  // Remove previous cached data from disk
30
38
  async function deleteOldCaches() {
@@ -36,6 +44,14 @@ sw.addEventListener('activate', event => {
36
44
  event.waitUntil(deleteOldCaches())
37
45
  })
38
46
 
47
+ /**
48
+ * Intercepts GET requests and applies a cache-first strategy for pre-cached
49
+ * assets, falling back to network-first (with cache fallback) for all other
50
+ * requests.
51
+ *
52
+ * API (`/api`), auth (`/auth`), and non-HTTP requests are bypassed and go
53
+ * directly to the network.
54
+ */
39
55
  sw.addEventListener('fetch', event => {
40
56
  // ignore POST requests etc
41
57
  if (event.request.method !== 'GET') return