sveltekit-auth-example 5.0.4 → 5.1.1
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/.editorconfig +9 -3
- package/{.env.sample → .env.example} +1 -0
- package/.prettierignore +1 -1
- package/.vscode/mcp.json +13 -0
- package/.vscode/settings.json +7 -5
- package/.yarn/releases/yarn-4.13.0.cjs +940 -0
- package/.yarnrc.yml +1 -1
- package/AGENTS.md +23 -0
- package/CHANGELOG.md +8 -0
- package/README.md +2 -3
- package/db_create.sql +98 -49
- package/{eslint.config.js → eslint.config.mjs} +4 -3
- package/package.json +34 -32
- package/playwright.config.ts +24 -0
- package/prettier.config.mjs +14 -5
- package/src/app.d.ts +1 -1
- package/src/app.html +1 -1
- package/src/hooks.server.ts +47 -9
- package/src/lib/app-state.svelte.ts +19 -0
- package/src/lib/auth-redirect.ts +25 -0
- package/src/lib/google.ts +7 -26
- package/src/lib/server/db.ts +63 -10
- package/src/lib/server/sendgrid.ts +5 -9
- package/src/routes/+error.svelte +3 -3
- package/src/routes/+layout.svelte +91 -125
- 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 +102 -84
- package/src/routes/auth/verify/[token]/+server.ts +48 -0
- package/src/routes/forgot/+page.svelte +64 -54
- package/src/routes/layout.css +63 -0
- package/src/routes/login/+page.server.ts +9 -0
- package/src/routes/login/+page.svelte +73 -115
- package/src/routes/profile/+page.svelte +174 -123
- package/src/routes/register/+page.svelte +147 -125
- package/src/service-worker.ts +22 -4
- package/svelte.config.js +13 -1
- package/tsconfig.json +3 -1
- package/vite.config.ts +5 -1
- package/.yarn/releases/yarn-4.9.2.cjs +0 -942
- package/src/stores.ts +0 -13
|
@@ -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,11 +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('')
|
|
19
|
+
let submitted = $state(false)
|
|
20
|
+
let passwordMismatch = $state(false)
|
|
21
|
+
let loading = $state(false)
|
|
18
22
|
|
|
19
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')
|
|
20
26
|
focusedField?.focus()
|
|
21
27
|
})
|
|
22
28
|
|
|
@@ -27,40 +33,48 @@
|
|
|
27
33
|
|
|
28
34
|
const resetPassword = async () => {
|
|
29
35
|
message = ''
|
|
30
|
-
|
|
36
|
+
submitted = false
|
|
37
|
+
passwordMismatch = false
|
|
38
|
+
const form = formEl!
|
|
31
39
|
|
|
32
40
|
if (!passwordMatch()) {
|
|
33
|
-
|
|
41
|
+
passwordMismatch = true
|
|
42
|
+
return
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
if (form.checkValidity()) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
})
|
|
46
58
|
})
|
|
47
|
-
})
|
|
48
59
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
60
|
+
if (res.ok) {
|
|
61
|
+
appState.toast = {
|
|
62
|
+
title: 'Password Reset Successful',
|
|
63
|
+
body: 'Your password was reset. Please login.',
|
|
64
|
+
isOpen: true
|
|
65
|
+
}
|
|
55
66
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
67
|
+
goto('/login')
|
|
68
|
+
} else {
|
|
69
|
+
const body = await res.json()
|
|
70
|
+
console.log('Failed reset', body)
|
|
71
|
+
message = body.message
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
loading = false
|
|
61
75
|
}
|
|
62
76
|
} else {
|
|
63
|
-
|
|
77
|
+
submitted = true
|
|
64
78
|
focusOnFirstError(form)
|
|
65
79
|
}
|
|
66
80
|
}
|
|
@@ -70,62 +84,66 @@
|
|
|
70
84
|
<title>New Password</title>
|
|
71
85
|
</svelte:head>
|
|
72
86
|
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
<
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
87
|
+
<form
|
|
88
|
+
bind:this={formEl}
|
|
89
|
+
autocomplete="on"
|
|
90
|
+
novalidate
|
|
91
|
+
class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
|
|
92
|
+
class:submitted
|
|
93
|
+
onsubmit={(e) => { e.preventDefault(); resetPassword() }}
|
|
94
|
+
>
|
|
95
|
+
<h4>New Password</h4>
|
|
96
|
+
<p>Please provide a new password.</p>
|
|
97
|
+
|
|
98
|
+
<label class="tw:block tw:text-sm tw:font-medium" for="password">
|
|
99
|
+
Password
|
|
100
|
+
<input
|
|
101
|
+
class="form-input-validated"
|
|
102
|
+
id="password"
|
|
103
|
+
type="password"
|
|
104
|
+
bind:value={password}
|
|
105
|
+
bind:this={focusedField}
|
|
106
|
+
required
|
|
107
|
+
minlength="8"
|
|
108
|
+
maxlength="80"
|
|
109
|
+
placeholder="Password"
|
|
110
|
+
autocomplete="new-password"
|
|
111
|
+
/>
|
|
112
|
+
<span class="form-error">Password with 8 chars or more required</span>
|
|
113
|
+
<span class="tw:text-xs tw:text-gray-500">
|
|
114
|
+
Minimum 8 characters, one capital letter, one number, one special character.
|
|
115
|
+
</span>
|
|
116
|
+
</label>
|
|
117
|
+
|
|
118
|
+
<label class="tw:block tw:text-sm tw:font-medium" for="passwordConfirm">
|
|
119
|
+
Password (retype)
|
|
120
|
+
<input
|
|
121
|
+
class="form-input"
|
|
122
|
+
class:tw:border-red-500={passwordMismatch}
|
|
123
|
+
id="passwordConfirm"
|
|
124
|
+
type="password"
|
|
125
|
+
required={!!password}
|
|
126
|
+
bind:this={confirmPassword}
|
|
127
|
+
minlength="8"
|
|
128
|
+
maxlength="80"
|
|
129
|
+
placeholder="Password (again)"
|
|
130
|
+
autocomplete="new-password"
|
|
131
|
+
/>
|
|
132
|
+
{#if passwordMismatch}
|
|
133
|
+
<span class="tw:text-xs tw:text-red-600 tw:mt-0.5">Passwords must match</span>
|
|
134
|
+
{/if}
|
|
135
|
+
</label>
|
|
136
|
+
|
|
137
|
+
{#if message}
|
|
138
|
+
<p class="tw:text-red-600">{message}</p>
|
|
139
|
+
{/if}
|
|
140
|
+
|
|
141
|
+
<button
|
|
142
|
+
type="submit"
|
|
143
|
+
class="btn-primary"
|
|
144
|
+
disabled={loading}
|
|
145
|
+
>
|
|
146
|
+
{loading ? 'Resetting...' : 'Reset Password'}
|
|
147
|
+
</button>
|
|
148
|
+
</form>
|
|
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,12 +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('')
|
|
11
|
+
let submitted = $state(false)
|
|
12
|
+
let loading = $state(false)
|
|
10
13
|
|
|
11
14
|
onMount(() => {
|
|
12
15
|
focusedField?.focus()
|
|
@@ -14,31 +17,36 @@
|
|
|
14
17
|
|
|
15
18
|
const sendPasswordReset = async () => {
|
|
16
19
|
message = ''
|
|
17
|
-
const form =
|
|
20
|
+
const form = formEl!
|
|
18
21
|
|
|
19
22
|
if (form.checkValidity()) {
|
|
20
23
|
if (email.toLowerCase().includes('gmail.com')) {
|
|
21
24
|
return (message = 'Gmail passwords must be reset on Manage Your Google Account.')
|
|
22
25
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
+
})
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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('/')
|
|
37
44
|
}
|
|
38
|
-
|
|
45
|
+
} finally {
|
|
46
|
+
loading = false
|
|
39
47
|
}
|
|
40
48
|
} else {
|
|
41
|
-
|
|
49
|
+
submitted = true
|
|
42
50
|
focusOnFirstError(form)
|
|
43
51
|
}
|
|
44
52
|
}
|
|
@@ -48,41 +56,43 @@
|
|
|
48
56
|
<title>Forgot Password</title>
|
|
49
57
|
</svelte:head>
|
|
50
58
|
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
59
|
+
<form
|
|
60
|
+
bind:this={formEl}
|
|
61
|
+
autocomplete="on"
|
|
62
|
+
novalidate
|
|
63
|
+
class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
|
|
64
|
+
class:submitted
|
|
65
|
+
onsubmit={(e) => { e.preventDefault(); sendPasswordReset() }}
|
|
66
|
+
>
|
|
67
|
+
<h4>Forgot password</h4>
|
|
68
|
+
<p>Hey, you're human. We get it.</p>
|
|
69
|
+
|
|
70
|
+
<label class="tw:block tw:text-sm tw:font-medium" for="email">
|
|
71
|
+
Email
|
|
72
|
+
<input
|
|
73
|
+
bind:this={focusedField}
|
|
74
|
+
bind:value={email}
|
|
75
|
+
type="email"
|
|
76
|
+
id="email"
|
|
77
|
+
class="form-input-validated"
|
|
78
|
+
required
|
|
79
|
+
placeholder="Email"
|
|
80
|
+
autocomplete="email"
|
|
81
|
+
/>
|
|
82
|
+
<span class="form-error">Email address required</span>
|
|
83
|
+
</label>
|
|
84
|
+
|
|
85
|
+
{#if message}
|
|
86
|
+
<p class="tw:text-red-600">{message}</p>
|
|
87
|
+
{/if}
|
|
88
|
+
|
|
89
|
+
<button
|
|
90
|
+
type="submit"
|
|
91
|
+
class="btn-primary"
|
|
92
|
+
disabled={loading}
|
|
93
|
+
>
|
|
94
|
+
{loading ? 'Sending...' : 'Send Email'}
|
|
95
|
+
</button>
|
|
96
|
+
</form>
|
|
97
|
+
|
|
83
98
|
|
|
84
|
-
<style>
|
|
85
|
-
.card-body {
|
|
86
|
-
width: 25rem;
|
|
87
|
-
}
|
|
88
|
-
</style>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
@import 'tailwindcss' prefix(tw);
|
|
2
|
+
|
|
3
|
+
@theme {
|
|
4
|
+
--font-sans: 'Avenir Next', 'Avenir', 'Myriad Pro', 'Helvetica Neue', sans-serif;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
@layer base {
|
|
8
|
+
* {
|
|
9
|
+
-webkit-font-smoothing: antialiased;
|
|
10
|
+
-moz-osx-font-smoothing: grayscale;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
html,
|
|
14
|
+
:host {
|
|
15
|
+
@apply tw:font-sans tw:text-gray-800;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@layer components {
|
|
20
|
+
.form-input,
|
|
21
|
+
.form-input-validated {
|
|
22
|
+
display: block;
|
|
23
|
+
width: 100%;
|
|
24
|
+
margin-top: 0.25rem;
|
|
25
|
+
border-radius: var(--radius, 0.25rem);
|
|
26
|
+
border: 1px solid var(--color-gray-300, #d1d5db);
|
|
27
|
+
padding: 0.375rem 0.75rem;
|
|
28
|
+
font-size: var(--text-sm, 0.875rem);
|
|
29
|
+
line-height: var(--tw-leading-sm, 1.25rem);
|
|
30
|
+
}
|
|
31
|
+
.form-input:focus,
|
|
32
|
+
.form-input-validated:focus {
|
|
33
|
+
outline: none;
|
|
34
|
+
box-shadow: 0 0 0 2px var(--color-blue-500, #3b82f6);
|
|
35
|
+
}
|
|
36
|
+
/* peer class is applied at the HTML element level and cannot be set via CSS */
|
|
37
|
+
.submitted .form-input-validated:invalid {
|
|
38
|
+
border-color: var(--color-red-500, #ef4444);
|
|
39
|
+
}
|
|
40
|
+
.form-error {
|
|
41
|
+
display: none;
|
|
42
|
+
font-size: var(--text-xs, 0.75rem);
|
|
43
|
+
color: var(--color-red-600, #dc2626);
|
|
44
|
+
margin-top: 0.125rem;
|
|
45
|
+
}
|
|
46
|
+
.submitted .form-input-validated:invalid ~ .form-error {
|
|
47
|
+
display: block;
|
|
48
|
+
}
|
|
49
|
+
.btn-primary {
|
|
50
|
+
display: block;
|
|
51
|
+
width: 100%;
|
|
52
|
+
border-radius: var(--radius, 0.25rem);
|
|
53
|
+
background-color: var(--color-blue-600, #2563eb);
|
|
54
|
+
padding: 0.5rem 1rem;
|
|
55
|
+
font-weight: 600;
|
|
56
|
+
color: white;
|
|
57
|
+
border: none;
|
|
58
|
+
cursor: pointer;
|
|
59
|
+
}
|
|
60
|
+
.btn-primary:hover {
|
|
61
|
+
background-color: var(--color-blue-700, #1d4ed8);
|
|
62
|
+
}
|
|
63
|
+
}
|