sveltekit-auth-example 5.6.0 → 5.8.2

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/.env.example +5 -3
  2. package/CHANGELOG.md +32 -0
  3. package/README.md +15 -9
  4. package/db_create.sql +1 -364
  5. package/db_schema.sql +3 -3
  6. package/package.json +7 -5
  7. package/src/app.d.ts +125 -40
  8. package/src/app.html +6 -0
  9. package/src/hooks.server.ts +37 -5
  10. package/src/lib/Turnstile.svelte +53 -0
  11. package/src/lib/app-state.svelte.ts +8 -0
  12. package/src/lib/auth-redirect.ts +4 -0
  13. package/src/lib/focus.ts +8 -0
  14. package/src/lib/google.ts +58 -17
  15. package/src/lib/server/brevo.ts +179 -0
  16. package/src/lib/server/db.ts +9 -0
  17. package/src/lib/server/email/mfa-code.ts +12 -6
  18. package/src/lib/server/email/password-reset.ts +12 -6
  19. package/src/lib/server/email/verify-email.ts +12 -6
  20. package/src/lib/server/turnstile.ts +29 -0
  21. package/src/routes/+layout.server.ts +10 -1
  22. package/src/routes/+layout.svelte +14 -0
  23. package/src/routes/admin/+page.server.ts +8 -0
  24. package/src/routes/api/v1/user/+server.ts +20 -0
  25. package/src/routes/auth/[slug]/+server.ts +9 -2
  26. package/src/routes/auth/forgot/+server.ts +17 -0
  27. package/src/routes/auth/google/+server.ts +29 -3
  28. package/src/routes/auth/login/+server.ts +32 -3
  29. package/src/routes/auth/logout/+server.ts +10 -0
  30. package/src/routes/auth/mfa/+server.ts +21 -1
  31. package/src/routes/auth/register/+server.ts +23 -1
  32. package/src/routes/auth/reset/+server.ts +21 -1
  33. package/src/routes/auth/reset/[token]/+page.svelte +21 -1
  34. package/src/routes/auth/reset/[token]/+page.ts +8 -0
  35. package/src/routes/auth/verify/[token]/+server.ts +12 -1
  36. package/src/routes/forgot/+page.svelte +17 -1
  37. package/src/routes/login/+page.server.ts +8 -0
  38. package/src/routes/login/+page.svelte +43 -5
  39. package/src/routes/profile/+page.server.ts +9 -0
  40. package/src/routes/profile/+page.svelte +16 -0
  41. package/src/routes/register/+page.server.ts +8 -1
  42. package/src/routes/register/+page.svelte +26 -1
  43. package/src/routes/teachers/+page.server.ts +9 -0
  44. package/src/service-worker.ts +17 -1
  45. package/svelte.config.js +4 -2
  46. package/vite.config.ts +8 -13
  47. package/src/lib/server/sendgrid.ts +0 -13
package/.env.example CHANGED
@@ -2,6 +2,8 @@ DATABASE_URL=postgres://REPLACE_WITH_USER:REPLACE_WITH_PASSWORD@localhost:5432/a
2
2
  DATABASE_SSL=false
3
3
  DOMAIN=http://localhost:3000
4
4
  JWT_SECRET=replace_with_your_own
5
- SENDGRID_KEY=replace_with_your_own
6
- SENDGRID_SENDER=replace_with_your_own
7
- PUBLIC_GOOGLE_CLIENT_ID=REPLACE_WITH_YOUR_OWN
5
+ BREVO_KEY=replace_with_your_own
6
+ EMAIL=replace_with_your_own
7
+ PUBLIC_GOOGLE_CLIENT_ID=replace_with_your_own
8
+ PUBLIC_TURNSTILE_SITE_KEY=replace_with_your_own
9
+ TURNSTILE_SECRET_KEY=replace_with_your_own
package/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  - Add password complexity checking on /register and /profile pages (only checks for length currently despite what the pages say)
4
4
 
5
+ # 5.8.2
6
+
7
+ - Add Chrome DevTools
8
+
9
+ # 5.8.1
10
+
11
+ - Fix MFA verification code always showing "6-digit verification code required" for valid codes: replaced `form.checkValidity()` and `codeInput.checkValidity()` (which picked up Cloudflare Turnstile's injected form elements) with a direct JS regex test against the bound `mfaCode` value
12
+ - Move `<Turnstile>` outside the MFA `<form>` so its injected inputs are never part of the form's element collection
13
+ - Fix `google is not defined` crash on hard reload of `/login`: Google GSI script is loaded `async defer` and may not be ready when `onMount` fires; added `whenGoogleReady()` helper in `src/lib/google.ts` that polls until `google` is available (up to 10 s) before calling `initializeGoogleAccounts()` and `renderGoogleButton()`
14
+
15
+ # 5.8.0
16
+
17
+ - Replace SendGrid with Brevo for all transactional email (password reset, email verification, MFA code)
18
+ - New `src/lib/server/brevo.ts` sends email via the Brevo API with exponential-backoff retry logic (up to 4 attempts), 30-second per-request timeout, and `Retry-After` header support
19
+ - Email templates (`password-reset.ts`, `mfa-code.ts`, `verify-email.ts`) updated to use Brevo message format (`sender`, `to[]`, `htmlContent`, `tags`)
20
+ - Env vars changed: `SENDGRID_KEY` → `BREVO_KEY`, `SENDGRID_SENDER` → `EMAIL`
21
+ - `app.d.ts` `PrivateEnv` updated to reflect new env vars
22
+ - README updated with Brevo setup instructions and new env var names
23
+
24
+ # 5.7.0
25
+
26
+ - Add Cloudflare Turnstile CAPTCHA to login, register, forgot password, MFA, and password reset forms
27
+ - New `src/lib/Turnstile.svelte` reusable Svelte 5 component wraps the Turnstile widget with `reset()` export
28
+ - New `src/lib/server/turnstile.ts` server-side token verification utility (`verifyTurnstileToken`)
29
+ - All auth endpoints verify the Turnstile challenge token before processing requests
30
+ - New env vars: `PUBLIC_TURNSTILE_SITE_KEY` (client) and `TURNSTILE_SECRET_KEY` (server)
31
+ - Extend `app.d.ts` with `PublicEnv`, `PrivateEnv` (add `TURNSTILE_SECRET_KEY`), and `Window.turnstile` types; wrap in `declare global`
32
+
33
+ # 5.6.1
34
+
35
+ - Add JSDoc comments throughout the codebase (`app.d.ts`, server hooks, route handlers, lib utilities, Svelte components)
36
+
5
37
  # 5.6.0
6
38
 
7
39
  - Split `db_create.sql` into `db_create.sql` (role + database creation) and `db_schema.sql` (schema, functions, seed data)
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  [![Node](https://img.shields.io/node/v/sveltekit-auth-example)](https://nodejs.org)
5
5
  [![Svelte](https://img.shields.io/badge/Svelte-5-orange)](https://svelte.dev)
6
6
 
7
- A complete, production-ready authentication and authorization starter for **Svelte 5** and **SvelteKit 2**. Skip the boilerplate — get secure local accounts, Google OAuth, MFA, email verification, role-based access control, and OWASP-compliant password hashing out of the box.
7
+ A complete, production-ready authentication and authorization starter for **Svelte 5** and **SvelteKit 2**. Skip the boilerplate — get secure local accounts, Google OAuth, MFA, email verification, role-based access control, OWASP-compliant password hashing, and bot protection out of the box.
8
8
 
9
9
  ## Features
10
10
 
@@ -12,10 +12,11 @@ A complete, production-ready authentication and authorization starter for **Svel
12
12
  | ---------------------------------------------- | --------------------------------------- |
13
13
  | ✅ Local accounts (email + password) | ✅ Sign in with Google / Google One Tap |
14
14
  | ✅ Multi-factor authentication (MFA via email) | ✅ Email verification |
15
- | ✅ Forgot password / email reset (SendGrid) | ✅ User profile management |
15
+ | ✅ Forgot password / email reset (Brevo) | ✅ User profile management |
16
16
  | ✅ Session management + timeout | ✅ Rate limiting |
17
17
  | ✅ Role-based access control | ✅ Password complexity enforcement |
18
18
  | ✅ Content Security Policy (CSP) | ✅ OWASP-compliant password hashing |
19
+ | ✅ Cloudflare Turnstile CAPTCHA (bot protection) | |
19
20
 
20
21
  ## Stack
21
22
 
@@ -28,8 +29,9 @@ A complete, production-ready authentication and authorization starter for **Svel
28
29
 
29
30
  - Node.js 24.14.0 or later
30
31
  - PostgreSQL 16 or later
31
- - A [SendGrid](https://sendgrid.com) account (for password reset emails)
32
+ - A [Brevo](https://brevo.com) account (for transactional emails)
32
33
  - A [Google API client ID](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid) (for Sign in with Google)
34
+ - A [Cloudflare Turnstile](https://www.cloudflare.com/products/turnstile/) site key and secret key (for bot protection on auth forms)
33
35
 
34
36
  ## Setting up the project
35
37
 
@@ -49,7 +51,7 @@ bash db_create.sh
49
51
 
50
52
  2. Create a **Google API client ID** per [these instructions](https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid). Make sure you include `http://localhost:3000` and `http://localhost` in the Authorized JavaScript origins, and `http://localhost:3000/auth/google/callback` in the Authorized redirect URIs for your Client ID for Web application. **Do not access the site using http://127.0.0.1:3000** — use `http://localhost:3000` or it will not work.
51
53
 
52
- 3. [Create a free Twilio SendGrid account](https://signup.sendgrid.com) and generate an API Key following [this documentation](https://docs.sendgrid.com/ui/account-and-settings/api-keys) and add a sender as documented [here](https://docs.sendgrid.com/ui/sending-email/senders).
54
+ 3. [Create a free Brevo account](https://app.brevo.com/account/register) and generate an API Key under **SMTP & API** settings. Set `EMAIL` to the sender address verified in your Brevo account.
53
55
 
54
56
  4. Create a **.env** file at the top level of the project with the following values (substituting your own id and PostgreSQL username and password):
55
57
 
@@ -58,9 +60,11 @@ DATABASE_URL=postgres://user:password@localhost:5432/auth
58
60
  DATABASE_SSL=false
59
61
  DOMAIN=http://localhost:3000
60
62
  JWT_SECRET=replace_with_your_own
61
- SENDGRID_KEY=replace_with_your_own
62
- SENDGRID_SENDER=replace_with_your_own
63
+ BREVO_KEY=replace_with_your_own
64
+ EMAIL=replace_with_your_own
63
65
  PUBLIC_GOOGLE_CLIENT_ID=replace_with_your_own
66
+ PUBLIC_TURNSTILE_SITE_KEY=replace_with_your_own
67
+ TURNSTILE_SECRET_KEY=replace_with_your_own
64
68
  ```
65
69
 
66
70
  ## Run locally
@@ -86,9 +90,11 @@ The db_create.sql script adds three users to the database with obvious roles:
86
90
 
87
91
  | Email | Password | Role |
88
92
  | ------------------- | ---------- | ------- |
89
- | admin@example.com | admin123 | admin |
90
- | teacher@example.com | teacher123 | teacher |
91
- | student@example.com | student123 | student |
93
+ | admin@example.com | Admin1234! | admin |
94
+ | teacher@example.com | Teacher1234! | teacher |
95
+ | student@example.com | Student1234! | student |
96
+
97
+ > **MFA note:** Local account logins require a 6-digit code sent to the user's email address. To successfully log in with the seed accounts above, either update their email addresses in the database to your own (`UPDATE users SET email = 'you@yourdomain.com' WHERE email = 'admin@example.com';`), or retrieve the code directly from the `mfa_codes` table after submitting the login form (`SELECT code FROM mfa_codes;`).
92
98
 
93
99
  ## How it works
94
100
 
package/db_create.sql CHANGED
@@ -1,5 +1,6 @@
1
1
  -- Run via db_create.sh or:
2
2
  -- $ psql -d postgres -f db_create.sql && psql -d auth -f db_schema.sql
3
+
3
4
  -- Create role if not already there
4
5
  DO $do$
5
6
  BEGIN
@@ -32,367 +33,3 @@ LIMIT
32
33
  = -1;
33
34
 
34
35
  COMMENT ON DATABASE auth IS 'SvelteKit Auth Example';
35
-
36
- -- Required to generate UUIDs for sessions
37
- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
38
-
39
- -- Required for case-insensitive text (email_address domain)
40
- CREATE EXTENSION IF NOT EXISTS citext;
41
-
42
- -- Using hard-coded roles (often this would be a table)
43
- CREATE TYPE public.roles AS ENUM('student', 'teacher', 'admin');
44
-
45
- ALTER TYPE public.roles OWNER TO auth;
46
-
47
- -- Domains
48
- CREATE DOMAIN public.email_address AS citext CHECK (
49
- length(VALUE) <= 254
50
- AND VALUE = btrim(VALUE)
51
- AND VALUE ~* '^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$'
52
- );
53
-
54
- COMMENT ON DOMAIN public.email_address IS 'RFC-compliant email address (case-insensitive, max 254 chars)';
55
-
56
- CREATE DOMAIN public.persons_name AS text CHECK (length(VALUE) <= 20) NOT NULL;
57
-
58
- COMMENT ON DOMAIN public.persons_name IS 'Person first or last name (max 20 characters)';
59
-
60
- CREATE DOMAIN public.phone_number AS text CHECK (
61
- VALUE IS NULL
62
- OR length(VALUE) <= 50
63
- );
64
-
65
- COMMENT ON DOMAIN public.phone_number IS 'Phone number (max 50 characters)';
66
-
67
- CREATE TABLE IF NOT EXISTS public.users (
68
- id integer NOT NULL GENERATED ALWAYS AS IDENTITY (
69
- INCREMENT 1 START 1 MINVALUE 1 MAXVALUE 2147483647 CACHE 1
70
- ),
71
- role roles NOT NULL DEFAULT 'student'::roles,
72
- email email_address NOT NULL,
73
- password character varying(80) COLLATE pg_catalog."default",
74
- first_name persons_name,
75
- last_name persons_name,
76
- opt_out boolean NOT NULL DEFAULT false,
77
- email_verified boolean NOT NULL DEFAULT false,
78
- phone phone_number,
79
- CONSTRAINT users_pkey PRIMARY KEY (id),
80
- CONSTRAINT users_email_unique UNIQUE (email)
81
- ) TABLESPACE pg_default;
82
-
83
- ALTER TABLE public.users OWNER to auth;
84
-
85
- CREATE INDEX users_first_name_index ON public.users USING btree (
86
- first_name COLLATE pg_catalog."default" ASC NULLS LAST
87
- ) TABLESPACE pg_default;
88
-
89
- CREATE INDEX users_last_name_index ON public.users USING btree (
90
- last_name COLLATE pg_catalog."default" ASC NULLS LAST
91
- ) TABLESPACE pg_default;
92
-
93
- CREATE INDEX users_password ON public.users USING btree (
94
- password COLLATE pg_catalog."default" ASC NULLS LAST
95
- ) TABLESPACE pg_default;
96
-
97
- CREATE TABLE IF NOT EXISTS public.sessions (
98
- id uuid NOT NULL DEFAULT uuid_generate_v4 (),
99
- user_id integer NOT NULL,
100
- expires timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '2 hours'),
101
- CONSTRAINT sessions_pkey PRIMARY KEY (id),
102
- CONSTRAINT sessions_user_fkey FOREIGN KEY (user_id) REFERENCES public.users (id) MATCH SIMPLE ON UPDATE CASCADE ON DELETE CASCADE,
103
- CONSTRAINT sessions_one_per_user UNIQUE (user_id)
104
- ) TABLESPACE pg_default;
105
-
106
- ALTER TABLE public.sessions OWNER to auth;
107
-
108
- CREATE OR REPLACE FUNCTION public.authenticate (input json, OUT response json) RETURNS json LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
109
- DECLARE
110
- input_email text := trim(input->>'email');
111
- input_password text := input->>'password';
112
- v_user users%ROWTYPE;
113
- BEGIN
114
- IF input_email IS NULL OR input_password IS NULL THEN
115
- response := json_build_object('statusCode', 400, 'status', 'Please provide an email address and password to authenticate.', 'user', NULL, 'sessionId', NULL);
116
- RETURN;
117
- END IF;
118
-
119
- SELECT * INTO v_user FROM users
120
- WHERE email = input_email AND password = crypt(input_password, password) LIMIT 1;
121
-
122
- IF NOT FOUND THEN
123
- response := json_build_object('statusCode', 401, 'status', 'Invalid username/password combination.', 'user', NULL, 'sessionId', NULL);
124
- ELSIF NOT v_user.email_verified THEN
125
- response := json_build_object('statusCode', 403, 'status', 'Please verify your email address before logging in.', 'user', NULL, 'sessionId', NULL);
126
- ELSE
127
- response := json_build_object(
128
- 'statusCode', 200,
129
- 'status', 'Login successful.',
130
- '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),
131
- 'sessionId', create_session(v_user.id)
132
- );
133
- END IF;
134
- END;
135
- $BODY$;
136
-
137
- ALTER FUNCTION public.authenticate (json) OWNER TO auth;
138
-
139
- CREATE OR REPLACE FUNCTION public.create_session (input_user_id integer) RETURNS uuid LANGUAGE 'sql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
140
- -- Remove expired sessions (index-friendly cleanup)
141
- DELETE FROM sessions WHERE expires < CURRENT_TIMESTAMP;
142
- -- Remove any existing session(s) for this user
143
- DELETE FROM sessions WHERE user_id = input_user_id;
144
- -- Create the new session
145
- INSERT INTO sessions(user_id) VALUES (input_user_id) RETURNING sessions.id;
146
- $BODY$;
147
-
148
- ALTER FUNCTION public.create_session (integer) OWNER TO auth;
149
-
150
- CREATE OR REPLACE FUNCTION public.get_session (input_session_id uuid) RETURNS json LANGUAGE 'sql' AS $BODY$
151
- SELECT json_build_object(
152
- 'id', sessions.user_id,
153
- 'role', users.role,
154
- 'email', users.email,
155
- 'firstName', users.first_name,
156
- 'lastName', users.last_name,
157
- 'phone', users.phone,
158
- 'optOut', users.opt_out,
159
- 'expires', sessions.expires
160
- ) AS user
161
- FROM sessions
162
- INNER JOIN users ON sessions.user_id = users.id
163
- WHERE sessions.id = input_session_id AND expires > CURRENT_TIMESTAMP LIMIT 1;
164
- $BODY$;
165
-
166
- ALTER FUNCTION public.get_session (uuid) OWNER TO auth;
167
-
168
- -- Like get_session but also bumps the expiry (sliding sessions).
169
- -- Returns NULL if the session is expired or does not exist.
170
- CREATE OR REPLACE FUNCTION public.get_and_update_session (input_session_id uuid) RETURNS json LANGUAGE 'plpgsql' AS $BODY$
171
- DECLARE
172
- result json;
173
- BEGIN
174
- UPDATE sessions
175
- SET expires = CURRENT_TIMESTAMP + INTERVAL '2 hours'
176
- WHERE id = input_session_id AND expires > CURRENT_TIMESTAMP;
177
-
178
- IF NOT FOUND THEN
179
- RETURN NULL;
180
- END IF;
181
-
182
- SELECT json_build_object(
183
- 'id', sessions.user_id,
184
- 'role', users.role,
185
- 'email', users.email,
186
- 'firstName', users.first_name,
187
- 'lastName', users.last_name,
188
- 'phone', users.phone,
189
- 'optOut', users.opt_out,
190
- 'expires', sessions.expires
191
- ) INTO result
192
- FROM sessions
193
- INNER JOIN users ON sessions.user_id = users.id
194
- WHERE sessions.id = input_session_id;
195
-
196
- RETURN result;
197
- END;
198
- $BODY$;
199
-
200
- ALTER FUNCTION public.get_and_update_session (uuid) OWNER TO auth;
201
-
202
- CREATE OR REPLACE FUNCTION public.verify_email_and_create_session (input_id integer) RETURNS uuid LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
203
- DECLARE
204
- session_id uuid;
205
- BEGIN
206
- UPDATE users SET email_verified = true WHERE id = input_id;
207
- SELECT create_session(input_id) INTO session_id;
208
- RETURN session_id;
209
- END;
210
- $BODY$;
211
-
212
- ALTER FUNCTION public.verify_email_and_create_session (integer) OWNER TO auth;
213
-
214
- CREATE OR REPLACE FUNCTION public.register (input json, OUT user_session json) RETURNS json LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
215
- DECLARE
216
- input_email text := trim(input->>'email');
217
- input_first_name text := trim(input->>'firstName');
218
- input_last_name text := trim(input->>'lastName');
219
- input_phone text := trim(input->>'phone');
220
- input_password text := input->>'password';
221
- BEGIN
222
- PERFORM id FROM users WHERE email = input_email;
223
- IF NOT FOUND THEN
224
- INSERT INTO users(role, password, email, first_name, last_name, phone)
225
- VALUES('student', crypt(input_password, gen_salt('bf', 12)), input_email, input_first_name, input_last_name, input_phone)
226
- RETURNING
227
- json_build_object(
228
- 'sessionId', create_session(users.id),
229
- 'user', json_build_object('id', users.id, 'role', 'student', 'email', input_email, 'firstName', input_first_name, 'lastName', input_last_name, 'phone', input_phone, 'optOut', users.opt_out)
230
- ) INTO user_session;
231
- ELSE -- user is registering account that already exists so set sessionId and user to null so client can let them know
232
- SELECT authenticate(input) INTO user_session;
233
- END IF;
234
- END;
235
- $BODY$;
236
-
237
- ALTER FUNCTION public.register (json) OWNER TO auth;
238
-
239
- CREATE OR REPLACE FUNCTION public.start_gmail_user_session (input json, OUT user_session json) RETURNS json LANGUAGE 'plpgsql' COST 100 VOLATILE PARALLEL UNSAFE AS $BODY$
240
- DECLARE
241
- input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
242
- input_first_name varchar(20) := TRIM((input->>'firstName')::varchar);
243
- input_last_name varchar(20) := TRIM((input->>'lastName')::varchar);
244
- BEGIN
245
- -- Google verifies email ownership; mark user as verified on every sign-in
246
- UPDATE users SET email_verified = true WHERE email = input_email;
247
- 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;
248
- IF NOT FOUND THEN
249
- INSERT INTO users(role, email, first_name, last_name, email_verified)
250
- VALUES('student', input_email, input_first_name, input_last_name, true)
251
- RETURNING
252
- json_build_object(
253
- 'id', create_session(users.id),
254
- 'user', json_build_object('id', users.id, 'role', 'student', 'email', input_email, 'firstName', input_first_name, 'lastName', input_last_name, 'phone', null)
255
- ) INTO user_session;
256
- END IF;
257
- END;
258
- $BODY$;
259
-
260
- ALTER FUNCTION public.start_gmail_user_session (json) OWNER TO auth;
261
-
262
- CREATE PROCEDURE public.delete_session (input_id integer) LANGUAGE sql AS $$
263
- DELETE FROM sessions WHERE user_id = input_id;
264
- $$;
265
-
266
- CREATE OR REPLACE PROCEDURE public.delete_user (input_id integer) LANGUAGE sql AS $$
267
- DELETE FROM users WHERE id = input_id;
268
- $$;
269
-
270
- ALTER PROCEDURE public.delete_user (integer) OWNER TO auth;
271
-
272
- CREATE OR REPLACE PROCEDURE public.reset_password (IN input_id integer, IN input_password text) LANGUAGE plpgsql AS $procedure$
273
- BEGIN
274
- UPDATE users SET password = crypt(input_password, gen_salt('bf', 12)) WHERE id = input_id;
275
- END;
276
- $procedure$;
277
-
278
- ALTER PROCEDURE public.reset_password (integer, text) OWNER TO auth;
279
-
280
- CREATE OR REPLACE PROCEDURE public.upsert_user (input json) LANGUAGE plpgsql AS $BODY$
281
- DECLARE
282
- input_id integer := COALESCE((input->>'id')::integer,0);
283
- input_role roles := COALESCE((input->>'role')::roles, 'student');
284
- input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
285
- input_password varchar(80) := COALESCE((input->>'password')::varchar, '');
286
- input_first_name varchar(20) := TRIM((input->>'firstName')::varchar);
287
- input_last_name varchar(20) := TRIM((input->>'lastName')::varchar);
288
- input_phone varchar(23) := TRIM((input->>'phone')::varchar);
289
- BEGIN
290
- IF input_id = 0 THEN
291
- INSERT INTO users (role, email, password, first_name, last_name, phone, email_verified)
292
- VALUES (
293
- input_role, input_email, crypt(input_password, gen_salt('bf', 12)),
294
- input_first_name, input_last_name, input_phone, true);
295
- ELSE
296
- UPDATE users SET
297
- role = input_role,
298
- email = input_email,
299
- email_verified = true,
300
- password = CASE WHEN input_password = ''
301
- THEN password -- leave as is (we are updating fields other than the password)
302
- ELSE crypt(input_password, gen_salt('bf', 12))
303
- END,
304
- first_name = input_first_name,
305
- last_name = input_last_name,
306
- phone = input_phone
307
- WHERE id = input_id;
308
- END IF;
309
- END;
310
- $BODY$;
311
-
312
- ALTER PROCEDURE public.upsert_user (json) OWNER TO auth;
313
-
314
- CREATE OR REPLACE PROCEDURE public.update_user (input_id integer, input json) LANGUAGE plpgsql AS $BODY$
315
- DECLARE
316
- input_email varchar(80) := LOWER(TRIM((input->>'email')::varchar));
317
- input_password varchar(80) := COALESCE((input->>'password')::varchar, '');
318
- input_first_name varchar(20) := TRIM((input->>'firstName')::varchar);
319
- input_last_name varchar(20) := TRIM((input->>'lastName')::varchar);
320
- input_phone varchar(23) := TRIM((input->>'phone')::varchar);
321
- BEGIN
322
- UPDATE users SET
323
- email = input_email,
324
- password = CASE WHEN input_password = ''
325
- THEN password -- leave as is (we are updating fields other than the password)
326
- ELSE crypt(input_password, gen_salt('bf', 12))
327
- END,
328
- first_name = input_first_name,
329
- last_name = input_last_name,
330
- phone = input_phone
331
- WHERE id = input_id;
332
- END;
333
- $BODY$;
334
-
335
- ALTER PROCEDURE public.update_user (integer, json) OWNER TO auth;
336
-
337
- -- MFA codes table (one pending code per user, replaced on each new login attempt)
338
- CREATE TABLE IF NOT EXISTS public.mfa_codes (
339
- user_id integer NOT NULL,
340
- code varchar(6) NOT NULL,
341
- expires timestamptz NOT NULL DEFAULT (CURRENT_TIMESTAMP + INTERVAL '10 minutes'),
342
- CONSTRAINT mfa_codes_pkey PRIMARY KEY (user_id),
343
- CONSTRAINT mfa_codes_user_fkey FOREIGN KEY (user_id) REFERENCES public.users (id) ON DELETE CASCADE
344
- ) TABLESPACE pg_default;
345
-
346
- ALTER TABLE public.mfa_codes OWNER TO auth;
347
-
348
- -- Generate and store a fresh 6-digit MFA code for a user, returning the code
349
- CREATE OR REPLACE FUNCTION public.create_mfa_code (input_user_id integer) RETURNS varchar LANGUAGE plpgsql AS $BODY$
350
- DECLARE
351
- v_code varchar(6) := lpad(floor(random() * 1000000)::integer::text, 6, '0');
352
- BEGIN
353
- DELETE FROM mfa_codes WHERE expires < CURRENT_TIMESTAMP;
354
- INSERT INTO mfa_codes(user_id, code)
355
- VALUES (input_user_id, v_code)
356
- ON CONFLICT (user_id) DO UPDATE
357
- SET code = EXCLUDED.code,
358
- expires = CURRENT_TIMESTAMP + INTERVAL '10 minutes';
359
- RETURN v_code;
360
- END;
361
- $BODY$;
362
-
363
- ALTER FUNCTION public.create_mfa_code (integer) OWNER TO auth;
364
-
365
- -- Verify an MFA code for a given email address.
366
- -- Returns the user_id on success (and deletes the code), or NULL on failure.
367
- CREATE OR REPLACE FUNCTION public.verify_mfa_code (input_email text, input_code text) RETURNS integer LANGUAGE plpgsql AS $BODY$
368
- DECLARE
369
- v_user_id integer;
370
- BEGIN
371
- SELECT mfa_codes.user_id INTO v_user_id
372
- FROM mfa_codes
373
- INNER JOIN users ON mfa_codes.user_id = users.id
374
- WHERE users.email = input_email
375
- AND mfa_codes.code = input_code
376
- AND mfa_codes.expires > CURRENT_TIMESTAMP;
377
-
378
- IF FOUND THEN
379
- DELETE FROM mfa_codes WHERE user_id = v_user_id;
380
- END IF;
381
-
382
- RETURN v_user_id;
383
- END;
384
- $BODY$;
385
-
386
- ALTER FUNCTION public.verify_mfa_code (text, text) OWNER TO auth;
387
-
388
- CALL public.upsert_user (
389
- '{"id":0, "role":"admin", "email":"admin@example.com", "password":"admin123", "firstName":"Jane", "lastName":"Doe", "phone":"412-555-1212"}'::json
390
- );
391
-
392
- CALL public.upsert_user (
393
- '{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"teacher123", "firstName":"John", "lastName":"Doe", "phone":"724-555-1212"}'::json
394
- );
395
-
396
- CALL public.upsert_user (
397
- '{"id":0, "role":"student", "email":"student@example.com", "password":"student123", "firstName":"Justin", "lastName":"Case", "phone":"814-555-1212"}'::json
398
- );
package/db_schema.sql CHANGED
@@ -357,13 +357,13 @@ $BODY$;
357
357
  ALTER FUNCTION public.verify_mfa_code (text, text) OWNER TO auth;
358
358
 
359
359
  CALL public.upsert_user (
360
- '{"id":0, "role":"admin", "email":"admin@example.com", "password":"admin123", "firstName":"Jane", "lastName":"Doe", "phone":"412-555-1212"}'::json
360
+ '{"id":0, "role":"admin", "email":"admin@example.com", "password":"Admin1234!", "firstName":"Jane", "lastName":"Doe", "phone":"412-555-1212"}'::json
361
361
  );
362
362
 
363
363
  CALL public.upsert_user (
364
- '{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"teacher123", "firstName":"John", "lastName":"Doe", "phone":"724-555-1212"}'::json
364
+ '{"id":0, "role":"teacher", "email":"teacher@example.com", "password":"Teacher1234!", "firstName":"John", "lastName":"Doe", "phone":"724-555-1212"}'::json
365
365
  );
366
366
 
367
367
  CALL public.upsert_user (
368
- '{"id":0, "role":"student", "email":"student@example.com", "password":"student123", "firstName":"Justin", "lastName":"Case", "phone":"814-555-1212"}'::json
368
+ '{"id":0, "role":"student", "email":"student@example.com", "password":"Student1234!", "firstName":"Justin", "lastName":"Case", "phone":"814-555-1212"}'::json
369
369
  );
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.6.0",
4
+ "version": "5.8.2",
5
5
  "author": "Nate Stuyvesant",
6
6
  "license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
7
7
  "repository": {
@@ -29,6 +29,7 @@
29
29
  "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
30
30
  "lint": "prettier --check . && eslint .",
31
31
  "format": "prettier --write .",
32
+ "coverage": "vitest run --coverage",
32
33
  "test:e2e": "playwright test",
33
34
  "test:unit": "vitest"
34
35
  },
@@ -48,7 +49,7 @@
48
49
  "@sveltejs/adapter-node": "^5.5.4",
49
50
  "@sveltejs/kit": "^2.54.0",
50
51
  "@sveltejs/mcp": "^0.1.21",
51
- "@sveltejs/vite-plugin-svelte": "^6.2.4",
52
+ "@sveltejs/vite-plugin-svelte": "^7.0.0",
52
53
  "@tailwindcss/vite": "^4.2.1",
53
54
  "@types/bootstrap": "5.2.10",
54
55
  "@types/google.accounts": "^0.0.18",
@@ -63,13 +64,14 @@
63
64
  "prettier-plugin-sql": "^0.19.2",
64
65
  "prettier-plugin-svelte": "^3.5.1",
65
66
  "prettier-plugin-tailwindcss": "^0.7.2",
66
- "svelte": "^5.53.10",
67
+ "svelte": "^5.53.11",
67
68
  "svelte-check": "^4.4.5",
68
69
  "tslib": "^2.8.1",
69
70
  "typescript": "^5.9.3",
70
71
  "typescript-eslint": "^8.57.0",
71
- "vite": "^7.3.1",
72
- "vitest": "^4.0.18",
72
+ "vite": "^8.0.0",
73
+ "vite-plugin-devtools-json": "^1.0.0",
74
+ "vitest": "^4.1.0",
73
75
  "vitest-browser-svelte": "^2.0.2"
74
76
  },
75
77
  "packageManager": "yarn@4.13.0"