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.
- package/CHANGELOG.md +19 -0
- package/README.md +12 -13
- package/db_create.sh +13 -0
- package/db_create.sql +135 -133
- package/db_schema.sql +369 -0
- package/package.json +4 -2
- package/src/hooks.server.ts +10 -7
- package/src/lib/server/email/index.ts +3 -0
- package/src/lib/server/email/mfa-code.ts +16 -0
- package/src/lib/server/email/password-reset.ts +15 -0
- package/src/lib/server/email/verify-email.ts +16 -0
- package/src/routes/+layout.svelte +89 -28
- package/src/routes/auth/forgot/+server.ts +8 -22
- package/src/routes/auth/google/+server.ts +6 -1
- package/src/routes/auth/login/+server.ts +41 -8
- package/src/routes/auth/mfa/+server.ts +60 -0
- package/src/routes/auth/register/+server.ts +7 -17
- package/src/routes/auth/reset/[token]/+page.svelte +6 -8
- package/src/routes/forgot/+page.svelte +5 -8
- package/src/routes/layout.css +3 -3
- package/src/routes/login/+page.svelte +203 -77
- package/src/routes/profile/+page.svelte +16 -12
- package/src/routes/register/+page.svelte +145 -122
|
@@ -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
|
|
4
|
+
import { JWT_SECRET } from '$env/static/private'
|
|
6
5
|
import { query } from '$lib/server/db'
|
|
7
|
-
import {
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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, {
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
4
|
+
import { JWT_SECRET } from '$env/static/private'
|
|
6
5
|
import { query } from '$lib/server/db'
|
|
7
|
-
import {
|
|
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(
|
|
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
|
-
|
|
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={
|
|
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
|
|
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={
|
|
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
|
-
|
package/src/routes/layout.css
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
|
|
13
13
|
html,
|
|
14
14
|
:host {
|
|
15
|
-
@apply tw:font-sans tw:text-gray-800
|
|
15
|
+
@apply tw:bg-white tw:font-sans tw:text-gray-800;
|
|
16
16
|
@variant dark {
|
|
17
|
-
@apply tw:
|
|
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:
|
|
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('
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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}
|