sveltekit-auth-example 5.0.3 → 5.1.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/.editorconfig +9 -3
- package/.env.sample +7 -0
- package/.prettierignore +9 -0
- 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 +2 -0
- package/AGENTS.md +23 -0
- package/CHANGELOG.md +11 -0
- package/README.md +2 -3
- package/db_create.sql +53 -24
- package/eslint.config.mjs +48 -0
- package/package.json +37 -34
- package/playwright.config.ts +24 -0
- package/prettier.config.mjs +14 -5
- package/src/app.html +1 -1
- package/src/hooks.server.ts +8 -4
- package/src/lib/server/db.ts +62 -10
- package/src/routes/+error.svelte +3 -3
- package/src/routes/+layout.svelte +76 -125
- package/src/routes/auth/reset/[token]/+page.svelte +69 -61
- package/src/routes/forgot/+page.svelte +40 -38
- package/src/routes/layout.css +17 -0
- package/src/routes/login/+page.svelte +63 -98
- package/src/routes/profile/+page.svelte +123 -111
- package/src/routes/register/+page.svelte +124 -113
- package/svelte.config.js +13 -1
- package/tsconfig.json +3 -1
- package/vite.config.ts +8 -1
- package/.eslintignore +0 -14
- package/.git-blame-ignore-revs +0 -2
- package/eslint.config.js +0 -16
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { loginSession, toast } from '../stores'
|
|
6
6
|
import { initializeGoogleAccounts } from '$lib/google'
|
|
7
7
|
|
|
8
|
-
import '
|
|
8
|
+
import './layout.css'
|
|
9
9
|
|
|
10
10
|
interface Props {
|
|
11
11
|
data: LayoutServerData
|
|
@@ -14,167 +14,118 @@
|
|
|
14
14
|
|
|
15
15
|
let { data, children }: Props = $props()
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
$effect(() => {
|
|
18
|
+
$loginSession = data.user
|
|
19
|
+
})
|
|
20
20
|
|
|
21
|
-
let
|
|
21
|
+
let navOpen = $state(false)
|
|
22
|
+
let dropdownOpen = $state(false)
|
|
22
23
|
|
|
23
24
|
beforeNavigate(() => {
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
navOpen = false
|
|
26
|
+
dropdownOpen = false
|
|
27
|
+
const expirationDate = $loginSession?.expires ? new Date($loginSession.expires) : undefined
|
|
26
28
|
if (expirationDate && expirationDate < new Date()) {
|
|
27
29
|
console.log('Login session expired.')
|
|
28
30
|
$loginSession = null
|
|
29
31
|
}
|
|
30
32
|
})
|
|
31
33
|
|
|
32
|
-
onMount(
|
|
34
|
+
onMount(() => {
|
|
33
35
|
initializeGoogleAccounts()
|
|
34
|
-
|
|
35
|
-
await import('bootstrap/js/dist/collapse') // lots of ways to load Bootstrap but prefer this approach to avoid SSR issues
|
|
36
|
-
await import('bootstrap/js/dist/dropdown')
|
|
37
|
-
Toast = (await import('bootstrap/js/dist/toast')).default
|
|
38
|
-
|
|
39
36
|
if (!$loginSession) google.accounts.id.prompt()
|
|
40
37
|
})
|
|
41
38
|
|
|
42
39
|
async function logout(event: MouseEvent) {
|
|
43
40
|
event.preventDefault()
|
|
44
|
-
|
|
45
|
-
const url = '/auth/logout'
|
|
46
|
-
const res = await fetch(url, {
|
|
47
|
-
method: 'POST'
|
|
48
|
-
})
|
|
41
|
+
const res = await fetch('/auth/logout', { method: 'POST' })
|
|
49
42
|
if (res.ok) {
|
|
50
|
-
loginSession.set(undefined)
|
|
43
|
+
loginSession.set(undefined)
|
|
51
44
|
goto('/login')
|
|
52
45
|
} else console.error(`Logout not successful: ${res.statusText} (${res.status})`)
|
|
53
46
|
}
|
|
54
|
-
|
|
55
|
-
const openToast = (open: boolean) => {
|
|
56
|
-
if (open) {
|
|
57
|
-
const toastDiv = document.getElementById('authToast') as HTMLDivElement
|
|
58
|
-
const t = new Toast(toastDiv)
|
|
59
|
-
t.show()
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
$effect(() => {
|
|
64
|
-
openToast($toast.isOpen)
|
|
65
|
-
})
|
|
66
47
|
</script>
|
|
67
48
|
|
|
68
|
-
<nav class="
|
|
69
|
-
<div class="
|
|
70
|
-
<a class="
|
|
49
|
+
<nav class="tw:bg-gray-100 tw:border-b tw:border-gray-200">
|
|
50
|
+
<div class="tw:mx-auto tw:max-w-5xl tw:px-4 tw:flex tw:items-center tw:justify-between tw:h-14">
|
|
51
|
+
<a class="tw:font-semibold tw:text-gray-800 tw:no-underline" href="/">SvelteKit-Auth-Example</a>
|
|
52
|
+
|
|
53
|
+
<!-- Mobile toggle -->
|
|
71
54
|
<button
|
|
72
|
-
class="
|
|
73
|
-
type="button"
|
|
74
|
-
data-bs-toggle="collapse"
|
|
75
|
-
data-bs-target="#navbarMain"
|
|
76
|
-
aria-controls="navbarMain"
|
|
77
|
-
aria-expanded="false"
|
|
55
|
+
class="tw:sm:hidden tw:p-2 tw:rounded tw:text-gray-600 hover:tw:bg-gray-200"
|
|
78
56
|
aria-label="Toggle navigation"
|
|
57
|
+
onclick={() => (navOpen = !navOpen)}
|
|
79
58
|
>
|
|
80
|
-
<
|
|
59
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="tw:h-5 tw:w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
60
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
|
61
|
+
</svg>
|
|
81
62
|
</button>
|
|
82
|
-
<div class="collapse navbar-collapse" id="navbarMain">
|
|
83
|
-
<ul class="navbar-nav me-5">
|
|
84
|
-
<li class="nav-item"><a class="nav-link active" aria-current="page" href="/">Home</a></li>
|
|
85
|
-
<li class="nav-item"><a class="nav-link" href="/info">Info</a></li>
|
|
86
63
|
|
|
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
|
-
<ul class="dropdown-menu">
|
|
123
|
-
<li>
|
|
124
|
-
<a class="dropdown-item" href="/profile">Profile</a>
|
|
125
|
-
</li>
|
|
126
|
-
<li>
|
|
127
|
-
<a
|
|
128
|
-
onclick={logout}
|
|
129
|
-
class="dropdown-item"
|
|
130
|
-
class:d-none={!$loginSession || $loginSession.id === 0}
|
|
131
|
-
href={'#'}>Logout</a
|
|
132
|
-
>
|
|
133
|
-
</li>
|
|
64
|
+
<!-- Nav links -->
|
|
65
|
+
<div class="tw:hidden tw:sm:flex tw:items-center tw:gap-6 {navOpen ? '!tw:flex tw:flex-col tw:absolute tw:top-14 tw:left-0 tw:right-0 tw:bg-gray-100 tw:p-4 tw:border-b tw:border-gray-200' : ''}">
|
|
66
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/">Home</a>
|
|
67
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/info">Info</a>
|
|
68
|
+
|
|
69
|
+
{#if $loginSession?.role === 'admin'}
|
|
70
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/admin">Admin</a>
|
|
71
|
+
{/if}
|
|
72
|
+
{#if $loginSession && $loginSession.role !== 'student'}
|
|
73
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/teachers">Teachers</a>
|
|
74
|
+
{/if}
|
|
75
|
+
|
|
76
|
+
{#if $loginSession}
|
|
77
|
+
<!-- User dropdown -->
|
|
78
|
+
<div class="tw:relative">
|
|
79
|
+
<button
|
|
80
|
+
class="tw:flex tw:items-center tw:gap-1 tw:text-sm tw:text-gray-700 hover:tw:text-gray-900 tw:bg-transparent tw:border-0 tw:cursor-pointer"
|
|
81
|
+
onclick={() => (dropdownOpen = !dropdownOpen)}
|
|
82
|
+
aria-expanded={dropdownOpen}
|
|
83
|
+
>
|
|
84
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" class="tw:relative tw:top-[-1.5px]">
|
|
85
|
+
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z" />
|
|
86
|
+
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z" />
|
|
87
|
+
</svg>
|
|
88
|
+
{$loginSession.firstName}
|
|
89
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
|
90
|
+
<path d="M7.247 11.14 2.451 5.658C1.885 5.013 2.345 4 3.204 4h9.592a1 1 0 0 1 .753 1.659l-4.796 5.48a1 1 0 0 1-1.506 0z"/>
|
|
91
|
+
</svg>
|
|
92
|
+
</button>
|
|
93
|
+
{#if dropdownOpen}
|
|
94
|
+
<ul class="tw:absolute tw:right-0 tw:mt-1 tw:w-36 tw:rounded tw:border tw:border-gray-200 tw:bg-white tw:shadow-md tw:py-1 tw:z-50 tw:list-none tw:p-0">
|
|
95
|
+
<li><a class="tw:block tw:px-4 tw:py-2 tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:bg-gray-100" href="/profile">Profile</a></li>
|
|
96
|
+
{#if $loginSession.id !== 0}
|
|
97
|
+
<li><a onclick={logout} class="tw:block tw:px-4 tw:py-2 tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:bg-gray-100" href="#">Logout</a></li>
|
|
98
|
+
{/if}
|
|
134
99
|
</ul>
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
{/if}
|
|
141
|
-
</ul>
|
|
100
|
+
{/if}
|
|
101
|
+
</div>
|
|
102
|
+
{:else}
|
|
103
|
+
<a class="tw:text-sm tw:text-gray-700 tw:no-underline hover:tw:text-gray-900" href="/login">Login</a>
|
|
104
|
+
{/if}
|
|
142
105
|
</div>
|
|
143
106
|
</div>
|
|
144
107
|
</nav>
|
|
145
108
|
|
|
146
|
-
<main class="
|
|
109
|
+
<main class="tw:mx-auto tw:max-w-5xl tw:px-4 tw:py-6">
|
|
147
110
|
{@render children?.()}
|
|
111
|
+
</main>
|
|
148
112
|
|
|
113
|
+
<!-- Toast notification -->
|
|
114
|
+
{#if $toast.isOpen}
|
|
149
115
|
<div
|
|
150
|
-
id="authToast"
|
|
151
|
-
class="toast position-fixed top-0 end-0 m-3"
|
|
152
116
|
role="alert"
|
|
153
117
|
aria-live="assertive"
|
|
154
118
|
aria-atomic="true"
|
|
119
|
+
class="tw:fixed tw:top-4 tw:right-4 tw:z-50 tw:min-w-64 tw:rounded tw:shadow-lg tw:border tw:border-gray-200 tw:bg-white tw:overflow-hidden"
|
|
155
120
|
>
|
|
156
|
-
<div class="
|
|
157
|
-
<strong class="
|
|
158
|
-
<button
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
121
|
+
<div class="tw:flex tw:items-center tw:justify-between tw:bg-blue-600 tw:px-4 tw:py-2">
|
|
122
|
+
<strong class="tw:text-white tw:text-sm">{$toast.title}</strong>
|
|
123
|
+
<button
|
|
124
|
+
type="button"
|
|
125
|
+
aria-label="Close"
|
|
126
|
+
class="tw:text-white tw:bg-transparent tw:border-0 tw:cursor-pointer tw:text-lg tw:leading-none"
|
|
127
|
+
onclick={() => ($toast = { ...$toast, isOpen: false })}>×</button>
|
|
162
128
|
</div>
|
|
129
|
+
<div class="tw:px-4 tw:py-3 tw:text-sm">{$toast.body}</div>
|
|
163
130
|
</div>
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
<style global>
|
|
167
|
-
* {
|
|
168
|
-
-webkit-font-smoothing: antialiased;
|
|
169
|
-
-moz-osx-font-smoothing: grayscale;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
.toast {
|
|
173
|
-
z-index: 9999;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
.avatar {
|
|
177
|
-
position: relative;
|
|
178
|
-
top: -1.5px;
|
|
179
|
-
}
|
|
180
|
-
</style>
|
|
131
|
+
{/if}
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
let password = $state('')
|
|
16
16
|
let confirmPassword: HTMLInputElement | undefined = $state()
|
|
17
17
|
let message = $state('')
|
|
18
|
+
let submitted = $state(false)
|
|
19
|
+
let passwordMismatch = $state(false)
|
|
18
20
|
|
|
19
21
|
onMount(() => {
|
|
20
22
|
focusedField?.focus()
|
|
@@ -27,10 +29,13 @@
|
|
|
27
29
|
|
|
28
30
|
const resetPassword = async () => {
|
|
29
31
|
message = ''
|
|
32
|
+
submitted = false
|
|
33
|
+
passwordMismatch = false
|
|
30
34
|
const form = document.getElementById('reset') as HTMLFormElement
|
|
31
35
|
|
|
32
36
|
if (!passwordMatch()) {
|
|
33
|
-
|
|
37
|
+
passwordMismatch = true
|
|
38
|
+
return
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
if (form.checkValidity()) {
|
|
@@ -60,7 +65,7 @@
|
|
|
60
65
|
message = body.message
|
|
61
66
|
}
|
|
62
67
|
} else {
|
|
63
|
-
|
|
68
|
+
submitted = true
|
|
64
69
|
focusOnFirstError(form)
|
|
65
70
|
}
|
|
66
71
|
}
|
|
@@ -70,62 +75,65 @@
|
|
|
70
75
|
<title>New Password</title>
|
|
71
76
|
</svelte:head>
|
|
72
77
|
|
|
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
|
-
|
|
78
|
+
<form
|
|
79
|
+
id="reset"
|
|
80
|
+
autocomplete="on"
|
|
81
|
+
novalidate
|
|
82
|
+
class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
|
|
83
|
+
class:submitted
|
|
84
|
+
>
|
|
85
|
+
<h4><strong>New Password</strong></h4>
|
|
86
|
+
<p>Please provide a new password.</p>
|
|
87
|
+
|
|
88
|
+
<label class="tw:block tw:text-sm tw:font-medium" for="password">
|
|
89
|
+
Password
|
|
90
|
+
<input
|
|
91
|
+
class="tw:peer tw:mt-1 tw:block tw:w-full tw:rounded tw:border tw:border-gray-300 tw:px-3 tw:py-1.5 tw:text-sm focus:tw:outline-none focus:tw:ring-2 focus:tw:ring-blue-500 tw:[.submitted_&]:invalid:border-red-500"
|
|
92
|
+
id="password"
|
|
93
|
+
type="password"
|
|
94
|
+
bind:value={password}
|
|
95
|
+
bind:this={focusedField}
|
|
96
|
+
required
|
|
97
|
+
minlength="8"
|
|
98
|
+
maxlength="80"
|
|
99
|
+
placeholder="Password"
|
|
100
|
+
autocomplete="new-password"
|
|
101
|
+
/>
|
|
102
|
+
<span class="tw:hidden tw:text-xs tw:text-red-600 tw:mt-0.5 tw:[.submitted_&]:peer-invalid:block">Password with 8 chars or more required</span>
|
|
103
|
+
<span class="tw:text-xs tw:text-gray-500">
|
|
104
|
+
Minimum 8 characters, one capital letter, one number, one special character.
|
|
105
|
+
</span>
|
|
106
|
+
</label>
|
|
107
|
+
|
|
108
|
+
<label class="tw:block tw:text-sm tw:font-medium" for="passwordConfirm">
|
|
109
|
+
Password (retype)
|
|
110
|
+
<input
|
|
111
|
+
class="tw:mt-1 tw:block tw:w-full tw:rounded tw:border tw:border-gray-300 tw:px-3 tw:py-1.5 tw:text-sm focus:tw:outline-none focus:tw:ring-2 focus:tw:ring-blue-500"
|
|
112
|
+
class:tw:border-red-500={passwordMismatch}
|
|
113
|
+
id="passwordConfirm"
|
|
114
|
+
type="password"
|
|
115
|
+
required={!!password}
|
|
116
|
+
bind:this={confirmPassword}
|
|
117
|
+
minlength="8"
|
|
118
|
+
maxlength="80"
|
|
119
|
+
placeholder="Password (again)"
|
|
120
|
+
autocomplete="new-password"
|
|
121
|
+
/>
|
|
122
|
+
{#if passwordMismatch}
|
|
123
|
+
<span class="tw:text-xs tw:text-red-600 tw:mt-0.5">Passwords must match</span>
|
|
124
|
+
{/if}
|
|
125
|
+
</label>
|
|
126
|
+
|
|
127
|
+
{#if message}
|
|
128
|
+
<p class="tw:text-red-600">{message}</p>
|
|
129
|
+
{/if}
|
|
130
|
+
|
|
131
|
+
<button
|
|
132
|
+
onclick={resetPassword}
|
|
133
|
+
type="button"
|
|
134
|
+
class="tw:w-full tw:rounded tw:bg-blue-600 tw:px-4 tw:py-2 tw:font-semibold tw:text-white hover:tw:bg-blue-700"
|
|
135
|
+
>
|
|
136
|
+
Reset Password
|
|
137
|
+
</button>
|
|
138
|
+
</form>
|
|
139
|
+
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
let focusedField: HTMLInputElement | undefined = $state()
|
|
8
8
|
let email: string = $state('')
|
|
9
9
|
let message: string = $state('')
|
|
10
|
+
let submitted = $state(false)
|
|
10
11
|
|
|
11
12
|
onMount(() => {
|
|
12
13
|
focusedField?.focus()
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
return goto('/')
|
|
39
40
|
}
|
|
40
41
|
} else {
|
|
41
|
-
|
|
42
|
+
submitted = true
|
|
42
43
|
focusOnFirstError(form)
|
|
43
44
|
}
|
|
44
45
|
}
|
|
@@ -48,41 +49,42 @@
|
|
|
48
49
|
<title>Forgot Password</title>
|
|
49
50
|
</svelte:head>
|
|
50
51
|
|
|
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
|
-
|
|
52
|
+
<form
|
|
53
|
+
id="forgot"
|
|
54
|
+
autocomplete="on"
|
|
55
|
+
novalidate
|
|
56
|
+
class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
|
|
57
|
+
class:submitted
|
|
58
|
+
>
|
|
59
|
+
<h4><strong>Forgot password</strong></h4>
|
|
60
|
+
<p>Hey, you're human. We get it.</p>
|
|
61
|
+
|
|
62
|
+
<label class="tw:block tw:text-sm tw:font-medium" for="email">
|
|
63
|
+
Email
|
|
64
|
+
<input
|
|
65
|
+
bind:this={focusedField}
|
|
66
|
+
bind:value={email}
|
|
67
|
+
type="email"
|
|
68
|
+
id="email"
|
|
69
|
+
class="tw:peer tw:mt-1 tw:block tw:w-full tw:rounded tw:border tw:border-gray-300 tw:px-3 tw:py-1.5 tw:text-sm focus:tw:outline-none focus:tw:ring-2 focus:tw:ring-blue-500 tw:[.submitted_&]:invalid:border-red-500"
|
|
70
|
+
required
|
|
71
|
+
placeholder="Email"
|
|
72
|
+
autocomplete="email"
|
|
73
|
+
/>
|
|
74
|
+
<span class="tw:hidden tw:text-xs tw:text-red-600 tw:mt-0.5 tw:[.submitted_&]:peer-invalid:block">Email address required</span>
|
|
75
|
+
</label>
|
|
76
|
+
|
|
77
|
+
{#if message}
|
|
78
|
+
<p class="tw:text-red-600">{message}</p>
|
|
79
|
+
{/if}
|
|
80
|
+
|
|
81
|
+
<button
|
|
82
|
+
onclick={sendPasswordReset}
|
|
83
|
+
type="button"
|
|
84
|
+
class="tw:w-full tw:rounded tw:bg-blue-600 tw:px-4 tw:py-2 tw:font-semibold tw:text-white hover:tw:bg-blue-700"
|
|
85
|
+
>
|
|
86
|
+
Send Email
|
|
87
|
+
</button>
|
|
88
|
+
</form>
|
|
89
|
+
|
|
83
90
|
|
|
84
|
-
<style>
|
|
85
|
-
.card-body {
|
|
86
|
-
width: 25rem;
|
|
87
|
-
}
|
|
88
|
-
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
}
|