sveltekit-auth-example 5.1.0 → 5.1.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.
- package/CHANGELOG.md +4 -0
- package/db_create.sql +51 -31
- package/package.json +1 -1
- package/src/app.d.ts +1 -1
- package/src/app.html +1 -0
- package/src/hooks.server.ts +40 -6
- package/src/lib/app-state.svelte.ts +19 -0
- package/src/lib/auth-redirect.ts +25 -0
- package/src/lib/google.ts +9 -27
- package/src/lib/server/db.ts +3 -2
- package/src/lib/server/sendgrid.ts +5 -9
- package/src/routes/+layout.svelte +45 -30
- package/src/routes/api/v1/user/+server.ts +16 -0
- package/src/routes/auth/[slug]/+server.ts +5 -56
- package/src/routes/auth/forgot/+server.ts +6 -1
- package/src/routes/auth/google/+server.ts +1 -1
- package/src/routes/auth/login/+server.ts +68 -0
- package/src/routes/auth/logout/+server.ts +19 -0
- package/src/routes/auth/register/+server.ts +64 -0
- package/src/routes/auth/reset/+server.ts +7 -0
- package/src/routes/auth/reset/[token]/+page.svelte +42 -32
- package/src/routes/auth/verify/[token]/+server.ts +48 -0
- package/src/routes/forgot/+page.svelte +32 -24
- package/src/routes/layout.css +51 -1
- package/src/routes/login/+page.server.ts +9 -0
- package/src/routes/login/+page.svelte +41 -36
- package/src/routes/profile/+page.svelte +70 -31
- package/src/routes/register/+page.svelte +152 -129
- package/src/service-worker.ts +22 -4
- package/src/stores.ts +0 -13
- /package/{.env.sample → .env.example} +0 -0
|
@@ -52,7 +52,7 @@ export const POST: RequestHandler = async event => {
|
|
|
52
52
|
// Prevent hooks.server.ts's handler() from deleting cookie thinking no one has authenticated
|
|
53
53
|
event.locals.user = userSession.user
|
|
54
54
|
|
|
55
|
-
cookies.set('session', userSession.id, { httpOnly: true, sameSite: 'lax', path: '/' })
|
|
55
|
+
cookies.set('session', userSession.id, { httpOnly: true, sameSite: 'lax', secure: true, path: '/' })
|
|
56
56
|
return json({ message: 'Successful Google Sign-In.', user: userSession.user })
|
|
57
57
|
} catch (err) {
|
|
58
58
|
let message = ''
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { error, json } from '@sveltejs/kit'
|
|
2
|
+
import type { RequestHandler } from './$types'
|
|
3
|
+
import { query } from '$lib/server/db'
|
|
4
|
+
|
|
5
|
+
// In-memory failed-attempt tracker for account lockout (per email)
|
|
6
|
+
// For production use a shared store like Redis.
|
|
7
|
+
const failedAttempts = new Map<string, { count: number; lockedUntil: number }>()
|
|
8
|
+
|
|
9
|
+
const MAX_FAILURES = 5
|
|
10
|
+
const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes
|
|
11
|
+
|
|
12
|
+
export const POST: RequestHandler = async event => {
|
|
13
|
+
const { cookies } = event
|
|
14
|
+
|
|
15
|
+
let body: { email?: string; password?: string }
|
|
16
|
+
try {
|
|
17
|
+
body = await event.request.json()
|
|
18
|
+
} catch {
|
|
19
|
+
error(400, 'Invalid request body.')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const email = body.email?.toLowerCase() ?? ''
|
|
23
|
+
|
|
24
|
+
// Check per-email account lockout
|
|
25
|
+
const attempts = failedAttempts.get(email)
|
|
26
|
+
if (attempts && Date.now() < attempts.lockedUntil) {
|
|
27
|
+
error(429, 'Account temporarily locked due to too many failed attempts. Try again later.')
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let result
|
|
31
|
+
try {
|
|
32
|
+
const sql = `SELECT authenticate($1) AS "authenticationResult";`
|
|
33
|
+
result = await query(sql, [JSON.stringify(body)])
|
|
34
|
+
} catch {
|
|
35
|
+
error(503, 'Could not communicate with database.')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const { authenticationResult }: { authenticationResult: AuthenticationResult } = result.rows[0]
|
|
39
|
+
|
|
40
|
+
if (!authenticationResult.user) {
|
|
41
|
+
// Track failed attempt for lockout
|
|
42
|
+
if (email) {
|
|
43
|
+
const existing = failedAttempts.get(email)
|
|
44
|
+
if (existing) {
|
|
45
|
+
existing.count++
|
|
46
|
+
if (existing.count >= MAX_FAILURES) {
|
|
47
|
+
existing.lockedUntil = Date.now() + LOCKOUT_MS
|
|
48
|
+
existing.count = 0
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
failedAttempts.set(email, { count: 1, lockedUntil: 0 })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
error(authenticationResult.statusCode, authenticationResult.status)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Clear lockout tracker on successful login
|
|
58
|
+
failedAttempts.delete(email)
|
|
59
|
+
|
|
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 })
|
|
68
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { json } from '@sveltejs/kit'
|
|
2
|
+
import type { RequestHandler } from './$types'
|
|
3
|
+
import { query } from '$lib/server/db'
|
|
4
|
+
|
|
5
|
+
export const POST: RequestHandler = async event => {
|
|
6
|
+
const { cookies } = event
|
|
7
|
+
|
|
8
|
+
if (event.locals.user) {
|
|
9
|
+
try {
|
|
10
|
+
await query(`CALL delete_session($1);`, [event.locals.user.id])
|
|
11
|
+
} catch (err) {
|
|
12
|
+
console.error('Failed to delete session from database:', err)
|
|
13
|
+
// Best effort — still clear the cookie below
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
cookies.delete('session', { path: '/' })
|
|
18
|
+
return json({ message: 'Logout successful.' })
|
|
19
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { error, json } from '@sveltejs/kit'
|
|
2
|
+
import type { RequestHandler } from './$types'
|
|
3
|
+
import type { MailDataRequired } from '@sendgrid/mail'
|
|
4
|
+
import jwt from 'jsonwebtoken'
|
|
5
|
+
import { JWT_SECRET, DOMAIN, SENDGRID_SENDER } from '$env/static/private'
|
|
6
|
+
import { query } from '$lib/server/db'
|
|
7
|
+
import { sendMessage } from '$lib/server/sendgrid'
|
|
8
|
+
|
|
9
|
+
export const POST: RequestHandler = async event => {
|
|
10
|
+
let body: { email?: string; password?: string; firstName?: string; lastName?: string }
|
|
11
|
+
try {
|
|
12
|
+
body = await event.request.json()
|
|
13
|
+
} catch {
|
|
14
|
+
error(400, 'Invalid request body.')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (!body.email || !body.password || !body.firstName || !body.lastName)
|
|
18
|
+
error(400, 'Please supply all required fields: email, password, first and last name.')
|
|
19
|
+
|
|
20
|
+
let result
|
|
21
|
+
try {
|
|
22
|
+
const sql = `SELECT register($1) AS "authenticationResult";`
|
|
23
|
+
result = await query(sql, [JSON.stringify(body)])
|
|
24
|
+
} catch {
|
|
25
|
+
error(503, 'Could not communicate with database.')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { authenticationResult }: { authenticationResult: AuthenticationResult } = result.rows[0]
|
|
29
|
+
|
|
30
|
+
if (!authenticationResult.user)
|
|
31
|
+
error(authenticationResult.statusCode, authenticationResult.status)
|
|
32
|
+
|
|
33
|
+
// The DB auto-creates a session, but the user must verify email before logging in. Clean it up.
|
|
34
|
+
try {
|
|
35
|
+
await query(`CALL delete_session($1);`, [authenticationResult.sessionId])
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error('Failed to delete pre-verification session:', err)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const token = jwt.sign(
|
|
41
|
+
{ subject: authenticationResult.user.id, purpose: 'verify-email' },
|
|
42
|
+
JWT_SECRET as jwt.Secret,
|
|
43
|
+
{ expiresIn: '24h' }
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const message: MailDataRequired = {
|
|
47
|
+
to: { email: body.email },
|
|
48
|
+
from: SENDGRID_SENDER,
|
|
49
|
+
subject: 'Verify your email address',
|
|
50
|
+
categories: ['account'],
|
|
51
|
+
html: `
|
|
52
|
+
<p>Thanks for registering! Please verify your email address by clicking the link below:</p>
|
|
53
|
+
<p><a href="${DOMAIN}/auth/verify/${token}">Verify my email address</a></p>
|
|
54
|
+
<p>This link expires in 24 hours. If you did not register, you can safely ignore this email.</p>
|
|
55
|
+
`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await sendMessage(message)
|
|
59
|
+
|
|
60
|
+
return json({
|
|
61
|
+
message: 'Registration successful. Please check your email to verify your account.',
|
|
62
|
+
emailVerification: true
|
|
63
|
+
})
|
|
64
|
+
}
|
|
@@ -18,6 +18,13 @@ export const PUT: RequestHandler = async event => {
|
|
|
18
18
|
const sql = `CALL reset_password($1, $2);`
|
|
19
19
|
await query(sql, [userId, password])
|
|
20
20
|
|
|
21
|
+
// Invalidate all existing sessions so the old password can no longer be used
|
|
22
|
+
try {
|
|
23
|
+
await query(`CALL delete_session($1);`, [userId])
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error('Failed to invalidate sessions after password reset:', err)
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
return json({
|
|
22
29
|
message: 'Password successfully reset.'
|
|
23
30
|
})
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import type { PageData } from './$types'
|
|
3
3
|
import { onMount } from 'svelte'
|
|
4
4
|
import { goto } from '$app/navigation'
|
|
5
|
-
import {
|
|
5
|
+
import { appState } from '$lib/app-state.svelte'
|
|
6
6
|
import { focusOnFirstError } from '$lib/focus'
|
|
7
7
|
|
|
8
8
|
interface Props {
|
|
@@ -12,13 +12,17 @@
|
|
|
12
12
|
let { data }: Props = $props()
|
|
13
13
|
|
|
14
14
|
let focusedField: HTMLInputElement | undefined = $state()
|
|
15
|
+
let formEl: HTMLFormElement | undefined = $state()
|
|
15
16
|
let password = $state('')
|
|
16
17
|
let confirmPassword: HTMLInputElement | undefined = $state()
|
|
17
18
|
let message = $state('')
|
|
18
19
|
let submitted = $state(false)
|
|
19
20
|
let passwordMismatch = $state(false)
|
|
21
|
+
let loading = $state(false)
|
|
20
22
|
|
|
21
23
|
onMount(() => {
|
|
24
|
+
// Remove the token from the URL to prevent it appearing in logs and Referer headers
|
|
25
|
+
history.replaceState('', document.title, '/auth/reset')
|
|
22
26
|
focusedField?.focus()
|
|
23
27
|
})
|
|
24
28
|
|
|
@@ -31,7 +35,7 @@
|
|
|
31
35
|
message = ''
|
|
32
36
|
submitted = false
|
|
33
37
|
passwordMismatch = false
|
|
34
|
-
const form =
|
|
38
|
+
const form = formEl!
|
|
35
39
|
|
|
36
40
|
if (!passwordMatch()) {
|
|
37
41
|
passwordMismatch = true
|
|
@@ -39,30 +43,35 @@
|
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
if (form.checkValidity()) {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
loading = true
|
|
47
|
+
try {
|
|
48
|
+
const url = `/auth/reset`
|
|
49
|
+
const res = await fetch(url, {
|
|
50
|
+
method: 'PUT',
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'application/json'
|
|
53
|
+
},
|
|
54
|
+
body: JSON.stringify({
|
|
55
|
+
token: data.token,
|
|
56
|
+
password
|
|
57
|
+
})
|
|
51
58
|
})
|
|
52
|
-
})
|
|
53
59
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
60
|
+
if (res.ok) {
|
|
61
|
+
appState.toast = {
|
|
62
|
+
title: 'Password Reset Successful',
|
|
63
|
+
body: 'Your password was reset. Please login.',
|
|
64
|
+
isOpen: true
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
goto('/login')
|
|
68
|
+
} else {
|
|
69
|
+
const body = await res.json()
|
|
70
|
+
console.log('Failed reset', body)
|
|
71
|
+
message = body.message
|
|
59
72
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
} else {
|
|
63
|
-
const body = await res.json()
|
|
64
|
-
console.log('Failed reset', body)
|
|
65
|
-
message = body.message
|
|
73
|
+
} finally {
|
|
74
|
+
loading = false
|
|
66
75
|
}
|
|
67
76
|
} else {
|
|
68
77
|
submitted = true
|
|
@@ -76,19 +85,20 @@
|
|
|
76
85
|
</svelte:head>
|
|
77
86
|
|
|
78
87
|
<form
|
|
79
|
-
|
|
88
|
+
bind:this={formEl}
|
|
80
89
|
autocomplete="on"
|
|
81
90
|
novalidate
|
|
82
91
|
class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
|
|
83
92
|
class:submitted
|
|
93
|
+
onsubmit={(e) => { e.preventDefault(); resetPassword() }}
|
|
84
94
|
>
|
|
85
|
-
<h4
|
|
95
|
+
<h4>New Password</h4>
|
|
86
96
|
<p>Please provide a new password.</p>
|
|
87
97
|
|
|
88
98
|
<label class="tw:block tw:text-sm tw:font-medium" for="password">
|
|
89
99
|
Password
|
|
90
100
|
<input
|
|
91
|
-
class="
|
|
101
|
+
class="form-input-validated"
|
|
92
102
|
id="password"
|
|
93
103
|
type="password"
|
|
94
104
|
bind:value={password}
|
|
@@ -99,7 +109,7 @@
|
|
|
99
109
|
placeholder="Password"
|
|
100
110
|
autocomplete="new-password"
|
|
101
111
|
/>
|
|
102
|
-
<span class="
|
|
112
|
+
<span class="form-error">Password with 8 chars or more required</span>
|
|
103
113
|
<span class="tw:text-xs tw:text-gray-500">
|
|
104
114
|
Minimum 8 characters, one capital letter, one number, one special character.
|
|
105
115
|
</span>
|
|
@@ -108,7 +118,7 @@
|
|
|
108
118
|
<label class="tw:block tw:text-sm tw:font-medium" for="passwordConfirm">
|
|
109
119
|
Password (retype)
|
|
110
120
|
<input
|
|
111
|
-
class="
|
|
121
|
+
class="form-input"
|
|
112
122
|
class:tw:border-red-500={passwordMismatch}
|
|
113
123
|
id="passwordConfirm"
|
|
114
124
|
type="password"
|
|
@@ -129,11 +139,11 @@
|
|
|
129
139
|
{/if}
|
|
130
140
|
|
|
131
141
|
<button
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
142
|
+
type="submit"
|
|
143
|
+
class="btn-primary"
|
|
144
|
+
disabled={loading}
|
|
135
145
|
>
|
|
136
|
-
Reset Password
|
|
146
|
+
{loading ? 'Resetting...' : 'Reset Password'}
|
|
137
147
|
</button>
|
|
138
148
|
</form>
|
|
139
149
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { redirect } from '@sveltejs/kit'
|
|
2
|
+
import type { RequestHandler } from './$types'
|
|
3
|
+
import type { JwtPayload } from 'jsonwebtoken'
|
|
4
|
+
import jwt from 'jsonwebtoken'
|
|
5
|
+
import { JWT_SECRET } from '$env/static/private'
|
|
6
|
+
import { query } from '$lib/server/db'
|
|
7
|
+
|
|
8
|
+
// Handles email verification links sent after registration
|
|
9
|
+
export const GET: RequestHandler = async event => {
|
|
10
|
+
const { token } = event.params
|
|
11
|
+
const { cookies } = event
|
|
12
|
+
|
|
13
|
+
// Verify the JWT — extract userId only if token is valid and correct purpose
|
|
14
|
+
let userId: string | undefined
|
|
15
|
+
try {
|
|
16
|
+
const decoded = jwt.verify(token, JWT_SECRET as jwt.Secret) as JwtPayload
|
|
17
|
+
if (decoded.purpose === 'verify-email' && decoded.subject) {
|
|
18
|
+
userId = decoded.subject
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// Expired or tampered token — fall through to redirect below
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!userId) redirect(302, '/login?error=invalid-token')
|
|
25
|
+
|
|
26
|
+
// Mark email as verified and create a session atomically
|
|
27
|
+
let sessionId: string | undefined
|
|
28
|
+
try {
|
|
29
|
+
const { rows } = await query<{ verify_email_and_create_session: string }>(
|
|
30
|
+
`SELECT verify_email_and_create_session($1)`,
|
|
31
|
+
[userId]
|
|
32
|
+
)
|
|
33
|
+
sessionId = rows[0]?.verify_email_and_create_session
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error('Email verification DB error:', err)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!sessionId) redirect(302, '/login?error=verification-failed')
|
|
39
|
+
|
|
40
|
+
cookies.set('session', sessionId, {
|
|
41
|
+
httpOnly: true,
|
|
42
|
+
sameSite: 'lax',
|
|
43
|
+
secure: true,
|
|
44
|
+
path: '/'
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
redirect(302, '/')
|
|
48
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte'
|
|
3
3
|
import { goto } from '$app/navigation'
|
|
4
|
-
import {
|
|
4
|
+
import { appState } from '$lib/app-state.svelte'
|
|
5
5
|
import { focusOnFirstError } from '$lib/focus'
|
|
6
6
|
|
|
7
7
|
let focusedField: HTMLInputElement | undefined = $state()
|
|
8
|
+
let formEl: HTMLFormElement | undefined = $state()
|
|
8
9
|
let email: string = $state('')
|
|
9
10
|
let message: string = $state('')
|
|
10
11
|
let submitted = $state(false)
|
|
12
|
+
let loading = $state(false)
|
|
11
13
|
|
|
12
14
|
onMount(() => {
|
|
13
15
|
focusedField?.focus()
|
|
@@ -15,28 +17,33 @@
|
|
|
15
17
|
|
|
16
18
|
const sendPasswordReset = async () => {
|
|
17
19
|
message = ''
|
|
18
|
-
const form =
|
|
20
|
+
const form = formEl!
|
|
19
21
|
|
|
20
22
|
if (form.checkValidity()) {
|
|
21
23
|
if (email.toLowerCase().includes('gmail.com')) {
|
|
22
24
|
return (message = 'Gmail passwords must be reset on Manage Your Google Account.')
|
|
23
25
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
loading = true
|
|
27
|
+
try {
|
|
28
|
+
const url = `/auth/forgot`
|
|
29
|
+
const res = await fetch(url, {
|
|
30
|
+
method: 'POST',
|
|
31
|
+
headers: {
|
|
32
|
+
'Content-Type': 'application/json'
|
|
33
|
+
},
|
|
34
|
+
body: JSON.stringify({ email })
|
|
35
|
+
})
|
|
32
36
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
if (res.ok) {
|
|
38
|
+
appState.toast = {
|
|
39
|
+
title: 'Password Reset',
|
|
40
|
+
body: 'Please check your inbox for a password reset email (junk mail, too).',
|
|
41
|
+
isOpen: true
|
|
42
|
+
}
|
|
43
|
+
return goto('/')
|
|
38
44
|
}
|
|
39
|
-
|
|
45
|
+
} finally {
|
|
46
|
+
loading = false
|
|
40
47
|
}
|
|
41
48
|
} else {
|
|
42
49
|
submitted = true
|
|
@@ -50,13 +57,14 @@
|
|
|
50
57
|
</svelte:head>
|
|
51
58
|
|
|
52
59
|
<form
|
|
53
|
-
|
|
60
|
+
bind:this={formEl}
|
|
54
61
|
autocomplete="on"
|
|
55
62
|
novalidate
|
|
56
63
|
class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
|
|
57
64
|
class:submitted
|
|
65
|
+
onsubmit={(e) => { e.preventDefault(); sendPasswordReset() }}
|
|
58
66
|
>
|
|
59
|
-
<h4
|
|
67
|
+
<h4>Forgot password</h4>
|
|
60
68
|
<p>Hey, you're human. We get it.</p>
|
|
61
69
|
|
|
62
70
|
<label class="tw:block tw:text-sm tw:font-medium" for="email">
|
|
@@ -66,12 +74,12 @@
|
|
|
66
74
|
bind:value={email}
|
|
67
75
|
type="email"
|
|
68
76
|
id="email"
|
|
69
|
-
class="
|
|
77
|
+
class="form-input-validated"
|
|
70
78
|
required
|
|
71
79
|
placeholder="Email"
|
|
72
80
|
autocomplete="email"
|
|
73
81
|
/>
|
|
74
|
-
<span class="
|
|
82
|
+
<span class="form-error">Email address required</span>
|
|
75
83
|
</label>
|
|
76
84
|
|
|
77
85
|
{#if message}
|
|
@@ -79,11 +87,11 @@
|
|
|
79
87
|
{/if}
|
|
80
88
|
|
|
81
89
|
<button
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
type="submit"
|
|
91
|
+
class="btn-primary"
|
|
92
|
+
disabled={loading}
|
|
85
93
|
>
|
|
86
|
-
Send Email
|
|
94
|
+
{loading ? 'Sending...' : 'Send Email'}
|
|
87
95
|
</button>
|
|
88
96
|
</form>
|
|
89
97
|
|
package/src/routes/layout.css
CHANGED
|
@@ -12,6 +12,56 @@
|
|
|
12
12
|
|
|
13
13
|
html,
|
|
14
14
|
:host {
|
|
15
|
-
@apply tw:font-sans tw:text-gray-800;
|
|
15
|
+
@apply tw:font-sans tw:text-gray-800 tw:bg-white;
|
|
16
|
+
@variant dark {
|
|
17
|
+
@apply tw:text-gray-100 tw:bg-gray-900;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@layer components {
|
|
23
|
+
.form-input,
|
|
24
|
+
.form-input-validated {
|
|
25
|
+
display: block;
|
|
26
|
+
width: 100%;
|
|
27
|
+
margin-top: 0.25rem;
|
|
28
|
+
appearance: none;
|
|
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;
|
|
31
|
+
padding: 0.375rem 0.75rem;
|
|
32
|
+
font-size: var(--text-sm, 0.875rem);
|
|
33
|
+
line-height: var(--tw-leading-sm, 1.25rem);
|
|
34
|
+
}
|
|
35
|
+
.form-input:focus,
|
|
36
|
+
.form-input-validated:focus {
|
|
37
|
+
outline: none;
|
|
38
|
+
box-shadow: 0 0 0 2px var(--color-blue-500, #3b82f6);
|
|
39
|
+
}
|
|
40
|
+
/* peer class is applied at the HTML element level and cannot be set via CSS */
|
|
41
|
+
.submitted .form-input-validated:invalid {
|
|
42
|
+
border-color: var(--color-red-500, #ef4444);
|
|
43
|
+
}
|
|
44
|
+
.form-error {
|
|
45
|
+
display: none;
|
|
46
|
+
font-size: var(--text-xs, 0.75rem);
|
|
47
|
+
color: var(--color-red-600, #dc2626);
|
|
48
|
+
margin-top: 0.125rem;
|
|
49
|
+
}
|
|
50
|
+
.submitted .form-input-validated:invalid ~ .form-error {
|
|
51
|
+
display: block;
|
|
52
|
+
}
|
|
53
|
+
.btn-primary {
|
|
54
|
+
display: block;
|
|
55
|
+
width: 100%;
|
|
56
|
+
border-radius: var(--radius, 0.25rem);
|
|
57
|
+
background-color: var(--color-blue-600, #2563eb);
|
|
58
|
+
padding: 0.5rem 1rem;
|
|
59
|
+
font-weight: 600;
|
|
60
|
+
color: white;
|
|
61
|
+
border: none;
|
|
62
|
+
cursor: pointer;
|
|
63
|
+
}
|
|
64
|
+
.btn-primary:hover {
|
|
65
|
+
background-color: var(--color-blue-700, #1d4ed8);
|
|
16
66
|
}
|
|
17
67
|
}
|