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 +51 -31
- package/package.json +1 -1
- package/src/app.d.ts +1 -1
- package/src/hooks.server.ts +40 -6
- package/src/lib/app-state.svelte.ts +19 -0
- package/src/lib/auth-redirect.ts +25 -0
- package/src/lib/google.ts +7 -26
- package/src/lib/server/db.ts +3 -2
- package/src/lib/server/sendgrid.ts +5 -9
- package/src/routes/+layout.svelte +33 -18
- package/src/routes/api/v1/user/+server.ts +16 -0
- package/src/routes/auth/[slug]/+server.ts +5 -56
- package/src/routes/auth/forgot/+server.ts +6 -1
- package/src/routes/auth/google/+server.ts +1 -1
- package/src/routes/auth/login/+server.ts +68 -0
- package/src/routes/auth/logout/+server.ts +19 -0
- package/src/routes/auth/register/+server.ts +64 -0
- package/src/routes/auth/reset/+server.ts +7 -0
- package/src/routes/auth/reset/[token]/+page.svelte +42 -32
- package/src/routes/auth/verify/[token]/+server.ts +48 -0
- package/src/routes/forgot/+page.svelte +32 -24
- package/src/routes/layout.css +46 -0
- package/src/routes/login/+page.server.ts +9 -0
- package/src/routes/login/+page.svelte +28 -35
- package/src/routes/profile/+page.svelte +70 -31
- package/src/routes/register/+page.svelte +139 -128
- package/src/service-worker.ts +22 -4
- package/src/stores.ts +0 -13
- /package/{.env.sample → .env.example} +0 -0
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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.
|
|
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
package/src/hooks.server.ts
CHANGED
|
@@ -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
|
|
7
|
-
|
|
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
|
|
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 {
|
|
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:
|
|
12
|
+
width: btn.offsetWidth || 400
|
|
14
13
|
})
|
|
15
14
|
}
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
export function initializeGoogleAccounts() {
|
|
19
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
}
|
package/src/lib/server/db.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
37
|
+
appState.user = undefined
|
|
31
38
|
}
|
|
32
39
|
})
|
|
33
40
|
|
|
34
41
|
onMount(() => {
|
|
35
42
|
initializeGoogleAccounts()
|
|
36
|
-
if (
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
|
97
|
-
|
|
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
|
|
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">{
|
|
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={() => (
|
|
142
|
+
onclick={() => (appState.toast = { ...appState.toast, isOpen: false })}>×</button>
|
|
128
143
|
</div>
|
|
129
|
-
<div class="tw:px-4 tw:py-3 tw:text-sm">{
|
|
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
|
|
1
|
+
import { error } from '@sveltejs/kit'
|
|
2
2
|
import type { RequestHandler } from './$types'
|
|
3
|
-
import { query } from '$lib/server/db'
|
|
4
3
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
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 = ''
|