sveltekit-auth-example 2.0.0 → 2.0.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 (38) hide show
  1. package/.eslintrc.cjs +19 -7
  2. package/.prettierignore +1 -0
  3. package/.yarn/install-state.gz +0 -0
  4. package/CHANGELOG.md +164 -98
  5. package/README.md +6 -1
  6. package/package.json +67 -66
  7. package/prettier.config.mjs +15 -0
  8. package/src/app.d.ts +31 -29
  9. package/src/app.html +8 -3
  10. package/src/hooks.server.ts +15 -15
  11. package/src/lib/focus.ts +6 -6
  12. package/src/lib/google.ts +48 -49
  13. package/src/lib/server/db.ts +7 -6
  14. package/src/lib/server/sendgrid.ts +11 -11
  15. package/src/routes/+error.svelte +1 -1
  16. package/src/routes/+layout.server.ts +5 -5
  17. package/src/routes/+layout.svelte +133 -100
  18. package/src/routes/admin/+page.server.ts +1 -1
  19. package/src/routes/admin/+page.svelte +2 -2
  20. package/src/routes/api/v1/user/+server.ts +13 -14
  21. package/src/routes/auth/[slug]/+server.ts +11 -6
  22. package/src/routes/auth/forgot/+server.ts +23 -23
  23. package/src/routes/auth/google/+server.ts +44 -45
  24. package/src/routes/auth/reset/+server.ts +1 -1
  25. package/src/routes/auth/reset/[token]/+page.svelte +117 -95
  26. package/src/routes/auth/reset/[token]/+page.ts +4 -4
  27. package/src/routes/forgot/+page.svelte +74 -63
  28. package/src/routes/info/+page.svelte +1 -1
  29. package/src/routes/login/+page.svelte +140 -120
  30. package/src/routes/profile/+page.server.ts +9 -9
  31. package/src/routes/profile/+page.svelte +142 -88
  32. package/src/routes/register/+page.server.ts +3 -2
  33. package/src/routes/register/+page.svelte +159 -104
  34. package/src/routes/teachers/+page.server.ts +5 -5
  35. package/src/routes/teachers/+page.svelte +2 -2
  36. package/src/stores.ts +1 -1
  37. package/svelte.config.js +1 -1
  38. package/.prettierrc +0 -9
@@ -2,20 +2,19 @@ import { error, json } from '@sveltejs/kit'
2
2
  import type { RequestHandler } from './$types'
3
3
  import { query } from '$lib/server/db'
4
4
 
5
- export const PUT: RequestHandler = async event => {
6
- const { user } = event.locals
5
+ export const PUT: RequestHandler = async (event) => {
6
+ const { user } = event.locals
7
7
 
8
- if (!user)
9
- error(401, 'Unauthorized - must be logged-in.');
8
+ if (!user) error(401, 'Unauthorized - must be logged-in.')
10
9
 
11
- try {
12
- const userUpdate = await event.request.json()
13
- await query(`CALL update_user($1, $2);`, [user.id, JSON.stringify(userUpdate)])
14
- } catch (err) {
15
- error(503, 'Could not communicate with database.');
16
- }
10
+ try {
11
+ const userUpdate = await event.request.json()
12
+ await query(`CALL update_user($1, $2);`, [user.id, JSON.stringify(userUpdate)])
13
+ } catch (err) {
14
+ error(503, 'Could not communicate with database.')
15
+ }
17
16
 
18
- return json({
19
- message: 'Successfully updated user profile.'
20
- })
21
- }
17
+ return json({
18
+ message: 'Successfully updated user profile.'
19
+ })
20
+ }
@@ -12,7 +12,8 @@ export const POST: RequestHandler = async (event) => {
12
12
  try {
13
13
  switch (slug) {
14
14
  case 'logout':
15
- if (event.locals.user) { // else they are logged out / session ended
15
+ if (event.locals.user) {
16
+ // else they are logged out / session ended
16
17
  sql = `CALL delete_session($1);`
17
18
  result = await query(sql, [event.locals.user.id])
18
19
  }
@@ -26,7 +27,7 @@ export const POST: RequestHandler = async (event) => {
26
27
  sql = `SELECT register($1) AS "authenticationResult";`
27
28
  break
28
29
  default:
29
- error(404, 'Invalid endpoint.');
30
+ error(404, 'Invalid endpoint.')
30
31
  }
31
32
 
32
33
  // Only /auth/login and /auth/register at this point
@@ -34,21 +35,25 @@ export const POST: RequestHandler = async (event) => {
34
35
 
35
36
  // While client checks for these to be non-null, register() in the database does not
36
37
  if (slug == 'register' && (!body.email || !body.password || !body.firstName || !body.lastName))
37
- error(400, 'Please supply all required fields: email, password, first and last name.');
38
+ error(400, 'Please supply all required fields: email, password, first and last name.')
38
39
 
39
40
  result = await query(sql, [JSON.stringify(body)])
40
41
  } catch (err) {
41
- error(503, 'Could not communicate with database.');
42
+ error(503, 'Could not communicate with database.')
42
43
  }
43
44
 
44
45
  const { authenticationResult }: { authenticationResult: AuthenticationResult } = result.rows[0]
45
46
 
46
47
  if (!authenticationResult.user)
47
48
  // includes when a user tries to register an existing email account with wrong password
48
- error(authenticationResult.statusCode, authenticationResult.status);
49
+ error(authenticationResult.statusCode, authenticationResult.status)
49
50
 
50
51
  // Ensures hooks.server.ts:handle() will not delete session cookie
51
52
  event.locals.user = authenticationResult.user
52
- cookies.set('session', authenticationResult.sessionId, { httpOnly: true, sameSite: 'lax', path: '/' })
53
+ cookies.set('session', authenticationResult.sessionId, {
54
+ httpOnly: true,
55
+ sameSite: 'lax',
56
+ path: '/'
57
+ })
53
58
  return json({ message: authenticationResult.status, user: authenticationResult.user })
54
59
  }
@@ -6,32 +6,32 @@ import { JWT_SECRET, DOMAIN, SENDGRID_SENDER } from '$env/static/private'
6
6
  import { query } from '$lib/server/db'
7
7
  import { sendMessage } from '$lib/server/sendgrid'
8
8
 
9
- export const POST: RequestHandler = async event => {
10
- const body = await event.request.json()
11
- const sql = `SELECT id as "userId" FROM users WHERE email = $1 LIMIT 1;`
12
- const { rows } = await query(sql, [body.email])
9
+ export const POST: RequestHandler = async (event) => {
10
+ const body = await event.request.json()
11
+ const sql = `SELECT id as "userId" FROM users WHERE email = $1 LIMIT 1;`
12
+ const { rows } = await query(sql, [body.email])
13
13
 
14
- if (rows.length > 0) {
15
- const { userId } = rows[0]
16
- // Create JWT with userId expiring in 30 mins
17
- const secret = JWT_SECRET
18
- const token = jwt.sign({ subject: userId }, <Secret> secret, {
19
- expiresIn: '30m'
20
- })
14
+ if (rows.length > 0) {
15
+ const { userId } = rows[0]
16
+ // Create JWT with userId expiring in 30 mins
17
+ const secret = JWT_SECRET
18
+ const token = jwt.sign({ subject: userId }, <Secret>secret, {
19
+ expiresIn: '30m'
20
+ })
21
21
 
22
- // Email URL with token to user
23
- const message: MailDataRequired = {
24
- to: { email: body.email },
25
- from: SENDGRID_SENDER,
26
- subject: 'Password reset',
27
- categories: ['account'],
28
- html: `
22
+ // Email URL with token to user
23
+ const message: MailDataRequired = {
24
+ to: { email: body.email },
25
+ from: SENDGRID_SENDER,
26
+ subject: 'Password reset',
27
+ categories: ['account'],
28
+ html: `
29
29
  <a href="${DOMAIN}/auth/reset/${token}">Reset my password</a>. Your browser will open and ask you to provide a
30
30
  new password with a confirmation then redirect you to your login page.
31
31
  `
32
- }
33
- sendMessage(message)
34
- }
32
+ }
33
+ sendMessage(message)
34
+ }
35
35
 
36
- return new Response(undefined, { status: 204 })
37
- }
36
+ return new Response(undefined, { status: 204 })
37
+ }
@@ -6,58 +6,57 @@ 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
- try {
10
- const client = new OAuth2Client(PUBLIC_GOOGLE_CLIENT_ID)
11
- const ticket = await client.verifyIdToken({
12
- idToken: token,
13
- audience: PUBLIC_GOOGLE_CLIENT_ID
14
- });
15
- const payload = ticket.getPayload()
16
- if (!payload) error(500, 'Google authentication did not get the expected payload');
17
-
18
- return {
19
- firstName: payload['given_name'] || 'UnknownFirstName',
20
- lastName: payload['family_name'] || 'UnknownLastName',
21
- email: payload['email']
22
- }
23
- } catch (err) {
24
- let message = ''
25
- if (err instanceof Error) message = err.message
26
- error(500, `Google user could not be authenticated: ${message}`);
27
- }
9
+ try {
10
+ const client = new OAuth2Client(PUBLIC_GOOGLE_CLIENT_ID)
11
+ const ticket = await client.verifyIdToken({
12
+ idToken: token,
13
+ audience: PUBLIC_GOOGLE_CLIENT_ID
14
+ })
15
+ const payload = ticket.getPayload()
16
+ if (!payload) error(500, 'Google authentication did not get the expected payload')
17
+
18
+ return {
19
+ firstName: payload['given_name'] || 'UnknownFirstName',
20
+ lastName: payload['family_name'] || 'UnknownLastName',
21
+ email: payload['email']
22
+ }
23
+ } catch (err) {
24
+ let message = ''
25
+ if (err instanceof Error) message = err.message
26
+ error(500, `Google user could not be authenticated: ${message}`)
27
+ }
28
28
  }
29
29
 
30
30
  // Upsert user and get session ID
31
31
  async function upsertGoogleUser(user: Partial<User>): Promise<UserSession> {
32
- try {
33
- const sql = `SELECT start_gmail_user_session($1) AS user_session;`
34
- const { rows } = await query(sql, [JSON.stringify(user)])
35
- return <UserSession> rows[0].user_session
36
- } catch (err) {
37
- let message = ''
38
- if (err instanceof Error) message = err.message
39
- error(500, `Gmail user could not be upserted: ${message}`);
40
- }
32
+ try {
33
+ const sql = `SELECT start_gmail_user_session($1) AS user_session;`
34
+ const { rows } = await query(sql, [JSON.stringify(user)])
35
+ return <UserSession>rows[0].user_session
36
+ } catch (err) {
37
+ let message = ''
38
+ if (err instanceof Error) message = err.message
39
+ error(500, `Gmail user could not be upserted: ${message}`)
40
+ }
41
41
  }
42
42
 
43
43
  // Returns local user if Google user authenticated (and authorized our app)
44
- export const POST: RequestHandler = async event => {
45
- const { cookies } = event
44
+ export const POST: RequestHandler = async (event) => {
45
+ const { cookies } = event
46
46
 
47
- try {
48
- const { token } = await event.request.json()
49
- const user = await getGoogleUserFromJWT(token)
50
- const userSession = await upsertGoogleUser(user)
47
+ try {
48
+ const { token } = await event.request.json()
49
+ const user = await getGoogleUserFromJWT(token)
50
+ const userSession = await upsertGoogleUser(user)
51
51
 
52
- // Prevent hooks.server.ts's handler() from deleting cookie thinking no one has authenticated
53
- event.locals.user = userSession.user
52
+ // Prevent hooks.server.ts's handler() from deleting cookie thinking no one has authenticated
53
+ event.locals.user = userSession.user
54
54
 
55
- cookies.set('session', userSession.id, { httpOnly: true, sameSite: 'lax', path: '/' })
56
- return json({ message: 'Successful Google Sign-In.', user: userSession.user })
57
-
58
- } catch (err) {
59
- let message = ''
60
- if (err instanceof Error) message = err.message
61
- error(401, message);
62
- }
63
- }
55
+ cookies.set('session', userSession.id, { httpOnly: true, sameSite: 'lax', path: '/' })
56
+ return json({ message: 'Successful Google Sign-In.', user: userSession.user })
57
+ } catch (err) {
58
+ let message = ''
59
+ if (err instanceof Error) message = err.message
60
+ error(401, message)
61
+ }
62
+ }
@@ -11,7 +11,7 @@ export const PUT: RequestHandler = async (event) => {
11
11
 
12
12
  // Check the validity of the token and extract userId
13
13
  try {
14
- const decoded = <JwtPayload> jwt.verify(token, <jwt.Secret> JWT_SECRET)
14
+ const decoded = <JwtPayload>jwt.verify(token, <jwt.Secret>JWT_SECRET)
15
15
  const userId = decoded.subject
16
16
 
17
17
  // Update the database with the new password
@@ -1,105 +1,127 @@
1
1
  <script lang="ts">
2
- import type { PageData } from './$types'
3
- import { onMount } from 'svelte'
4
- import { goto } from '$app/navigation'
5
- import { toast } from '../../../../stores'
6
- import { focusOnFirstError } from '$lib/focus'
7
-
8
- export let data: PageData
9
-
10
- let focusedField: HTMLInputElement
11
- let password: string
12
- let confirmPassword: HTMLInputElement
13
- let message: string
14
-
15
- onMount(() => {
16
- focusedField.focus()
17
- })
18
-
19
- const passwordMatch = () => {
20
- if (!password) password = ''
21
- return password == confirmPassword.value
22
- }
23
-
24
- const resetPassword = async () => {
25
- message = ''
26
- const form = <HTMLFormElement> document.getElementById('reset')
27
-
28
- if (!passwordMatch()) {
29
- confirmPassword.classList.add('is-invalid')
30
- }
31
-
32
- if (form.checkValidity()) {
33
-
34
- const url = `/auth/reset`
35
- const res = await fetch(url, {
36
- method: 'PUT',
37
- headers: {
38
- 'Content-Type': 'application/json'
39
- },
40
- body: JSON.stringify({
41
- token: data.token,
42
- password
43
- })
44
- })
45
-
46
- if (res.ok) {
47
- $toast = {
48
- title: 'Password Reset Succesful',
49
- body: 'Your password was reset. Please login.',
50
- isOpen: true
51
- }
52
-
53
- goto('/login')
54
- } else {
55
- const body = await res.json()
56
- console.log('Failed reset', body)
57
- message = body.message
58
- }
59
-
60
- } else {
61
- form.classList.add('was-validated')
62
- focusOnFirstError(form)
63
- }
64
-
65
- }
2
+ import type { PageData } from './$types'
3
+ import { onMount } from 'svelte'
4
+ import { goto } from '$app/navigation'
5
+ import { toast } from '../../../../stores'
6
+ import { focusOnFirstError } from '$lib/focus'
7
+
8
+ export let data: PageData
9
+
10
+ let focusedField: HTMLInputElement
11
+ let password: string
12
+ let confirmPassword: HTMLInputElement
13
+ let message: string
14
+
15
+ onMount(() => {
16
+ focusedField.focus()
17
+ })
18
+
19
+ const passwordMatch = () => {
20
+ if (!password) password = ''
21
+ return password == confirmPassword.value
22
+ }
23
+
24
+ const resetPassword = async () => {
25
+ message = ''
26
+ const form = <HTMLFormElement>document.getElementById('reset')
27
+
28
+ if (!passwordMatch()) {
29
+ confirmPassword.classList.add('is-invalid')
30
+ }
31
+
32
+ if (form.checkValidity()) {
33
+ const url = `/auth/reset`
34
+ const res = await fetch(url, {
35
+ method: 'PUT',
36
+ headers: {
37
+ 'Content-Type': 'application/json'
38
+ },
39
+ body: JSON.stringify({
40
+ token: data.token,
41
+ password
42
+ })
43
+ })
44
+
45
+ if (res.ok) {
46
+ $toast = {
47
+ title: 'Password Reset Succesful',
48
+ body: 'Your password was reset. Please login.',
49
+ isOpen: true
50
+ }
51
+
52
+ goto('/login')
53
+ } else {
54
+ const body = await res.json()
55
+ console.log('Failed reset', body)
56
+ message = body.message
57
+ }
58
+ } else {
59
+ form.classList.add('was-validated')
60
+ focusOnFirstError(form)
61
+ }
62
+ }
66
63
  </script>
67
64
 
68
65
  <svelte:head>
69
- <title>New Password</title>
66
+ <title>New Password</title>
70
67
  </svelte:head>
71
68
 
72
69
  <div class="d-flex justify-content-center mt-5">
73
- <div class="card login">
74
- <div class="card-body">
75
- <form id="reset" autocomplete="on" novalidate>
76
- <h4><strong>New Password</strong></h4>
77
- <p>Please provide a new password.</p>
78
- <div class="mb-3">
79
- <label class="form-label" for="password">Password</label>
80
- <input class="form-control" id="password" type="password" bind:value={password} bind:this={focusedField} minlength="8" maxlength="80" placeholder="Password" autocomplete="new-password"/>
81
- <div class="invalid-feedback">Password with 8 chars or more required</div>
82
- <div class="form-text">Password minimum length 8, must have one capital letter, 1 number, and one unique character.</div>
83
- </div>
84
- <div class="mb-3">
85
- <label class="form-label" for="passwordConfirm">Password (retype)</label>
86
- <input class="form-control" id="passwordConfirm" type="password" required={!!password} bind:this={confirmPassword} minlength="8" maxlength="80" placeholder="Password (again)" autocomplete="new-password"/>
87
- <div class="invalid-feedback">Passwords must match</div>
88
- </div>
89
-
90
- {#if message}
91
- <p class="text-danger">{message}</p>
92
- {/if}
93
- <div class="d-grid gap-2">
94
- <button on:click|preventDefault={resetPassword} class="btn btn-primary btn-lg">Send Email</button>
95
- </div>
96
- </form>
97
- </div>
98
- </div>
70
+ <div class="card login">
71
+ <div class="card-body">
72
+ <form id="reset" autocomplete="on" novalidate>
73
+ <h4><strong>New Password</strong></h4>
74
+ <p>Please provide a new password.</p>
75
+ <div class="mb-3">
76
+ <label class="form-label" for="password">Password</label>
77
+ <input
78
+ class="form-control"
79
+ id="password"
80
+ type="password"
81
+ bind:value={password}
82
+ bind:this={focusedField}
83
+ minlength="8"
84
+ maxlength="80"
85
+ placeholder="Password"
86
+ autocomplete="new-password"
87
+ />
88
+ <div class="invalid-feedback">Password with 8 chars or more required</div>
89
+ <div class="form-text">
90
+ Password minimum length 8, must have one capital letter, 1 number, and one unique
91
+ character.
92
+ </div>
93
+ </div>
94
+ <div class="mb-3">
95
+ <label class="form-label" for="passwordConfirm">Password (retype)</label>
96
+ <input
97
+ class="form-control"
98
+ id="passwordConfirm"
99
+ type="password"
100
+ required={!!password}
101
+ bind:this={confirmPassword}
102
+ minlength="8"
103
+ maxlength="80"
104
+ placeholder="Password (again)"
105
+ autocomplete="new-password"
106
+ />
107
+ <div class="invalid-feedback">Passwords must match</div>
108
+ </div>
109
+
110
+ {#if message}
111
+ <p class="text-danger">{message}</p>
112
+ {/if}
113
+ <div class="d-grid gap-2">
114
+ <button on:click|preventDefault={resetPassword} class="btn btn-primary btn-lg"
115
+ >Send Email</button
116
+ >
117
+ </div>
118
+ </form>
119
+ </div>
120
+ </div>
99
121
  </div>
100
122
 
101
123
  <style lang="scss">
102
- .card-body {
103
- width: 25rem;
104
- }
105
- </style>
124
+ .card-body {
125
+ width: 25rem;
126
+ }
127
+ </style>
@@ -1,7 +1,7 @@
1
1
  import type { PageLoad } from './$types'
2
2
 
3
- export const load: PageLoad = async event => {
4
- return {
5
- token: event.params.token
6
- }
3
+ export const load: PageLoad = async (event) => {
4
+ return {
5
+ token: event.params.token
6
+ }
7
7
  }
@@ -1,77 +1,88 @@
1
1
  <script lang="ts">
2
- import { onMount } from 'svelte'
3
- import { goto } from '$app/navigation'
4
- import { toast } from '../../stores'
5
- import { focusOnFirstError } from '$lib/focus'
2
+ import { onMount } from 'svelte'
3
+ import { goto } from '$app/navigation'
4
+ import { toast } from '../../stores'
5
+ import { focusOnFirstError } from '$lib/focus'
6
6
 
7
- let focusedField: HTMLInputElement
8
- let email: string
9
- let message: string
7
+ let focusedField: HTMLInputElement
8
+ let email: string
9
+ let message: string
10
10
 
11
- onMount(() => {
12
- focusedField.focus()
13
- })
11
+ onMount(() => {
12
+ focusedField.focus()
13
+ })
14
14
 
15
- const sendPasswordReset = async () => {
16
- message = ''
17
- const form = <HTMLFormElement> document.getElementById('forgot')
15
+ const sendPasswordReset = async () => {
16
+ message = ''
17
+ const form = <HTMLFormElement>document.getElementById('forgot')
18
18
 
19
- if (form.checkValidity()) {
20
- if (email.toLowerCase().includes('gmail.com')) {
21
- return message = 'Gmail passwords must be reset on Manage Your Google Account.'
22
- }
23
- const url = `/auth/forgot`
24
- const res = await fetch(url, {
25
- method: 'POST',
26
- headers: {
27
- 'Content-Type': 'application/json'
28
- },
29
- body: JSON.stringify({ email })
30
- })
31
-
32
- if (res.ok) {
33
- $toast = {
34
- title: 'Password Reset',
35
- body: 'Please check your inbox for a password reset email (junk mail, too).',
36
- isOpen: true
37
- }
38
- return goto('/')
39
- }
40
- } else {
41
- form.classList.add('was-validated')
42
- focusOnFirstError(form)
43
- }
44
- }
19
+ if (form.checkValidity()) {
20
+ if (email.toLowerCase().includes('gmail.com')) {
21
+ return (message = 'Gmail passwords must be reset on Manage Your Google Account.')
22
+ }
23
+ const url = `/auth/forgot`
24
+ const res = await fetch(url, {
25
+ method: 'POST',
26
+ headers: {
27
+ 'Content-Type': 'application/json'
28
+ },
29
+ body: JSON.stringify({ email })
30
+ })
31
+
32
+ if (res.ok) {
33
+ $toast = {
34
+ title: 'Password Reset',
35
+ body: 'Please check your inbox for a password reset email (junk mail, too).',
36
+ isOpen: true
37
+ }
38
+ return goto('/')
39
+ }
40
+ } else {
41
+ form.classList.add('was-validated')
42
+ focusOnFirstError(form)
43
+ }
44
+ }
45
45
  </script>
46
46
 
47
47
  <svelte:head>
48
- <title>Forgot Password</title>
48
+ <title>Forgot Password</title>
49
49
  </svelte:head>
50
50
 
51
51
  <div class="d-flex justify-content-center mt-5">
52
- <div class="card">
53
- <div class="card-body">
54
- <form id="forgot" autocomplete="on" novalidate>
55
- <h4><strong>Forgot password</strong></h4>
56
- <p>Hey, you're human. We get it.</p>
57
- <div class="mb-3">
58
- <label class="form-label" for="email">Email</label>
59
- <input bind:this={focusedField} bind:value={email} type="email" id="email" class="form-control" required placeholder="Email" autocomplete="email"/>
60
- <div class="invalid-feedback">Email address required</div>
61
- </div>
62
- {#if message}
63
- <p class="text-danger">{message}</p>
64
- {/if}
65
- <div class="d-grid gap-2">
66
- <button on:click|preventDefault={sendPasswordReset} class="btn btn-primary btn-lg">Send Email</button>
67
- </div>
68
- </form>
69
- </div>
70
- </div>
52
+ <div class="card">
53
+ <div class="card-body">
54
+ <form id="forgot" autocomplete="on" novalidate>
55
+ <h4><strong>Forgot password</strong></h4>
56
+ <p>Hey, you're human. We get it.</p>
57
+ <div class="mb-3">
58
+ <label class="form-label" for="email">Email</label>
59
+ <input
60
+ bind:this={focusedField}
61
+ bind:value={email}
62
+ type="email"
63
+ id="email"
64
+ class="form-control"
65
+ required
66
+ placeholder="Email"
67
+ autocomplete="email"
68
+ />
69
+ <div class="invalid-feedback">Email address required</div>
70
+ </div>
71
+ {#if message}
72
+ <p class="text-danger">{message}</p>
73
+ {/if}
74
+ <div class="d-grid gap-2">
75
+ <button on:click|preventDefault={sendPasswordReset} class="btn btn-primary btn-lg"
76
+ >Send Email</button
77
+ >
78
+ </div>
79
+ </form>
80
+ </div>
81
+ </div>
71
82
  </div>
72
83
 
73
84
  <style lang="scss">
74
- .card-body {
75
- width: 25rem;
76
- }
77
- </style>
85
+ .card-body {
86
+ width: 25rem;
87
+ }
88
+ </style>
@@ -1,5 +1,5 @@
1
1
  <svelte:head>
2
- <title>Info Page</title>
2
+ <title>Info Page</title>
3
3
  </svelte:head>
4
4
 
5
5
  <h1>Info</h1>