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
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount } from 'svelte'
|
|
3
|
-
import {
|
|
4
|
-
import { page } from '$app/state'
|
|
5
|
-
import { loginSession } from '../../stores'
|
|
3
|
+
import { appState } from '$lib/app-state.svelte'
|
|
6
4
|
import { focusOnFirstError } from '$lib/focus'
|
|
7
5
|
import { initializeGoogleAccounts, renderGoogleButton } from '$lib/google'
|
|
6
|
+
import { redirectAfterLogin } from '$lib/auth-redirect'
|
|
8
7
|
|
|
9
8
|
let focusedField: HTMLInputElement | undefined = $state()
|
|
9
|
+
let formEl: HTMLFormElement | undefined = $state()
|
|
10
10
|
let message = $state('')
|
|
11
11
|
let submitted = $state(false)
|
|
12
|
+
let loading = $state(false)
|
|
12
13
|
const credentials: Credentials = $state({
|
|
13
14
|
email: '',
|
|
14
15
|
password: ''
|
|
@@ -17,7 +18,7 @@
|
|
|
17
18
|
async function login() {
|
|
18
19
|
message = ''
|
|
19
20
|
submitted = false
|
|
20
|
-
const form =
|
|
21
|
+
const form = formEl!
|
|
21
22
|
|
|
22
23
|
if (form.checkValidity()) {
|
|
23
24
|
try {
|
|
@@ -37,11 +38,11 @@
|
|
|
37
38
|
onMount(() => {
|
|
38
39
|
initializeGoogleAccounts()
|
|
39
40
|
renderGoogleButton()
|
|
40
|
-
|
|
41
41
|
focusedField?.focus()
|
|
42
42
|
})
|
|
43
43
|
|
|
44
44
|
async function loginLocal(credentials: Credentials) {
|
|
45
|
+
loading = true
|
|
45
46
|
try {
|
|
46
47
|
const res = await fetch('/auth/login', {
|
|
47
48
|
method: 'POST',
|
|
@@ -52,20 +53,8 @@
|
|
|
52
53
|
})
|
|
53
54
|
const fromEndpoint = await res.json()
|
|
54
55
|
if (res.ok) {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const referrer = page.url.searchParams.get('referrer')
|
|
58
|
-
if (referrer) goto(referrer)
|
|
59
|
-
switch (role) {
|
|
60
|
-
case 'teacher':
|
|
61
|
-
goto('/teachers')
|
|
62
|
-
break
|
|
63
|
-
case 'admin':
|
|
64
|
-
goto('/admin')
|
|
65
|
-
break
|
|
66
|
-
default:
|
|
67
|
-
goto('/')
|
|
68
|
-
}
|
|
56
|
+
appState.user = fromEndpoint.user
|
|
57
|
+
redirectAfterLogin(fromEndpoint.user)
|
|
69
58
|
} else {
|
|
70
59
|
throw new Error(fromEndpoint.message)
|
|
71
60
|
}
|
|
@@ -74,6 +63,8 @@
|
|
|
74
63
|
console.error('Login error', err)
|
|
75
64
|
message = err.message
|
|
76
65
|
}
|
|
66
|
+
} finally {
|
|
67
|
+
loading = false
|
|
77
68
|
}
|
|
78
69
|
}
|
|
79
70
|
</script>
|
|
@@ -84,16 +75,30 @@
|
|
|
84
75
|
</svelte:head>
|
|
85
76
|
|
|
86
77
|
<form
|
|
87
|
-
|
|
78
|
+
bind:this={formEl}
|
|
88
79
|
autocomplete="on"
|
|
89
80
|
novalidate
|
|
90
81
|
class="tw:mx-auto tw:mt-20 tw:max-w-sm tw:space-y-4"
|
|
91
82
|
class:submitted
|
|
83
|
+
onsubmit={(e) => { e.preventDefault(); login() }}
|
|
92
84
|
>
|
|
93
|
-
<h4
|
|
85
|
+
<h4>Sign In</h4>
|
|
94
86
|
<p>Welcome back.</p>
|
|
95
87
|
|
|
96
|
-
<div
|
|
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
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
97
102
|
|
|
98
103
|
<div class="tw:flex tw:items-center tw:gap-2 tw:text-gray-400 tw:text-sm">
|
|
99
104
|
<span class="tw:flex-1 tw:border-t tw:border-gray-300"></span>
|
|
@@ -104,21 +109,26 @@
|
|
|
104
109
|
<label class="tw:block tw:text-sm tw:font-medium" for="email">
|
|
105
110
|
Email
|
|
106
111
|
<input
|
|
112
|
+
id="email"
|
|
107
113
|
type="email"
|
|
108
|
-
class="
|
|
114
|
+
class="form-input-validated"
|
|
109
115
|
bind:this={focusedField}
|
|
110
116
|
bind:value={credentials.email}
|
|
111
117
|
required
|
|
112
118
|
placeholder="Email"
|
|
113
119
|
autocomplete="email"
|
|
114
120
|
/>
|
|
115
|
-
<span class="
|
|
121
|
+
<span class="form-error">Email address required</span>
|
|
116
122
|
</label>
|
|
117
123
|
|
|
118
|
-
<
|
|
119
|
-
|
|
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>
|
|
128
|
+
</div>
|
|
120
129
|
<input
|
|
121
|
-
|
|
130
|
+
id="password"
|
|
131
|
+
class="form-input-validated"
|
|
122
132
|
type="password"
|
|
123
133
|
bind:value={credentials.password}
|
|
124
134
|
required
|
|
@@ -127,20 +137,15 @@
|
|
|
127
137
|
placeholder="Password"
|
|
128
138
|
autocomplete="current-password"
|
|
129
139
|
/>
|
|
130
|
-
<span class="
|
|
131
|
-
|
|
132
|
-
Minimum 8 characters, one capital letter, one number, one special character.
|
|
133
|
-
</span>
|
|
134
|
-
</label>
|
|
135
|
-
|
|
136
|
-
<a href="/forgot" class="tw:text-sm tw:text-gray-500">Forgot Password?</a>
|
|
140
|
+
<span class="form-error">Password with 8 chars or more required</span>
|
|
141
|
+
</div>
|
|
137
142
|
|
|
138
143
|
{#if message}
|
|
139
144
|
<p class="tw:text-red-600">{message}</p>
|
|
140
145
|
{/if}
|
|
141
146
|
|
|
142
|
-
<button
|
|
143
|
-
Sign In
|
|
147
|
+
<button type="submit" class="btn-primary" disabled={loading}>
|
|
148
|
+
{loading ? 'Signing in...' : 'Sign In'}
|
|
144
149
|
</button>
|
|
145
150
|
|
|
146
151
|
<p class="tw:text-center tw:text-sm">
|
|
@@ -1,21 +1,28 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { PageData } from './$types'
|
|
3
|
+
import { untrack } from 'svelte'
|
|
3
4
|
import { onMount } from 'svelte'
|
|
5
|
+
import { goto } from '$app/navigation'
|
|
4
6
|
import { focusOnFirstError } from '$lib/focus'
|
|
5
|
-
import {
|
|
7
|
+
import { appState } from '$lib/app-state.svelte'
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
data: PageData
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
let { data }: Props = $props()
|
|
12
|
-
|
|
14
|
+
// untrack: intentionally take a one-time snapshot of server data for local form editing
|
|
15
|
+
let user: User = $state(untrack(() => ({ ...data.user })))
|
|
16
|
+
|
|
17
|
+
const passwordPattern = '(?=.*[A-Z])(?=.*[0-9])(?=.*[^A-Za-z0-9]).{8,}'
|
|
13
18
|
|
|
14
19
|
let focusedField: HTMLInputElement | undefined = $state()
|
|
15
20
|
let message = $state('')
|
|
16
21
|
let confirmPassword: HTMLInputElement | undefined = $state()
|
|
17
22
|
let submitted = $state(false)
|
|
18
23
|
let passwordMismatch = $state(false)
|
|
24
|
+
let loading = $state(false)
|
|
25
|
+
let deleting = $state(false)
|
|
19
26
|
|
|
20
27
|
onMount(() => {
|
|
21
28
|
focusedField?.focus()
|
|
@@ -33,23 +40,44 @@
|
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
if (form.checkValidity()) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
loading = true
|
|
44
|
+
try {
|
|
45
|
+
const url = '/api/v1/user'
|
|
46
|
+
const res = await fetch(url, {
|
|
47
|
+
method: 'PUT',
|
|
48
|
+
headers: {
|
|
49
|
+
'Content-Type': 'application/json'
|
|
50
|
+
},
|
|
51
|
+
body: JSON.stringify(user)
|
|
52
|
+
})
|
|
53
|
+
const reply = await res.json()
|
|
54
|
+
message = reply.message
|
|
55
|
+
appState.user = JSON.parse(JSON.stringify(user)) // update app state so navbar reflects changes
|
|
56
|
+
} finally {
|
|
57
|
+
loading = false
|
|
58
|
+
}
|
|
47
59
|
} else {
|
|
48
60
|
submitted = true
|
|
49
61
|
focusOnFirstError(form)
|
|
50
62
|
}
|
|
51
63
|
}
|
|
52
64
|
|
|
65
|
+
async function deleteAccount() {
|
|
66
|
+
if (!confirm('Are you sure you want to permanently delete your account? This cannot be undone.')) return
|
|
67
|
+
deleting = true
|
|
68
|
+
try {
|
|
69
|
+
const res = await fetch('/api/v1/user', { method: 'DELETE' })
|
|
70
|
+
if (res.ok) {
|
|
71
|
+
appState.user = undefined
|
|
72
|
+
goto('/login')
|
|
73
|
+
} else {
|
|
74
|
+
message = 'Failed to delete account. Please try again.'
|
|
75
|
+
}
|
|
76
|
+
} finally {
|
|
77
|
+
deleting = false
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
const passwordMatch = () => {
|
|
54
82
|
if (!user.password) user.password = ''
|
|
55
83
|
return user.password == confirmPassword?.value
|
|
@@ -66,8 +94,9 @@
|
|
|
66
94
|
novalidate
|
|
67
95
|
class="tw:mx-auto tw:my-8 tw:max-w-sm tw:space-y-4"
|
|
68
96
|
class:submitted
|
|
97
|
+
onsubmit={(e) => { e.preventDefault(); update() }}
|
|
69
98
|
>
|
|
70
|
-
<h4
|
|
99
|
+
<h4>Profile</h4>
|
|
71
100
|
<p>Update your information.</p>
|
|
72
101
|
|
|
73
102
|
{#if !user?.email?.includes('gmail.com')}
|
|
@@ -76,14 +105,14 @@
|
|
|
76
105
|
<input
|
|
77
106
|
bind:this={focusedField}
|
|
78
107
|
type="email"
|
|
79
|
-
class="
|
|
108
|
+
class="form-input-validated"
|
|
80
109
|
bind:value={user.email}
|
|
81
110
|
required
|
|
82
111
|
placeholder="Email"
|
|
83
112
|
id="email"
|
|
84
113
|
autocomplete="email"
|
|
85
114
|
/>
|
|
86
|
-
<span class="
|
|
115
|
+
<span class="form-error">Email address required</span>
|
|
87
116
|
</label>
|
|
88
117
|
|
|
89
118
|
<label class="tw:block tw:text-sm tw:font-medium" for="password">
|
|
@@ -91,13 +120,14 @@
|
|
|
91
120
|
<input
|
|
92
121
|
type="password"
|
|
93
122
|
id="password"
|
|
94
|
-
class="
|
|
123
|
+
class="form-input-validated"
|
|
95
124
|
bind:value={user.password}
|
|
96
125
|
minlength="8"
|
|
97
126
|
maxlength="80"
|
|
127
|
+
pattern={passwordPattern}
|
|
98
128
|
placeholder="Password"
|
|
99
129
|
/>
|
|
100
|
-
<span class="
|
|
130
|
+
<span class="form-error">Must be 8+ characters with a capital letter, number, and special character</span>
|
|
101
131
|
<span class="tw:text-xs tw:text-gray-500">
|
|
102
132
|
Minimum 8 characters, one capital letter, one number, one special character.
|
|
103
133
|
</span>
|
|
@@ -108,7 +138,7 @@
|
|
|
108
138
|
<input
|
|
109
139
|
type="password"
|
|
110
140
|
id="confirmPassword"
|
|
111
|
-
class="
|
|
141
|
+
class="form-input"
|
|
112
142
|
class:tw:border-red-500={passwordMismatch}
|
|
113
143
|
bind:this={confirmPassword}
|
|
114
144
|
required={!!user.password}
|
|
@@ -120,9 +150,6 @@
|
|
|
120
150
|
{#if passwordMismatch}
|
|
121
151
|
<span class="tw:text-xs tw:text-red-600 tw:mt-0.5">Passwords must match</span>
|
|
122
152
|
{/if}
|
|
123
|
-
<span class="tw:text-xs tw:text-gray-500">
|
|
124
|
-
Minimum 8 characters, one capital letter, one number, one special character.
|
|
125
|
-
</span>
|
|
126
153
|
</label>
|
|
127
154
|
{/if}
|
|
128
155
|
|
|
@@ -131,26 +158,26 @@
|
|
|
131
158
|
<input
|
|
132
159
|
bind:this={focusedField}
|
|
133
160
|
bind:value={user.firstName}
|
|
134
|
-
class="
|
|
161
|
+
class="form-input-validated"
|
|
135
162
|
id="firstName"
|
|
136
163
|
required
|
|
137
164
|
placeholder="First name"
|
|
138
165
|
autocomplete="given-name"
|
|
139
166
|
/>
|
|
140
|
-
<span class="
|
|
167
|
+
<span class="form-error">First name required</span>
|
|
141
168
|
</label>
|
|
142
169
|
|
|
143
170
|
<label class="tw:block tw:text-sm tw:font-medium" for="lastName">
|
|
144
171
|
Last name
|
|
145
172
|
<input
|
|
146
173
|
bind:value={user.lastName}
|
|
147
|
-
class="
|
|
174
|
+
class="form-input-validated"
|
|
148
175
|
id="lastName"
|
|
149
176
|
required
|
|
150
177
|
placeholder="Last name"
|
|
151
178
|
autocomplete="family-name"
|
|
152
179
|
/>
|
|
153
|
-
<span class="
|
|
180
|
+
<span class="form-error">Last name required</span>
|
|
154
181
|
</label>
|
|
155
182
|
|
|
156
183
|
<label class="tw:block tw:text-sm tw:font-medium" for="phone">
|
|
@@ -159,7 +186,7 @@
|
|
|
159
186
|
type="tel"
|
|
160
187
|
bind:value={user.phone}
|
|
161
188
|
id="phone"
|
|
162
|
-
class="
|
|
189
|
+
class="form-input"
|
|
163
190
|
placeholder="Phone"
|
|
164
191
|
autocomplete="tel-local"
|
|
165
192
|
/>
|
|
@@ -170,10 +197,22 @@
|
|
|
170
197
|
{/if}
|
|
171
198
|
|
|
172
199
|
<button
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
200
|
+
type="submit"
|
|
201
|
+
class="btn-primary"
|
|
202
|
+
disabled={loading}
|
|
176
203
|
>
|
|
177
|
-
Update
|
|
204
|
+
{loading ? 'Updating...' : 'Update'}
|
|
178
205
|
</button>
|
|
206
|
+
|
|
207
|
+
<div class="tw:border-t tw:border-red-200 tw:pt-4 tw:mt-4">
|
|
208
|
+
<p class="tw:text-sm tw:text-gray-500 tw:mb-2">Danger zone</p>
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
class="tw:w-full tw:rounded tw:border tw:border-red-600 tw:bg-red-600 tw:px-4 tw:py-2 tw:text-sm tw:font-medium tw:text-white tw:cursor-pointer hover:tw:bg-red-700 hover:tw:border-red-700 disabled:tw:opacity-50 disabled:tw:cursor-not-allowed"
|
|
212
|
+
disabled={deleting}
|
|
213
|
+
onclick={deleteAccount}
|
|
214
|
+
>
|
|
215
|
+
{deleting ? 'Deleting...' : 'Delete my account'}
|
|
216
|
+
</button>
|
|
217
|
+
</div>
|
|
179
218
|
</form>
|