sveltekit-auth-example 5.1.3 → 5.6.0

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.
@@ -1,10 +1,9 @@
1
1
  import type { Secret } from 'jsonwebtoken'
2
- import type { MailDataRequired } from '@sendgrid/mail'
3
2
  import type { RequestHandler } from './$types'
4
3
  import jwt from 'jsonwebtoken'
5
- import { JWT_SECRET, DOMAIN, SENDGRID_SENDER } from '$env/static/private'
4
+ import { JWT_SECRET } from '$env/static/private'
6
5
  import { query } from '$lib/server/db'
7
- import { sendMessage } from '$lib/server/sendgrid'
6
+ import { sendPasswordResetEmail } from '$lib/server/email'
8
7
 
9
8
  export const POST: RequestHandler = async event => {
10
9
  const body = await event.request.json()
@@ -12,26 +11,13 @@ export const POST: RequestHandler = async event => {
12
11
  const { rows } = await query(sql, [body.email])
13
12
 
14
13
  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, purpose: 'reset-password' }, <Secret>secret, {
19
- expiresIn: '30m'
20
- })
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: `
29
- <a href="${DOMAIN}/auth/reset/${token}">Reset my password</a>. Your browser will open and ask you to provide a
30
- new password with a confirmation then redirect you to your login page.
31
- `
32
- }
14
+ const token = jwt.sign(
15
+ { subject: rows[0].userId, purpose: 'reset-password' },
16
+ JWT_SECRET as Secret,
17
+ { expiresIn: '30m' }
18
+ )
33
19
  try {
34
- await sendMessage(message)
20
+ await sendPasswordResetEmail(body.email, token)
35
21
  } catch (err) {
36
22
  console.error('Failed to send password reset email:', err)
37
23
  // Still return 204 to avoid leaking whether the email exists in our system
@@ -52,7 +52,12 @@ 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', secure: true, path: '/' })
55
+ cookies.set('session', userSession.id, {
56
+ httpOnly: true,
57
+ sameSite: 'lax',
58
+ secure: true,
59
+ path: '/'
60
+ })
56
61
  return json({ message: 'Successful Google Sign-In.', user: userSession.user })
57
62
  } catch {
58
63
  error(401, 'Google authentication failed.')
@@ -1,6 +1,10 @@
1
1
  import { error, json } from '@sveltejs/kit'
2
2
  import type { RequestHandler } from './$types'
3
+ import type { Secret } from 'jsonwebtoken'
4
+ import jwt from 'jsonwebtoken'
5
+ import { JWT_SECRET } from '$env/static/private'
3
6
  import { query } from '$lib/server/db'
7
+ import { sendMfaCodeEmail } from '$lib/server/email'
4
8
 
5
9
  // In-memory failed-attempt tracker for account lockout (per email)
6
10
  // For production use a shared store like Redis.
@@ -8,6 +12,7 @@ const failedAttempts = new Map<string, { count: number; lockedUntil: number }>()
8
12
 
9
13
  const MAX_FAILURES = 5
10
14
  const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes
15
+ const MFA_TRUSTED_COOKIE = 'mfa_trusted'
11
16
 
12
17
  export const POST: RequestHandler = async event => {
13
18
  const { cookies } = event
@@ -57,12 +62,40 @@ export const POST: RequestHandler = async event => {
57
62
  // Clear lockout tracker on successful login
58
63
  failedAttempts.delete(email)
59
64
 
60
- event.locals.user = authenticationResult.user
61
- cookies.set('session', authenticationResult.sessionId, {
62
- httpOnly: true,
63
- sameSite: 'lax',
64
- secure: true,
65
- path: '/'
66
- })
67
- return json({ message: authenticationResult.status, user: authenticationResult.user })
65
+ const userId = authenticationResult.user.id!
66
+
67
+ // Check if this device is already MFA-trusted for this user
68
+ const trustedToken = cookies.get(MFA_TRUSTED_COOKIE)
69
+ if (trustedToken) {
70
+ try {
71
+ const payload = jwt.verify(trustedToken, JWT_SECRET as Secret) as {
72
+ userId: number
73
+ purpose: string
74
+ }
75
+ if (payload.purpose === 'mfa-trusted' && payload.userId === userId) {
76
+ // Trusted device — skip MFA and complete login
77
+ event.locals.user = authenticationResult.user
78
+ cookies.set('session', authenticationResult.sessionId, {
79
+ httpOnly: true,
80
+ sameSite: 'lax',
81
+ secure: true,
82
+ path: '/'
83
+ })
84
+ return json({ message: authenticationResult.status, user: authenticationResult.user })
85
+ }
86
+ } catch {
87
+ // Invalid or expired trusted token — fall through to MFA
88
+ cookies.delete(MFA_TRUSTED_COOKIE, { path: '/' })
89
+ }
90
+ }
91
+
92
+ // MFA required — delete the pre-created session until code is verified
93
+ await query(`CALL delete_session($1);`, [userId])
94
+
95
+ // Generate and email the MFA code
96
+ const codeResult = await query(`SELECT create_mfa_code($1) AS code;`, [userId])
97
+ const code: string = codeResult.rows[0].code
98
+ await sendMfaCodeEmail(email, code)
99
+
100
+ return json({ mfaRequired: true })
68
101
  }
@@ -0,0 +1,60 @@
1
+ import { error, json } from '@sveltejs/kit'
2
+ import type { RequestHandler } from './$types'
3
+ import type { Secret } from 'jsonwebtoken'
4
+ import jwt from 'jsonwebtoken'
5
+ import { JWT_SECRET } from '$env/static/private'
6
+ import { query } from '$lib/server/db'
7
+
8
+ const MFA_TRUSTED_COOKIE = 'mfa_trusted'
9
+ const MFA_TRUSTED_MAX_AGE = 30 * 24 * 60 * 60 // 30 days in seconds
10
+
11
+ export const POST: RequestHandler = async event => {
12
+ const { cookies } = event
13
+
14
+ let body: { email?: string; code?: string }
15
+ try {
16
+ body = await event.request.json()
17
+ } catch {
18
+ error(400, 'Invalid request body.')
19
+ }
20
+
21
+ if (!body.email || !body.code) error(400, 'Email and verification code are required.')
22
+
23
+ // Verify the code; returns user_id on success, NULL on failure/expiry
24
+ const verifyResult = await query(`SELECT verify_mfa_code($1, $2) AS "userId";`, [
25
+ body.email.toLowerCase(),
26
+ body.code
27
+ ])
28
+
29
+ const userId: number | null = verifyResult.rows[0]?.userId
30
+ if (!userId) error(401, 'Invalid or expired verification code.')
31
+
32
+ // Create a new session now that MFA is verified
33
+ const sessionResult = await query(`SELECT create_session($1) AS "sessionId";`, [userId])
34
+ const sessionId: string = sessionResult.rows[0].sessionId
35
+
36
+ // Load the full user object for the response
37
+ const userResult = await query(`SELECT get_session($1::uuid);`, [sessionId])
38
+ const user = userResult.rows[0]?.get_session
39
+
40
+ cookies.set('session', sessionId, {
41
+ httpOnly: true,
42
+ sameSite: 'lax',
43
+ secure: true,
44
+ path: '/'
45
+ })
46
+
47
+ // Issue a 30-day trusted-device cookie so MFA is not required again on this device
48
+ const trustedToken = jwt.sign({ userId, purpose: 'mfa-trusted' }, JWT_SECRET as Secret, {
49
+ expiresIn: '30d'
50
+ })
51
+ cookies.set(MFA_TRUSTED_COOKIE, trustedToken, {
52
+ httpOnly: true,
53
+ sameSite: 'lax',
54
+ secure: true,
55
+ path: '/',
56
+ maxAge: MFA_TRUSTED_MAX_AGE
57
+ })
58
+
59
+ return json({ message: 'Login successful.', user })
60
+ }
@@ -1,10 +1,9 @@
1
1
  import { error, json } from '@sveltejs/kit'
2
2
  import type { RequestHandler } from './$types'
3
- import type { MailDataRequired } from '@sendgrid/mail'
4
3
  import jwt from 'jsonwebtoken'
5
- import { JWT_SECRET, DOMAIN, SENDGRID_SENDER } from '$env/static/private'
4
+ import { JWT_SECRET } from '$env/static/private'
6
5
  import { query } from '$lib/server/db'
7
- import { sendMessage } from '$lib/server/sendgrid'
6
+ import { sendVerificationEmail } from '$lib/server/email'
8
7
 
9
8
  export const POST: RequestHandler = async event => {
10
9
  let body: { email?: string; password?: string; firstName?: string; lastName?: string }
@@ -19,7 +18,10 @@ export const POST: RequestHandler = async event => {
19
18
 
20
19
  const passwordPattern = /(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}/
21
20
  if (!passwordPattern.test(body.password))
22
- error(400, 'Password must be at least 8 characters and include an uppercase letter, a number, and a special character.')
21
+ error(
22
+ 400,
23
+ 'Password must be at least 8 characters and include an uppercase letter, a number, and a special character.'
24
+ )
23
25
 
24
26
  let result
25
27
  try {
@@ -47,19 +49,7 @@ export const POST: RequestHandler = async event => {
47
49
  { expiresIn: '24h' }
48
50
  )
49
51
 
50
- const message: MailDataRequired = {
51
- to: { email: body.email },
52
- from: SENDGRID_SENDER,
53
- subject: 'Verify your email address',
54
- categories: ['account'],
55
- html: `
56
- <p>Thanks for registering! Please verify your email address by clicking the link below:</p>
57
- <p><a href="${DOMAIN}/auth/verify/${token}">Verify my email address</a></p>
58
- <p>This link expires in 24 hours. If you did not register, you can safely ignore this email.</p>
59
- `
60
- }
61
-
62
- await sendMessage(message)
52
+ await sendVerificationEmail(body.email, token)
63
53
 
64
54
  return json({
65
55
  message: 'Registration successful. Please check your email to verify your account.',
@@ -90,7 +90,10 @@
90
90
  novalidate
91
91
  class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
92
92
  class:submitted
93
- onsubmit={(e) => { e.preventDefault(); resetPassword() }}
93
+ onsubmit={e => {
94
+ e.preventDefault()
95
+ resetPassword()
96
+ }}
94
97
  >
95
98
  <h4>New Password</h4>
96
99
  <p>Please provide a new password.</p>
@@ -130,7 +133,7 @@
130
133
  autocomplete="new-password"
131
134
  />
132
135
  {#if passwordMismatch}
133
- <span class="tw:text-xs tw:text-red-600 tw:mt-0.5">Passwords must match</span>
136
+ <span class="tw:mt-0.5 tw:text-xs tw:text-red-600">Passwords must match</span>
134
137
  {/if}
135
138
  </label>
136
139
 
@@ -138,12 +141,7 @@
138
141
  <p class="tw:text-red-600">{message}</p>
139
142
  {/if}
140
143
 
141
- <button
142
- type="submit"
143
- class="btn-primary"
144
- disabled={loading}
145
- >
144
+ <button type="submit" class="btn-primary" disabled={loading}>
146
145
  {loading ? 'Resetting...' : 'Reset Password'}
147
146
  </button>
148
147
  </form>
149
-
@@ -62,7 +62,10 @@
62
62
  novalidate
63
63
  class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
64
64
  class:submitted
65
- onsubmit={(e) => { e.preventDefault(); sendPasswordReset() }}
65
+ onsubmit={e => {
66
+ e.preventDefault()
67
+ sendPasswordReset()
68
+ }}
66
69
  >
67
70
  <h4>Forgot password</h4>
68
71
  <p>Hey, you're human. We get it.</p>
@@ -86,13 +89,7 @@
86
89
  <p class="tw:text-red-600">{message}</p>
87
90
  {/if}
88
91
 
89
- <button
90
- type="submit"
91
- class="btn-primary"
92
- disabled={loading}
93
- >
92
+ <button type="submit" class="btn-primary" disabled={loading}>
94
93
  {loading ? 'Sending...' : 'Send Email'}
95
94
  </button>
96
95
  </form>
97
-
98
-
@@ -12,9 +12,9 @@
12
12
 
13
13
  html,
14
14
  :host {
15
- @apply tw:font-sans tw:text-gray-800 tw:bg-white;
15
+ @apply tw:bg-white tw:font-sans tw:text-gray-800;
16
16
  @variant dark {
17
- @apply tw:text-gray-100 tw:bg-gray-900;
17
+ @apply tw:bg-gray-900 tw:text-gray-100;
18
18
  }
19
19
  }
20
20
  }
@@ -27,7 +27,7 @@
27
27
  margin-top: 0.25rem;
28
28
  appearance: none;
29
29
  -webkit-appearance: none;
30
- @apply tw:rounded tw:border tw:border-gray-300 tw:bg-white tw:text-gray-900 tw:dark:bg-gray-800 tw:dark:border-gray-600 tw:dark:text-gray-200;
30
+ @apply tw:rounded tw:border tw:border-gray-300 tw:bg-white tw:text-gray-900 tw:dark:border-gray-600 tw:dark:bg-gray-800 tw:dark:text-gray-200;
31
31
  padding: 0.375rem 0.75rem;
32
32
  font-size: var(--text-sm, 0.875rem);
33
33
  line-height: var(--tw-leading-sm, 1.25rem);
@@ -7,9 +7,13 @@
7
7
 
8
8
  let focusedField: HTMLInputElement | undefined = $state()
9
9
  let formEl: HTMLFormElement | undefined = $state()
10
+ let mfaFormEl: HTMLFormElement | undefined = $state()
10
11
  let message = $state('')
11
12
  let submitted = $state(false)
13
+ let mfaSubmitted = $state(false)
12
14
  let loading = $state(false)
15
+ let mfaRequired = $state(false)
16
+ let mfaCode = $state('')
13
17
  const credentials: Credentials = $state({
14
18
  email: '',
15
19
  password: ''
@@ -52,6 +56,46 @@
52
56
  }
53
57
  })
54
58
  const fromEndpoint = await res.json()
59
+ if (res.ok) {
60
+ if (fromEndpoint.mfaRequired) {
61
+ mfaRequired = true
62
+ message = ''
63
+ } else {
64
+ appState.user = fromEndpoint.user
65
+ redirectAfterLogin(fromEndpoint.user)
66
+ }
67
+ } else {
68
+ throw new Error(fromEndpoint.message)
69
+ }
70
+ } catch (err) {
71
+ if (err instanceof Error) {
72
+ console.error('Login error', err)
73
+ message = err.message
74
+ }
75
+ } finally {
76
+ loading = false
77
+ }
78
+ }
79
+
80
+ async function verifyMfa() {
81
+ message = ''
82
+ mfaSubmitted = false
83
+ const form = mfaFormEl!
84
+
85
+ if (!form.checkValidity()) {
86
+ mfaSubmitted = true
87
+ focusOnFirstError(form)
88
+ return
89
+ }
90
+
91
+ loading = true
92
+ try {
93
+ const res = await fetch('/auth/mfa', {
94
+ method: 'POST',
95
+ body: JSON.stringify({ email: credentials.email, code: mfaCode }),
96
+ headers: { 'Content-Type': 'application/json' }
97
+ })
98
+ const fromEndpoint = await res.json()
55
99
  if (res.ok) {
56
100
  appState.user = fromEndpoint.user
57
101
  redirectAfterLogin(fromEndpoint.user)
@@ -60,7 +104,7 @@
60
104
  }
61
105
  } catch (err) {
62
106
  if (err instanceof Error) {
63
- console.error('Login error', err)
107
+ console.error('MFA error', err)
64
108
  message = err.message
65
109
  }
66
110
  } finally {
@@ -74,81 +118,163 @@
74
118
  <meta name="robots" content="noindex, nofollow" />
75
119
  </svelte:head>
76
120
 
77
- <form
78
- bind:this={formEl}
79
- autocomplete="on"
80
- novalidate
81
- class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
82
- class:submitted
83
- onsubmit={(e) => { e.preventDefault(); login() }}
84
- >
85
- <h4>Sign In</h4>
86
- <p>Welcome back.</p>
87
-
88
- <div class="tw:group tw:relative tw:w-full">
89
- <!-- Real Google button: invisible but receives clicks -->
90
- <div id="googleButton" class="tw:opacity-0 tw:w-full"></div>
91
- <!-- Visual overlay: looks good, no pointer events -->
92
- <div class="tw:pointer-events-none tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:gap-3 tw:rounded tw:border tw:border-gray-300 tw:bg-white tw:group-hover:bg-gray-50 tw:text-sm tw:font-medium tw:text-gray-700 tw:dark:bg-gray-800 tw:dark:group-hover:bg-gray-700 tw:dark:border-gray-600 tw:dark:text-gray-200">
93
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
94
- <path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
95
- <path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
96
- <path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
97
- <path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
98
- </svg>
99
- Sign in with Google
121
+ {#if mfaRequired}
122
+ <form
123
+ bind:this={mfaFormEl}
124
+ novalidate
125
+ class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
126
+ class:submitted={mfaSubmitted}
127
+ onsubmit={e => {
128
+ e.preventDefault()
129
+ verifyMfa()
130
+ }}
131
+ >
132
+ <h4>Verification Required</h4>
133
+ <p>
134
+ We sent a 6-digit code to <strong>{credentials.email}</strong>. Enter it below to complete
135
+ sign in.
136
+ </p>
137
+
138
+ <label class="tw:block tw:text-sm tw:font-medium" for="mfaCode">
139
+ Verification Code
140
+ <input
141
+ id="mfaCode"
142
+ type="text"
143
+ inputmode="numeric"
144
+ pattern="[0-9]{6}"
145
+ class="form-input-validated"
146
+ bind:value={mfaCode}
147
+ required
148
+ minlength="6"
149
+ maxlength="6"
150
+ placeholder="000000"
151
+ autocomplete="one-time-code"
152
+ />
153
+ <span class="form-error">6-digit verification code required</span>
154
+ </label>
155
+
156
+ {#if message}
157
+ <p class="tw:text-red-600">{message}</p>
158
+ {/if}
159
+
160
+ <button type="submit" class="btn-primary" disabled={loading}>
161
+ {loading ? 'Verifying...' : 'Verify'}
162
+ </button>
163
+
164
+ <p class="tw:text-center tw:text-sm">
165
+ <button
166
+ type="button"
167
+ class="tw:text-gray-500 tw:underline"
168
+ onclick={() => {
169
+ mfaRequired = false
170
+ mfaCode = ''
171
+ message = ''
172
+ }}
173
+ >
174
+ Back to sign in
175
+ </button>
176
+ </p>
177
+ </form>
178
+ {:else}
179
+ <form
180
+ bind:this={formEl}
181
+ autocomplete="on"
182
+ novalidate
183
+ class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
184
+ class:submitted
185
+ onsubmit={e => {
186
+ e.preventDefault()
187
+ login()
188
+ }}
189
+ >
190
+ <h4>Sign In</h4>
191
+ <p>Welcome back.</p>
192
+
193
+ <div class="tw:group tw:relative tw:w-full">
194
+ <!-- Real Google button: invisible but receives clicks -->
195
+ <div id="googleButton" class="tw:w-full tw:opacity-0"></div>
196
+ <!-- Visual overlay: looks good, no pointer events -->
197
+ <div
198
+ class="tw:pointer-events-none tw:absolute tw:inset-0 tw:flex tw:items-center tw:justify-center tw:gap-3 tw:rounded tw:border tw:border-gray-300 tw:bg-white tw:text-sm tw:font-medium tw:text-gray-700 tw:group-hover:bg-gray-50 tw:dark:border-gray-600 tw:dark:bg-gray-800 tw:dark:text-gray-200 tw:dark:group-hover:bg-gray-700"
199
+ >
200
+ <svg
201
+ xmlns="http://www.w3.org/2000/svg"
202
+ viewBox="0 0 24 24"
203
+ width="18"
204
+ height="18"
205
+ aria-hidden="true"
206
+ >
207
+ <path
208
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
209
+ fill="#4285F4"
210
+ />
211
+ <path
212
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
213
+ fill="#34A853"
214
+ />
215
+ <path
216
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z"
217
+ fill="#FBBC05"
218
+ />
219
+ <path
220
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
221
+ fill="#EA4335"
222
+ />
223
+ </svg>
224
+ Sign in with Google
225
+ </div>
100
226
  </div>
101
- </div>
102
-
103
- <div class="tw:flex tw:items-center tw:gap-2 tw:text-gray-400 tw:text-sm">
104
- <span class="tw:flex-1 tw:border-t tw:border-gray-300"></span>
105
- <span>or</span>
106
- <span class="tw:flex-1 tw:border-t tw:border-gray-300"></span>
107
- </div>
108
-
109
- <label class="tw:block tw:text-sm tw:font-medium" for="email">
110
- Email
111
- <input
112
- id="email"
113
- type="email"
114
- class="form-input-validated"
115
- bind:this={focusedField}
116
- bind:value={credentials.email}
117
- required
118
- placeholder="Email"
119
- autocomplete="email"
120
- />
121
- <span class="form-error">Email address required</span>
122
- </label>
123
-
124
- <div class="tw:block tw:text-sm tw:font-medium">
125
- <div class="tw:flex tw:justify-between tw:items-baseline">
126
- <label for="password">Password</label>
127
- <a href="/forgot" class="tw:text-xs tw:text-gray-500 tw:font-normal">Forgot password?</a>
227
+
228
+ <div class="tw:flex tw:items-center tw:gap-2 tw:text-sm tw:text-gray-400">
229
+ <span class="tw:flex-1 tw:border-t tw:border-gray-300"></span>
230
+ <span>or</span>
231
+ <span class="tw:flex-1 tw:border-t tw:border-gray-300"></span>
128
232
  </div>
129
- <input
130
- id="password"
131
- class="form-input-validated"
132
- type="password"
133
- bind:value={credentials.password}
134
- required
135
- minlength="8"
136
- maxlength="80"
137
- placeholder="Password"
138
- autocomplete="current-password"
139
- />
140
- <span class="form-error">Password with 8 chars or more required</span>
141
- </div>
142
-
143
- {#if message}
144
- <p class="tw:text-red-600">{message}</p>
145
- {/if}
146
-
147
- <button type="submit" class="btn-primary" disabled={loading}>
148
- {loading ? 'Signing in...' : 'Sign In'}
149
- </button>
150
-
151
- <p class="tw:text-center tw:text-sm">
152
- <a href="/register" class="tw:text-gray-500">Don't have an account?</a>
153
- </p>
154
- </form>
233
+
234
+ <label class="tw:block tw:text-sm tw:font-medium" for="email">
235
+ Email
236
+ <input
237
+ id="email"
238
+ type="email"
239
+ class="form-input-validated"
240
+ bind:this={focusedField}
241
+ bind:value={credentials.email}
242
+ required
243
+ placeholder="Email"
244
+ autocomplete="email"
245
+ />
246
+ <span class="form-error">Email address required</span>
247
+ </label>
248
+
249
+ <div class="tw:block tw:text-sm tw:font-medium">
250
+ <div class="tw:flex tw:items-baseline tw:justify-between">
251
+ <label for="password">Password</label>
252
+ <a href="/forgot" class="tw:text-xs tw:font-normal tw:text-gray-500">Forgot password?</a>
253
+ </div>
254
+ <input
255
+ id="password"
256
+ class="form-input-validated"
257
+ type="password"
258
+ bind:value={credentials.password}
259
+ required
260
+ minlength="8"
261
+ maxlength="80"
262
+ placeholder="Password"
263
+ autocomplete="current-password"
264
+ />
265
+ <span class="form-error">Password with 8 chars or more required</span>
266
+ </div>
267
+
268
+ {#if message}
269
+ <p class="tw:text-red-600">{message}</p>
270
+ {/if}
271
+
272
+ <button type="submit" class="btn-primary" disabled={loading}>
273
+ {loading ? 'Signing in...' : 'Sign In'}
274
+ </button>
275
+
276
+ <p class="tw:text-center tw:text-sm">
277
+ <a href="/register" class="tw:text-gray-500">Don't have an account?</a>
278
+ </p>
279
+ </form>
280
+ {/if}