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.
@@ -1,14 +1,15 @@
1
1
  <script lang="ts">
2
2
  import { onMount } from 'svelte'
3
- import { goto } from '$app/navigation'
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 = document.getElementById('signIn') as HTMLFormElement
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
- loginSession.set(fromEndpoint.user)
56
- const { role } = fromEndpoint.user
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
- id="signIn"
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><strong>Sign In</strong></h4>
85
+ <h4>Sign In</h4>
94
86
  <p>Welcome back.</p>
95
87
 
96
- <div id="googleButton"></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="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"
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="tw:hidden tw:text-xs tw:text-red-600 tw:mt-0.5 tw:[.submitted_&]:peer-invalid:block">Email address required</span>
121
+ <span class="form-error">Email address required</span>
116
122
  </label>
117
123
 
118
- <label class="tw:block tw:text-sm tw:font-medium" for="password">
119
- Password
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
- 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"
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="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>
131
- <span class="tw:text-xs tw:text-gray-500">
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 onclick={login} type="button" 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">
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 { loginSession } from '../../stores'
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
- const { user }: { user: User } = $state(data)
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
- const url = '/api/v1/user'
37
- const res = await fetch(url, {
38
- method: 'PUT',
39
- headers: {
40
- 'Content-Type': 'application/json'
41
- },
42
- body: JSON.stringify(user)
43
- })
44
- const reply = await res.json()
45
- message = reply.message
46
- $loginSession = JSON.parse(JSON.stringify(user)) // update loginSession store
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><strong>Profile</strong></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="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"
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="tw:hidden tw:text-xs tw:text-red-600 tw:mt-0.5 tw:[.submitted_&]:peer-invalid:block">Email address required</span>
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="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"
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="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>
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="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"
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="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"
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="tw:hidden tw:text-xs tw:text-red-600 tw:mt-0.5 tw:[.submitted_&]:peer-invalid:block">First name required</span>
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="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"
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="tw:hidden tw:text-xs tw:text-red-600 tw:mt-0.5 tw:[.submitted_&]:peer-invalid:block">Last name required</span>
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="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"
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
- onclick={update}
174
- type="button"
175
- 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"
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>