sveltekit-auth-example 1.0.18 → 1.0.21

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/.env-sample ADDED
@@ -0,0 +1,8 @@
1
+ DATABASE_URL=postgres://REPLACE_WITH_USER:REPLACE_WITH_PASSWORD@localhost:5432/auth
2
+ DOMAIN=http://localhost:3000
3
+ JWT_SECRET=replace_with_your_own
4
+ SEND_IN_BLUE_URL=https://api.sendinblue.com
5
+ SEND_IN_BLUE_KEY=REPLACE_WITH_YOUR_OWN
6
+ SEND_IN_BLUE_FROM='{ "email":"sender@example.com", "name":"First Last" }'
7
+ SEND_IN_BLUE_ADMINS='{ "email":"admin@example.com", "name":"First Last" }'
8
+ PUBLIC_GOOGLE_CLIENT_ID=REPLACE_WITH_YOUR_OWN
package/CHANGELOG.md CHANGED
@@ -1,10 +1,20 @@
1
1
  # Backlog
2
- * Add username and Avatar icon to menu bar
3
- * [Possible Bug] Getting HTTP 401 on https://play.google.com/log?format=json&hasfast=true&authuser=0 from google-auth-library. As I didn't explicitly request logging, it could be that Safari is preventing Google from further invading our privacy. Will require some investigation. The site works regardless.
4
- * Consider not setting defaultUser in loginSession as it would simplify +layout.svelte.
5
- * Refactor $env/dynamic/private and public
6
2
  * Add password complexity checking on /register and /profile pages (only checks for length currently despite what the pages say)
7
3
 
4
+ # 1.0.21
5
+ * Refactor to use $env/static/private and public, dropping dotenv dependency
6
+ * Remove @types/cookie and bootstrap-icons dependencies
7
+
8
+ # 1.0.20
9
+ * Bump dependencies
10
+ * Add service-worker
11
+ * Add dropdown, avatarm and user's first name to navbar once user is logged in
12
+ * Refactor user session and update typing
13
+
14
+ # 1.0.19
15
+ * Added SvelteKit's cookies implementation in RequestEvent
16
+ * [Bug] Logout then go to http://localhost/admin gives error on auth.ts:39
17
+
8
18
  # 1.0.18
9
19
  * Bump dependencies
10
20
 
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # SvelteKit Authentication and Authorization Example
2
2
 
3
3
  This is an example of how to register, authenticate, and update users and limit their access to
4
- areas of the website by role (admin, teacher, student).
4
+ areas of the website by role (admin, teacher, student). As almost every recent release of SvelteKit introduced breaking changes, this project attempts to
5
+ maintain compatibility with the latest release.
5
6
 
6
7
  It's a Single Page App (SPA) built with SvelteKit and a PostgreSQL database back-end. Code is TypeScript and the website is styled using Bootstrap. PostgreSQL functions handle password hashing and UUID generation for the session ID. Unlike most authentication examples, this SPA does not use callbacks that redirect back to the site (causing the website to be reloaded with a visual flash).
7
8
 
@@ -27,9 +28,9 @@ The website supports two types of authentication:
27
28
  The forgot password functionality uses [**SendInBlue**](https://www.sendinblue.com) to send the email. You would need to have a **SendInBlue** account and set three environmental variables. Email sending is in /src/routes/auth/forgot.ts. This code could easily be replaced by nodemailer or something similar. Note: I have no affliation with **SendInBlue** (just happen to be familiar with their API because of another project).
28
29
 
29
30
  ## Prerequisites
30
- - PostgreSQL 14 or higher
31
- - Node.js 18.7.0 or higher
32
- - npm 8.18.0 or higher
31
+ - PostgreSQL 14.5 or higher
32
+ - Node.js 18.9.0 or higher
33
+ - npm 8.19.1 or higher
33
34
  - Google API client
34
35
  - SendInBlue account (only used for emailing password reset link - the sample can run without it but forgot password will not work)
35
36
 
@@ -61,7 +62,7 @@ SEND_IN_BLUE_URL=https://api.sendinblue.com
61
62
  SEND_IN_BLUE_KEY=replace_with_your_own
62
63
  SEND_IN_BLUE_FROM='{ "email":"jdoe@example.com", "name":"John Doe" }'
63
64
  SEND_IN_BLUE_ADMINS='{ "email":"jdoe@example.com", "name":"John Doe" }'
64
- VITE_GOOGLE_CLIENT_ID=replace_with_your_own
65
+ PUBLIC_GOOGLE_CLIENT_ID=replace_with_your_own
65
66
  ```
66
67
 
67
68
  ## Run locally
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sveltekit-auth-example",
3
3
  "description": "SvelteKit Authentication Example",
4
- "version": "1.0.18",
4
+ "version": "1.0.21",
5
5
  "private": false,
6
6
  "author": "Nate Stuyvesant",
7
7
  "license": "https://github.com/nstuyvesant/sveltekit-auth-example/blob/master/LICENSE",
@@ -33,13 +33,11 @@
33
33
  "format": "prettier --write ."
34
34
  },
35
35
  "engines": {
36
- "node": "~18.8.0",
36
+ "node": "~18.9.0",
37
37
  "npm": "^8.19.1"
38
38
  },
39
39
  "type": "module",
40
40
  "dependencies": {
41
- "cookie": "^0.5.0",
42
- "dotenv": "^16.0.2",
43
41
  "google-auth-library": "^8.5.1",
44
42
  "jsonwebtoken": "^8.5.1",
45
43
  "pg": "^8.8.0"
@@ -47,26 +45,24 @@
47
45
  "devDependencies": {
48
46
  "@sveltejs/adapter-node": "latest",
49
47
  "@sveltejs/kit": "latest",
50
- "@types/bootstrap": "5.2.3",
51
- "@types/cookie": "^0.5.1",
48
+ "@types/bootstrap": "5.2.4",
52
49
  "@types/google.accounts": "0.0.2",
53
50
  "@types/jsonwebtoken": "^8.5.9",
54
51
  "@types/pg": "^8.6.5",
55
52
  "@typescript-eslint/eslint-plugin": "^5.36.1",
56
53
  "@typescript-eslint/parser": "^5.36.1",
57
- "bootstrap": "^5.2.0",
58
- "bootstrap-icons": "^1.9.1",
54
+ "bootstrap": "^5.2.1",
59
55
  "eslint": "^8.23.0",
60
56
  "eslint-config-prettier": "^8.5.0",
61
57
  "eslint-plugin-svelte3": "^4.0.0",
62
58
  "prettier": "^2.7.1",
63
59
  "prettier-plugin-svelte": "^2.7.0",
64
- "sass": "^1.54.8",
65
- "svelte": "^3.50.0",
60
+ "sass": "^1.54.9",
61
+ "svelte": "^3.50.1",
66
62
  "svelte-check": "^2.9.0",
67
63
  "svelte-preprocess": "^4.10.7",
68
64
  "tslib": "^2.4.0",
69
- "typescript": "^4.7.4",
65
+ "typescript": "^4.8.3",
70
66
  "vite": "^3.1.0"
71
67
  }
72
68
  }
package/src/app.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
1
3
  /// <reference types="bootstrap" />
2
4
  /// <reference types="google.accounts" />
3
5
 
@@ -11,19 +13,29 @@ declare namespace App {
11
13
 
12
14
  // interface Platform {}
13
15
 
14
- // interface PrivateEnv {} // $env/dynamic/private
15
-
16
- // interface PublicEnv {} // $env/dynamic/public
16
+ interface PrivateEnv { // $env/dynamic/private
17
+ DATABASE_URL: string
18
+ DOMAIN: string
19
+ JWT_SECRET: string
20
+ SEND_IN_BLUE_URL: string
21
+ SEND_IN_BLUE_KEY: string
22
+ SEND_IN_BLUE_FROM: string
23
+ SEND_IN_BLUE_ADMINS: string
24
+ }
25
+
26
+ interface PublicEnv { // $env/dynamic/public
27
+ PUBLIC_GOOGLE_CLIENT_ID: string
28
+ }
17
29
  }
18
30
 
19
- type AuthenticationResult = {
31
+ interface AuthenticationResult {
20
32
  statusCode: number
21
33
  status: string
22
34
  user: User
23
35
  sessionId: string
24
36
  }
25
37
 
26
- type Credentials = {
38
+ interface Credentials {
27
39
  email: string
28
40
  password: string
29
41
  }
@@ -41,16 +53,12 @@ interface GoogleCredentialResponse {
41
53
  | 'btn_confirm_add_session'
42
54
  }
43
55
 
44
- interface ImportMetaEnv {
45
- VITE_GOOGLE_CLIENT_ID: string
46
- }
47
-
48
- type MessageAddressee = {
56
+ interface MessageAddressee {
49
57
  email: string
50
58
  name?: string
51
59
  }
52
60
 
53
- type Message = {
61
+ interface Message {
54
62
  sender?: MessageAddressee
55
63
  to?: MessageAddressee[]
56
64
  subject: string
@@ -81,7 +89,7 @@ interface SendInBlueRequest extends RequestInit {
81
89
  }
82
90
  }
83
91
 
84
- type User = {
92
+ interface UserProperties {
85
93
  id: number
86
94
  role: 'student' | 'teacher' | 'admin'
87
95
  password?: string
@@ -91,13 +99,13 @@ type User = {
91
99
  phone?: string
92
100
  }
93
101
 
94
- type UserSession = {
102
+ type User = UserProperties | undefined
103
+
104
+ interface UserSession {
95
105
  id: string,
96
106
  user: User
97
107
  }
98
108
 
99
- /* eslint-disable @typescript-eslint/no-explicit-any */
100
-
101
109
  interface Window {
102
110
  google?: any
103
111
  grecaptcha: any
@@ -1,9 +1,8 @@
1
- import * as cookie from 'cookie'
2
1
  import type { Handle, RequestEvent } from '@sveltejs/kit'
3
2
  import { query } from './routes/_db'
4
3
 
5
4
  // Attach authorization to each server request (role may have changed)
6
- async function attachUserToRequest(sessionId: string, event: RequestEvent) {
5
+ async function attachUserToRequestEvent(sessionId: string, event: RequestEvent) {
7
6
  const sql = `
8
7
  SELECT * FROM get_session($1);`
9
8
  const { rows } = await query(sql, [sessionId])
@@ -12,24 +11,20 @@ async function attachUserToRequest(sessionId: string, event: RequestEvent) {
12
11
  }
13
12
  }
14
13
 
15
- function deleteCookieIfNoUser(event: RequestEvent, response: Response) {
16
- if (!event.locals.user) {
17
- response.headers.set('Set-Cookie', `session=; Path=/; HttpOnly; SameSite=Lax; Expires=${new Date().toUTCString()}`)
18
- }
19
- }
20
-
21
14
  // Invoked for each endpoint called and initially for SSR router
22
15
  export const handle: Handle = async ({ event, resolve }) => {
16
+ const { cookies } = event
17
+ const sessionId = cookies.get('session')
23
18
 
24
19
  // before endpoint or page is called
25
- const cookies = cookie.parse(event.request.headers.get('Cookie') || '')
26
- if (cookies.session) {
27
- await attachUserToRequest(cookies.session, event)
20
+ if (sessionId) {
21
+ await attachUserToRequestEvent(sessionId, event)
28
22
  }
29
23
 
30
24
  const response = await resolve(event)
31
25
 
32
26
  // after endpoint or page is called
33
- deleteCookieIfNoUser(event, response)
27
+ if (!event.locals.user) cookies.delete('session')
28
+
34
29
  return response
35
30
  }
package/src/lib/auth.ts CHANGED
@@ -1,130 +1,130 @@
1
- import type { Page } from '@sveltejs/kit'
1
+ /* eslint-disable @typescript-eslint/no-explicit-any */
2
+
3
+ import type { Page } from '@sveltejs/kit'
2
4
  import type { Readable, Writable } from 'svelte/store'
3
- import { config } from '$lib/config'
4
- import { defaultUser } from '../stores'
5
+ import { PUBLIC_GOOGLE_CLIENT_ID } from '$env/static/public'
5
6
 
6
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
- export default function useAuth(page: Readable<Page>, loginSession: Writable<User>, goto: (url: string | URL, opts?: { replaceState?: boolean; noscroll?: boolean; keepfocus?: boolean; state?: any; }) => Promise<any>) {
8
- let user: User
9
- loginSession.subscribe(value => {
10
- user = value
11
- })
7
+ export default function useAuth(
8
+ page: Readable<Page>,
9
+ loginSession: Writable<User>,
10
+ goto: (
11
+ url: string | URL,
12
+ opts?: { replaceState?: boolean; noscroll?: boolean; keepfocus?: boolean; state?: any }
13
+ ) => Promise<any>
14
+ ) {
15
+ let user: User
16
+ loginSession.subscribe((value) => {
17
+ user = value
18
+ })
12
19
 
13
- let referrer: string | null
14
- page.subscribe(value => {
20
+ let referrer: string | null
21
+ page.subscribe((value) => {
15
22
  referrer = value.url.searchParams.get('referrer')
16
23
  })
17
24
 
18
- async function googleCallback(response: GoogleCredentialResponse) {
19
- const res = await fetch('/auth/google', {
20
- method: 'POST',
21
- headers: {
22
- 'Content-Type': 'application/json'
23
- },
24
- body: JSON.stringify({ token: response.credential })
25
- })
26
-
27
- if (res.ok) {
28
- const fromEndpoint = await res.json()
29
- loginSession.set(fromEndpoint.user) // update loginSession store
30
- const { role } = fromEndpoint.user
31
- if (referrer) return goto(referrer)
32
- if (role === 'teacher') return goto('/teachers')
33
- if (role === 'admin') return goto('/admin')
34
- if (location.pathname === '/login') goto('/') // logged in so go home
35
- }
36
- }
25
+ async function googleCallback(response: GoogleCredentialResponse) {
26
+ const res = await fetch('/auth/google', {
27
+ method: 'POST',
28
+ headers: {
29
+ 'Content-Type': 'application/json'
30
+ },
31
+ body: JSON.stringify({ token: response.credential })
32
+ })
37
33
 
38
- function initializeSignInWithGoogle(htmlId?: string) {
39
- const { id } = window.google.accounts // assumes <script src="https://accounts.google.com/gsi/client" async defer></script> is in app.html
40
- id.initialize({ client_id: config.googleClientId, callback: googleCallback })
34
+ if (res.ok) {
35
+ const fromEndpoint = await res.json()
36
+ loginSession.set(fromEndpoint.user) // update loginSession store
37
+ const { role } = fromEndpoint.user
38
+ if (referrer) return goto(referrer)
39
+ if (role === 'teacher') return goto('/teachers')
40
+ if (role === 'admin') return goto('/admin')
41
+ if (location.pathname === '/login') goto('/') // logged in so go home
42
+ }
43
+ }
41
44
 
42
- if (htmlId) { // render button instead of prompt
43
- return id.renderButton(
44
- document.getElementById(htmlId), {
45
- theme: 'filled_blue',
46
- size: 'large',
47
- width: '367'
48
- }
49
- )
50
- }
45
+ function initializeSignInWithGoogle(htmlId?: string) {
46
+ const { id } = window.google.accounts // assumes <script src="https://accounts.google.com/gsi/client" async defer></script> is in app.html
47
+ id.initialize({ client_id: PUBLIC_GOOGLE_CLIENT_ID, callback: googleCallback })
51
48
 
52
- if (!user) id.prompt()
53
- }
49
+ if (htmlId) {
50
+ // render button instead of prompt
51
+ return id.renderButton(document.getElementById(htmlId), {
52
+ theme: 'filled_blue',
53
+ size: 'large',
54
+ width: '367'
55
+ })
56
+ }
54
57
 
55
- function setloginSession(user: User | null) {
56
- loginSession.update(s => ({
57
- ...s,
58
- user
59
- }))
60
- }
58
+ if (!user) id.prompt()
59
+ }
61
60
 
62
- async function registerLocal(user: User) {
63
- try {
64
- const res = await fetch('/auth/register', {
65
- method: 'POST',
66
- body: JSON.stringify(user), // server needs to ignore user.role and always set it to 'student'
67
- headers: {
68
- 'Content-Type': 'application/json'
69
- }
70
- })
71
- if (!res.ok) {
72
- if (res.status == 401) // user already existed and passwords didn't match (otherwise, we login the user)
73
- throw new Error('Sorry, that username is already in use.')
74
- throw new Error(res.statusText) // should only occur if there's a database error
75
- }
61
+ async function registerLocal(user: User) {
62
+ try {
63
+ const res = await fetch('/auth/register', {
64
+ method: 'POST',
65
+ body: JSON.stringify(user), // server needs to ignore user.role and always set it to 'student'
66
+ headers: {
67
+ 'Content-Type': 'application/json'
68
+ }
69
+ })
70
+ if (!res.ok) {
71
+ if (res.status == 401)
72
+ // user already existed and passwords didn't match (otherwise, we login the user)
73
+ throw new Error('Sorry, that username is already in use.')
74
+ throw new Error(res.statusText) // should only occur if there's a database error
75
+ }
76
76
 
77
- // res.ok
78
- const fromEndpoint = await res.json()
79
- loginSession.set(fromEndpoint.user) // update store so user is logged in
80
- goto('/')
81
- } catch (err) {
82
- console.error('Register error', err)
83
- if (err instanceof Error) {
84
- throw new Error(err.message)
85
- }
86
- }
87
- }
77
+ // res.ok
78
+ const fromEndpoint = await res.json()
79
+ loginSession.set(fromEndpoint.user) // update store so user is logged in
80
+ goto('/')
81
+ } catch (err) {
82
+ console.error('Register error', err)
83
+ if (err instanceof Error) {
84
+ throw new Error(err.message)
85
+ }
86
+ }
87
+ }
88
88
 
89
- async function loginLocal(credentials: Credentials) {
90
- try {
91
- const res = await fetch('/auth/login', {
92
- method: 'POST',
93
- body: JSON.stringify(credentials),
94
- headers: {
95
- 'Content-Type': 'application/json'
96
- }
97
- })
98
- const fromEndpoint = await res.json()
99
- if (res.ok) {
100
- setloginSession(fromEndpoint.user)
101
- const { role } = fromEndpoint.user
102
- if (referrer) return goto(referrer)
103
- if (role === 'teacher') return goto('/teachers')
104
- if (role === 'admin') return goto('/admin')
105
- return goto('/')
106
- } else {
107
- throw new Error(fromEndpoint.message)
108
- }
109
- } catch (err) {
110
- if (err instanceof Error) {
111
- console.error('Login error', err)
112
- throw new Error(err.message)
113
- }
114
- }
115
- }
89
+ async function loginLocal(credentials: Credentials) {
90
+ try {
91
+ const res = await fetch('/auth/login', {
92
+ method: 'POST',
93
+ body: JSON.stringify(credentials),
94
+ headers: {
95
+ 'Content-Type': 'application/json'
96
+ }
97
+ })
98
+ const fromEndpoint = await res.json()
99
+ if (res.ok) {
100
+ loginSession.set(fromEndpoint.user)
101
+ const { role } = fromEndpoint.user
102
+ if (referrer) return goto(referrer)
103
+ if (role === 'teacher') return goto('/teachers')
104
+ if (role === 'admin') return goto('/admin')
105
+ return goto('/')
106
+ } else {
107
+ throw new Error(fromEndpoint.message)
108
+ }
109
+ } catch (err) {
110
+ if (err instanceof Error) {
111
+ console.error('Login error', err)
112
+ throw new Error(err.message)
113
+ }
114
+ }
115
+ }
116
116
 
117
- async function logout() {
118
- // Request server delete httpOnly cookie called loginSession
119
- const url = '/auth/logout'
120
- const res = await fetch(url, {
121
- method: 'POST'
122
- })
123
- if (res.ok) {
124
- loginSession.set(defaultUser) // delete loginSession.user from
125
- goto('/login')
126
- } else console.error(`Logout not successful: ${res.statusText} (${res.status})`)
127
- }
117
+ async function logout() {
118
+ // Request server delete httpOnly cookie called loginSession
119
+ const url = '/auth/logout'
120
+ const res = await fetch(url, {
121
+ method: 'POST'
122
+ })
123
+ if (res.ok) {
124
+ loginSession.set(undefined) // delete loginSession.user from
125
+ goto('/login')
126
+ } else console.error(`Logout not successful: ${res.statusText} (${res.status})`)
127
+ }
128
128
 
129
- return { initializeSignInWithGoogle, registerLocal, loginLocal, logout }
130
- }
129
+ return { initializeSignInWithGoogle, registerLocal, loginLocal, logout }
130
+ }
@@ -20,6 +20,7 @@
20
20
 
21
21
  onMount(async () => {
22
22
  await import('bootstrap/js/dist/collapse') // lots of ways to load Bootstrap but prefer this approach to avoid SSR issues
23
+ await import('bootstrap/js/dist/dropdown')
23
24
  Toast = (await import('bootstrap/js/dist/toast')).default
24
25
  initializeSignInWithGoogle()
25
26
  })
@@ -37,20 +38,50 @@
37
38
 
38
39
  <nav class="navbar navbar-expand-lg navbar-light bg-light">
39
40
  <div class="container">
40
- <a class="navbar-brand" href="/">Auth</a>
41
+ <a class="navbar-brand" href="/">SvelteKit-Auth-Example</a>
41
42
  <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarMain" aria-controls="navbarMain" aria-expanded="false" aria-label="Toggle navigation">
42
43
  <span class="navbar-toggler-icon"></span>
43
44
  </button>
44
45
  <div class="collapse navbar-collapse" id="navbarMain">
45
- <div class="navbar-nav">
46
- <a class="nav-link active" aria-current="page" href="/">Home</a>
47
- <a class="nav-link" href="/info">Info</a>
48
- <a class="nav-link" class:d-none={!$loginSession || $loginSession.id === 0} href="/profile">Profile</a>
49
- <a class="nav-link" class:d-none={!$loginSession || $loginSession?.role !== 'admin'} href="/admin">Admin</a>
50
- <a class="nav-link" class:d-none={!$loginSession || $loginSession?.role === 'student'} href="/teachers">Teachers</a>
51
- <a class="nav-link" class:d-none={!!$loginSession} href="/login">Login</a>
52
- <a on:click|preventDefault={logout} class="nav-link" class:d-none={!$loginSession || $loginSession.id === 0} href={'#'}>Logout</a>
53
- </div>
46
+
47
+ <ul class="navbar-nav me-5">
48
+ <li class="nav-item"><a class="nav-link active" aria-current="page" href="/">Home</a></li>
49
+ <li class="nav-item"><a class="nav-link" href="/info">Info</a></li>
50
+
51
+ {#if $loginSession}
52
+ {#if $loginSession.role == 'admin'}
53
+ <li class="nav-item"><a class="nav-link" href="/admin">Admin</a></li>
54
+ {/if}
55
+ {#if $loginSession.role != 'student'}
56
+ <li class="nav-item"><a class="nav-link" href="/teachers">Teachers</a></li>
57
+ {/if}
58
+ {/if}
59
+ </ul>
60
+ <ul class="navbar-nav">
61
+ {#if $loginSession}
62
+ <li class="nav-item dropdown">
63
+ <a class="nav-link dropdown-toggle" href={'#'} role="button" data-bs-toggle="dropdown" aria-expanded="false">
64
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="avatar" viewBox="0 0 16 16">
65
+ <path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
66
+ <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"/>
67
+ </svg>
68
+ {$loginSession.firstName}
69
+ </a>
70
+ <ul class="dropdown-menu">
71
+ <li>
72
+ <a class="dropdown-item" href="/profile">Profile</a>
73
+ </li>
74
+ <li>
75
+ <a on:click|preventDefault={logout} class="dropdown-item" class:d-none={!$loginSession || $loginSession.id === 0} href={'#'}>Logout</a>
76
+ </li>
77
+ </ul>
78
+ </li>
79
+ {:else}
80
+ <li class="nav-item">
81
+ <a class="nav-link" href="/login">Login</a>
82
+ </li>
83
+ {/if}
84
+ </ul>
54
85
  </div>
55
86
  </div>
56
87
  </nav>
@@ -80,4 +111,9 @@
80
111
  .toast {
81
112
  z-index: 9999;
82
113
  }
114
+
115
+ .avatar {
116
+ position: relative;
117
+ top: -1.5px;
118
+ }
83
119
  </style>
package/src/routes/_db.ts CHANGED
@@ -1,13 +1,11 @@
1
1
  /* eslint-disable @typescript-eslint/no-explicit-any */
2
- import dotenv from 'dotenv'
3
2
  import type { QueryResult} from 'pg'
4
3
  import pg from 'pg'
5
-
6
- dotenv.config()
4
+ import { DATABASE_URL } from '$env/static/private'
7
5
 
8
6
  const pool = new pg.Pool({
9
7
  max: 10, // default
10
- connectionString: process.env.DATABASE_URL,
8
+ connectionString: DATABASE_URL,
11
9
  ssl: {
12
10
  rejectUnauthorized: false
13
11
  }
@@ -1,11 +1,7 @@
1
- import dotenv from 'dotenv'
1
+ import { SEND_IN_BLUE_KEY, SEND_IN_BLUE_URL, SEND_IN_BLUE_FROM, SEND_IN_BLUE_ADMINS } from '$env/static/private'
2
2
 
3
- dotenv.config()
4
-
5
- const SEND_IN_BLUE_KEY = process.env.SEND_IN_BLUE_KEY
6
- const SEND_IN_BLUE_URL = process.env.SEND_IN_BLUE_URL
7
- const SEND_IN_BLUE_FROM = <MessageAddressee> JSON.parse(process.env.SEND_IN_BLUE_FROM || '')
8
- const SEND_IN_BLUE_ADMINS = <MessageAddressee> JSON.parse(process.env.SEND_IN_BLUE_ADMINS || '')
3
+ const sender = <MessageAddressee> JSON.parse(SEND_IN_BLUE_FROM || '')
4
+ const to = <MessageAddressee> JSON.parse(SEND_IN_BLUE_ADMINS || '')
9
5
 
10
6
  // POST or PUT submission to SendInBlue
11
7
  const submit = async (method: string, url: string, data: Partial<SendInBlueContact> | SendInBlueMessage) => {
@@ -23,7 +19,4 @@ const submit = async (method: string, url: string, data: Partial<SendInBlueConta
23
19
  }
24
20
  }
25
21
 
26
- const sender = SEND_IN_BLUE_FROM
27
- const to = SEND_IN_BLUE_ADMINS
28
-
29
22
  export const sendMessage = async (message: Message) => submit('POST', '/v3/smtp/email', { sender, to: [to], ...message })
@@ -4,8 +4,8 @@ import type { PageServerLoad } from './$types'
4
4
  export const load: PageServerLoad = async ({locals})=> {
5
5
  const { user } = locals
6
6
  const authorized = ['admin']
7
- if (user && !authorized.includes(user.role)) {
8
- throw redirect(302, '/login?referrer=/admin');
7
+ if (!user || !authorized.includes(user.role)) {
8
+ throw redirect(302, '/login?referrer=/admin')
9
9
  }
10
10
 
11
11
  return {
@@ -16,11 +16,12 @@ export const POST: RequestHandler = async (event) => {
16
16
  sql = `CALL delete_session($1);`
17
17
  result = await query(sql, [event.locals.user.id])
18
18
  }
19
- return new Response(JSON.stringify({ message: 'Logout successful.' }), {
19
+ return json({ message: 'Logout successful.' }, {
20
20
  headers: {
21
21
  'Set-Cookie': `session=; Path=/; SameSite=Lax; HttpOnly; Expires=${new Date().toUTCString()}`
22
22
  }
23
23
  })
24
+
24
25
  case 'login':
25
26
  sql = `SELECT authenticate($1) AS "authenticationResult";`
26
27
  break
@@ -1,14 +1,10 @@
1
1
  import type { RequestHandler } from './$types'
2
+ import { JWT_SECRET, DOMAIN } from '$env/static/private'
2
3
  import type { Secret } from 'jsonwebtoken'
3
4
  import jwt from 'jsonwebtoken'
4
- import dotenv from 'dotenv'
5
5
  import { query } from '../../_db'
6
6
  import { sendMessage } from '../../_send-in-blue'
7
7
 
8
- dotenv.config()
9
- const DOMAIN = process.env.DOMAIN
10
- const JWT_SECRET = process.env.JWT_SECRET
11
-
12
8
  export const POST: RequestHandler = async event => {
13
9
  const body = await event.request.json()
14
10
  const sql = `SELECT id as "userId" FROM users WHERE email = $1 LIMIT 1;`
@@ -1,17 +1,16 @@
1
- import { error } from '@sveltejs/kit'
1
+ import { error, json } from '@sveltejs/kit'
2
2
  import type { RequestHandler } from './$types'
3
3
  import { OAuth2Client } from 'google-auth-library'
4
- import { query } from '../../_db';
5
- import { config } from '$lib/config'
4
+ import { query } from '../../_db'
5
+ import { PUBLIC_GOOGLE_CLIENT_ID } from '$env/static/public'
6
6
 
7
7
  // Verify JWT per https://developers.google.com/identity/gsi/web/guides/verify-google-id-token
8
8
  async function getGoogleUserFromJWT(token: string): Promise<Partial<User>> {
9
9
  try {
10
- const clientId = config.googleClientId
11
- const client = new OAuth2Client(clientId)
10
+ const client = new OAuth2Client(PUBLIC_GOOGLE_CLIENT_ID)
12
11
  const ticket = await client.verifyIdToken({
13
12
  idToken: token,
14
- audience: clientId
13
+ audience: PUBLIC_GOOGLE_CLIENT_ID
15
14
  });
16
15
  const payload = ticket.getPayload()
17
16
  if (!payload) throw error(500, 'Google authentication did not get the expected payload')
@@ -51,15 +50,14 @@ export const POST: RequestHandler = async event => {
51
50
  // Prevent hooks.ts's handler() from deleting cookie thinking no one has authenticated
52
51
  event.locals.user = userSession.user
53
52
 
54
- return new Response(JSON.stringify({
53
+ return json({
55
54
  message: 'Successful Google Sign-In.',
56
55
  user: userSession.user
57
- }), {
56
+ }, {
58
57
  headers: {
59
58
  'Set-Cookie': `session=${userSession.id}; Path=/; SameSite=Lax; HttpOnly;`}
60
- }
61
- )
62
-
59
+ })
60
+
63
61
  } catch (err) {
64
62
  let message = ''
65
63
  if (err instanceof Error) message = err.message
@@ -1,36 +1,35 @@
1
- import { json as json$1 } from '@sveltejs/kit';
2
- import dotenv from 'dotenv'
1
+ import { json } from '@sveltejs/kit'
3
2
  import type { RequestHandler } from './$types'
4
- import type { JwtPayload } from 'jsonwebtoken'
3
+ import type { JwtPayload } from 'jsonwebtoken'
5
4
  import jwt from 'jsonwebtoken'
6
5
  import { query } from '../../_db'
6
+ import { JWT_SECRET } from '$env/static/private'
7
7
 
8
- dotenv.config()
8
+ export const PUT: RequestHandler = async (event) => {
9
+ const body = await event.request.json()
10
+ const { token, password } = body
9
11
 
10
- const JWT_SECRET = <jwt.Secret> process.env.JWT_SECRET
12
+ // Check the validity of the token and extract userId
13
+ try {
14
+ const decoded = <JwtPayload> jwt.verify(token, <jwt.Secret> JWT_SECRET)
15
+ const userId = decoded.subject
11
16
 
12
- export const PUT: RequestHandler = async event => {
13
- const body = await event.request.json()
14
- const { token, password } = body
17
+ // Update the database with the new password
18
+ const sql = `CALL reset_password($1, $2);`
19
+ await query(sql, [userId, password])
15
20
 
16
- // Check the validity of the token and extract userId
17
- try {
18
- const decoded = <JwtPayload> jwt.verify(token, JWT_SECRET)
19
- const userId = decoded.subject
20
-
21
- // Update the database with the new password
22
- const sql = `CALL reset_password($1, $2);`
23
- await query(sql, [userId, password])
24
-
25
- return json$1({
26
- message: 'Password successfully reset.'
27
- })
28
- } catch (error) {
29
- // Technically, I should check error.message to make sure it's not a DB issue
30
- return json$1({
31
- message: 'Password reset token expired.'
32
- }, {
33
- status: 403
34
- })
35
- }
21
+ return json({
22
+ message: 'Password successfully reset.'
23
+ })
24
+ } catch (error) {
25
+ // Technically, I should check error.message to make sure it's not a DB issue
26
+ return json(
27
+ {
28
+ message: 'Password reset token expired.'
29
+ },
30
+ {
31
+ status: 403
32
+ }
33
+ )
34
+ }
36
35
  }
@@ -5,7 +5,7 @@ export const load: PageServerLoad = async ({ locals }) => {
5
5
  const { user } = locals // populated by /src/hooks.ts
6
6
 
7
7
  const authorized = ['admin', 'teacher', 'student'] // must be logged-in
8
- if (user && !authorized.includes(user.role)) {
8
+ if (!user || !authorized.includes(user.role)) {
9
9
  throw redirect(302, '/login?referrer=/profile')
10
10
  }
11
11
 
@@ -54,6 +54,7 @@
54
54
 
55
55
 
56
56
  const passwordMatch = () => {
57
+ if (!user) return false // placate TypeScript
57
58
  if (!user.password) user.password = ''
58
59
  return user.password == confirmPassword.value
59
60
  }
@@ -68,47 +69,49 @@
68
69
  <div class="card-body">
69
70
  <h4><strong>Register</strong></h4>
70
71
  <p>Welcome to our community.</p>
71
- <form id="register" autocomplete="on" novalidate class="mt-3">
72
- <div class="mb-3">
73
- <div id="googleButton"></div>
74
- </div>
75
- <div class="mb-3">
76
- <label class="form-label" for="email">Email</label>
77
- <input bind:this={focusedField} type="email" class="form-control" bind:value={user.email} required placeholder="Email" id="email" autocomplete="email"/>
78
- <div class="invalid-feedback">Email address required</div>
79
- </div>
80
- <div class="mb-3">
81
- <label class="form-label" for="password">Password</label>
82
- <input type="password" id="password" class="form-control" bind:value={user.password} required minlength="8" maxlength="80" placeholder="Password" autocomplete="new-password"/>
83
- <div class="invalid-feedback">Password with 8 chars or more required</div>
84
- <div class="form-text">Password minimum length 8, must have one capital letter, 1 number, and one unique character.</div>
85
- </div>
86
- <div class="mb-3">
87
- <label class="form-label" for="password">Confirm password</label>
88
- <input type="password" id="password" class="form-control" bind:this={confirmPassword} required minlength="8" maxlength="80" placeholder="Password (again)" autocomplete="new-password"/>
89
- <div class="form-text">Password minimum length 8, must have one capital letter, 1 number, and one unique character.</div>
90
- </div>
91
- <div class="mb-3">
92
- <label class="form-label" for="firstName">First name</label>
93
- <input bind:value={user.firstName} class="form-control" id="firstName" placeholder="First name" required autocomplete="given-name"/>
94
- <div class="invalid-feedback">First name required</div>
95
- </div>
96
- <div class="mb-3">
97
- <label class="form-label" for="lastName">Last name</label>
98
- <input bind:value={user.lastName} class="form-control" id="lastName" placeholder="Last name" required autocomplete="family-name"/>
99
- <div class="invalid-feedback">Last name required</div>
100
- </div>
101
- <div class="mb-3">
102
- <label class="form-label" for="phone">Phone</label>
103
- <input type="tel" bind:value={user.phone} id="phone" class="form-control" placeholder="Phone" autocomplete="tel-local"/>
104
- </div>
105
-
106
- {#if message}
107
- <p class="text-danger">{message}</p>
108
- {/if}
109
-
110
- <button type="button" on:click={register} class="btn btn-primary btn-lg">Register</button>
111
- </form>
72
+ {#if user}
73
+ <form id="register" autocomplete="on" novalidate class="mt-3">
74
+ <div class="mb-3">
75
+ <div id="googleButton"></div>
76
+ </div>
77
+ <div class="mb-3">
78
+ <label class="form-label" for="email">Email</label>
79
+ <input bind:this={focusedField} type="email" class="form-control" bind:value={user.email} required placeholder="Email" id="email" autocomplete="email"/>
80
+ <div class="invalid-feedback">Email address required</div>
81
+ </div>
82
+ <div class="mb-3">
83
+ <label class="form-label" for="password">Password</label>
84
+ <input type="password" id="password" class="form-control" bind:value={user.password} required minlength="8" maxlength="80" placeholder="Password" autocomplete="new-password"/>
85
+ <div class="invalid-feedback">Password with 8 chars or more required</div>
86
+ <div class="form-text">Password minimum length 8, must have one capital letter, 1 number, and one unique character.</div>
87
+ </div>
88
+ <div class="mb-3">
89
+ <label class="form-label" for="password">Confirm password</label>
90
+ <input type="password" id="password" class="form-control" bind:this={confirmPassword} required minlength="8" maxlength="80" placeholder="Password (again)" autocomplete="new-password"/>
91
+ <div class="form-text">Password minimum length 8, must have one capital letter, 1 number, and one unique character.</div>
92
+ </div>
93
+ <div class="mb-3">
94
+ <label class="form-label" for="firstName">First name</label>
95
+ <input bind:value={user.firstName} class="form-control" id="firstName" placeholder="First name" required autocomplete="given-name"/>
96
+ <div class="invalid-feedback">First name required</div>
97
+ </div>
98
+ <div class="mb-3">
99
+ <label class="form-label" for="lastName">Last name</label>
100
+ <input bind:value={user.lastName} class="form-control" id="lastName" placeholder="Last name" required autocomplete="family-name"/>
101
+ <div class="invalid-feedback">Last name required</div>
102
+ </div>
103
+ <div class="mb-3">
104
+ <label class="form-label" for="phone">Phone</label>
105
+ <input type="tel" bind:value={user.phone} id="phone" class="form-control" placeholder="Phone" autocomplete="tel-local"/>
106
+ </div>
107
+
108
+ {#if message}
109
+ <p class="text-danger">{message}</p>
110
+ {/if}
111
+
112
+ <button type="button" on:click={register} class="btn btn-primary btn-lg">Register</button>
113
+ </form>
114
+ {/if}
112
115
  </div>
113
116
  </div>
114
117
  </div>
@@ -2,8 +2,9 @@ import { redirect } from '@sveltejs/kit'
2
2
  import type { PageServerLoad } from './$types'
3
3
 
4
4
  export const load: PageServerLoad = async ({locals}) => {
5
+ const { user } = locals
5
6
  const authorized = ['admin', 'teacher']
6
- if (!locals.user || !authorized.includes(locals.user.role)) {
7
+ if (!user || !authorized.includes(user.role)) {
7
8
  throw redirect(302, '/login?referrer=/teachers')
8
9
  }
9
10
 
@@ -0,0 +1,74 @@
1
+ /// <reference lib="webworker" />
2
+ import { build, files, version } from '$service-worker'
3
+
4
+ const worker = <ServiceWorkerGlobalScope> <unknown> self
5
+ const cacheName = `cache${version}`
6
+ const toCache = build.concat(files)
7
+ const staticAssets = new Set(toCache)
8
+
9
+ worker.addEventListener('install', event => {
10
+ // console.log('[Service Worker] Installation')
11
+ event.waitUntil(
12
+ caches
13
+ .open(cacheName)
14
+ .then(cache => cache.addAll(toCache))
15
+ .then(() => {
16
+ worker.skipWaiting()
17
+ })
18
+ .catch(error => console.error(error))
19
+ )
20
+ })
21
+
22
+ worker.addEventListener('activate', event => {
23
+ // console.log('[Service Worker] Activation')
24
+ event.waitUntil(
25
+ caches.keys()
26
+ .then(async (keys) => {
27
+ for (const key of keys) {
28
+ if (key !== cacheName) await caches.delete(key)
29
+ }
30
+ })
31
+ )
32
+ worker.clients.claim() // or should this be inside the caches.keys().then()?
33
+ })
34
+
35
+ // Fetch from network into cache and fall back to cache if user offline
36
+ async function fetchAndCache(request: Request) {
37
+ const cache = await caches.open(`offline${version}`)
38
+
39
+ try {
40
+ const response = await fetch(request)
41
+ cache.put(request, response.clone())
42
+ return response
43
+ } catch (err) {
44
+ const response = await cache.match(request)
45
+ if (response) return response
46
+ throw err
47
+ }
48
+ }
49
+
50
+ worker.addEventListener('fetch', event => {
51
+ if (event.request.method !== 'GET' || event.request.headers.has('range')) return
52
+
53
+ const url = new URL(event.request.url)
54
+ // console.log(`[Service Worker] Fetch ${url}`)
55
+
56
+ // don't try to handle data: or blob: URIs
57
+ const isHttp = url.protocol.startsWith('http')
58
+ const isDevServerRequest = url.hostname === self.location.hostname && url.port !== self.location.port
59
+ const isStaticAsset = url.host === self.location.host && staticAssets.has(url.pathname)
60
+ const skipBecauseUncached = event.request.cache === 'only-if-cached' && !isStaticAsset
61
+
62
+ if (isHttp && !isDevServerRequest && !skipBecauseUncached) {
63
+ event.respondWith(
64
+ (async () => {
65
+ // always serve static files and bundler-generated assets from cache.
66
+ // if your application has other URLs with data that will never change,
67
+ // set this variable to true for them and they will only be fetched once.
68
+ const cachedAsset = isStaticAsset && (await caches.match(event.request))
69
+
70
+ return cachedAsset || fetchAndCache(event.request)
71
+ })()
72
+ )
73
+ }
74
+ })
package/src/stores.ts CHANGED
@@ -1,17 +1,11 @@
1
- import { writable } from 'svelte/store'
1
+ import { writable, type Writable } from 'svelte/store'
2
2
 
3
3
  export const toast = writable({
4
- title: '',
5
- body: '',
6
- isOpen: false
4
+ title: '',
5
+ body: '',
6
+ isOpen: false
7
7
  })
8
8
 
9
9
  // While server determines whether the user is logged in by examining RequestEvent.locals.user, the
10
10
  // loginSession is updated so all parts of the SPA client-side see the user and role.
11
-
12
- export const defaultUser: User = {
13
- id: 0, // the not-logged-in user id
14
- role: 'student' // default role for users who are not logged in
15
- }
16
-
17
- export const loginSession = writable(defaultUser)
11
+ export const loginSession = <Writable<User>> writable(undefined)
package/svelte.config.js CHANGED
@@ -31,6 +31,9 @@ const config = {
31
31
  'object-src': ['none'],
32
32
  'base-uri': ['self']
33
33
  }
34
+ },
35
+ files: {
36
+ serviceWorker: 'src/service-worker.ts'
34
37
  }
35
38
  }
36
39
  }
package/src/lib/config.ts DELETED
@@ -1,4 +0,0 @@
1
- // Only include data that is not sensitive
2
- export const config = {
3
- googleClientId: import.meta.env.VITE_GOOGLE_CLIENT_ID
4
- }