spine-framework 0.3.101 → 0.3.103
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/.framework/src/App.tsx +6 -0
- package/.framework/src/contexts/AppContext.tsx +3 -1
- package/.framework/src/hooks/useApps.ts +3 -1
- package/.framework/src/pages/auth/AuthConfirmPage.tsx +288 -0
- package/.framework/src/pages/auth/ForgotPasswordPage.tsx +146 -0
- package/.framework/src/pages/auth/RegisterPage.tsx +4 -1
- package/package.json +1 -1
package/.framework/src/App.tsx
CHANGED
|
@@ -4,6 +4,8 @@ import { useAuth } from './contexts/AuthContext'
|
|
|
4
4
|
import { LoadingSpinner } from './components/ui/LoadingSpinner'
|
|
5
5
|
import { LoginPage } from './pages/auth/LoginPage'
|
|
6
6
|
import { RegisterPage } from './pages/auth/RegisterPage'
|
|
7
|
+
import { ForgotPasswordPage } from './pages/auth/ForgotPasswordPage'
|
|
8
|
+
import { AuthConfirmPage } from './pages/auth/AuthConfirmPage'
|
|
7
9
|
import { PendingApprovalPage } from './pages/auth/PendingApprovalPage'
|
|
8
10
|
import { NotFoundPage } from './pages/NotFoundPage'
|
|
9
11
|
import { SetupPage } from './pages/SetupPage'
|
|
@@ -36,6 +38,8 @@ function App() {
|
|
|
36
38
|
<Routes>
|
|
37
39
|
<Route path="/login" element={<LoginPage />} />
|
|
38
40
|
<Route path="/register" element={<RegisterPage />} />
|
|
41
|
+
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
|
42
|
+
<Route path="/auth/confirm" element={<AuthConfirmPage />} />
|
|
39
43
|
{/* / shows setup instructions when no user is logged in */}
|
|
40
44
|
<Route path="/" element={<SetupPage />} />
|
|
41
45
|
<Route path="*" element={<Navigate to="/login" replace />} />
|
|
@@ -109,6 +113,8 @@ function AuthenticatedRouter() {
|
|
|
109
113
|
{/* Auth routes: redirect handled by useEffect above, but keep as fallback */}
|
|
110
114
|
<Route path="/login" element={<Navigate to={defaultRedirect} replace />} />
|
|
111
115
|
<Route path="/register" element={<RegisterPage />} />
|
|
116
|
+
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
|
117
|
+
<Route path="/auth/confirm" element={<AuthConfirmPage />} />
|
|
112
118
|
|
|
113
119
|
{/* Dynamic app routes — no framework opinions about / or /dashboard */}
|
|
114
120
|
{sorted.map(app => {
|
|
@@ -148,7 +148,9 @@ export function AppsRegistryProvider({ children }: AppsRegistryProviderProps) {
|
|
|
148
148
|
const appMatchesDomain = (app: AppRecord): boolean => {
|
|
149
149
|
const domain = environment === 'preview' ? app.preview_domain : app.prod_domain
|
|
150
150
|
if (!domain) return true
|
|
151
|
-
|
|
151
|
+
// Accept both "hostname.com" and "https://hostname.com" formats
|
|
152
|
+
const hostname = domain.includes('://') ? new URL(domain).hostname : domain.split(':')[0]
|
|
153
|
+
return currentHost === hostname
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
const routableApps = apps.filter(
|
|
@@ -73,7 +73,9 @@ export function useApps(): UseAppsResult {
|
|
|
73
73
|
const appMatchesDomain = (app: AppRecord): boolean => {
|
|
74
74
|
const domain = environment === 'preview' ? app.preview_domain : app.prod_domain
|
|
75
75
|
if (!domain) return true
|
|
76
|
-
|
|
76
|
+
// Accept both "hostname.com" and "https://hostname.com" formats
|
|
77
|
+
const hostname = domain.includes('://') ? new URL(domain).hostname : domain.split(':')[0]
|
|
78
|
+
return currentHost === hostname
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
// Filter by user's roles: include app if user has any of the required_roles,
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module src/pages/auth/AuthConfirmPage
|
|
3
|
+
* @audience installer
|
|
4
|
+
* @layer frontend-page
|
|
5
|
+
* @stability stable
|
|
6
|
+
*
|
|
7
|
+
* Universal auth callback page. Handles two token types delivered by Supabase
|
|
8
|
+
* via email link, both arriving as query params on `/auth/confirm`:
|
|
9
|
+
*
|
|
10
|
+
* type=signup — email confirmation after registration
|
|
11
|
+
* type=recovery — password-reset link from ForgotPasswordPage
|
|
12
|
+
*
|
|
13
|
+
* Flow:
|
|
14
|
+
* 1. On mount, read `token_hash` and `type` from the URL search params.
|
|
15
|
+
* 2. Call `supabase.auth.verifyOtp({ token_hash, type })` to exchange the
|
|
16
|
+
* token for a live session. Supabase fires `SIGNED_IN` via
|
|
17
|
+
* `onAuthStateChange`, which triggers `fetchUserContext` in AuthContext.
|
|
18
|
+
* 3a. signup → navigate to "/" (AuthenticatedRouter takes over).
|
|
19
|
+
* 3b. recovery → show a "set new password" form.
|
|
20
|
+
* On submit call `supabase.auth.updateUser({ password })`, then navigate.
|
|
21
|
+
*
|
|
22
|
+
* Error cases:
|
|
23
|
+
* - Token missing/malformed — show error + link back to login.
|
|
24
|
+
* - Token expired (>1 h) — show error + link to /forgot-password to retry.
|
|
25
|
+
* - Password too short — inline validation, never sent to Supabase.
|
|
26
|
+
*
|
|
27
|
+
* @seeAlso src/pages/auth/ForgotPasswordPage.tsx (generates recovery links)
|
|
28
|
+
* @seeAlso src/pages/auth/RegisterPage.tsx (generates signup links)
|
|
29
|
+
* @seeAlso src/contexts/AuthContext.tsx (onAuthStateChange handler)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import React, { useState, useEffect } from 'react'
|
|
33
|
+
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
|
34
|
+
import { supabase } from '../../lib/supabase'
|
|
35
|
+
import { LoadingSpinner } from '../../components/ui/LoadingSpinner'
|
|
36
|
+
|
|
37
|
+
// ─── TYPES ────────────────────────────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
type Stage =
|
|
40
|
+
| 'verifying' // exchanging token with Supabase
|
|
41
|
+
| 'signup_success' // signup confirmed — brief success then redirect
|
|
42
|
+
| 'set_password' // recovery: show new-password form
|
|
43
|
+
| 'password_saved' // recovery: update succeeded — brief success then redirect
|
|
44
|
+
| 'error' // unrecoverable error
|
|
45
|
+
|
|
46
|
+
// ─── COMPONENT ────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export function AuthConfirmPage() {
|
|
49
|
+
const [searchParams] = useSearchParams()
|
|
50
|
+
const navigate = useNavigate()
|
|
51
|
+
|
|
52
|
+
const tokenHash = searchParams.get('token_hash')
|
|
53
|
+
const type = searchParams.get('type') as 'signup' | 'recovery' | string | null
|
|
54
|
+
|
|
55
|
+
const [stage, setStage] = useState<Stage>('verifying')
|
|
56
|
+
const [errorMessage, setErrorMessage] = useState('')
|
|
57
|
+
|
|
58
|
+
// New-password form state
|
|
59
|
+
const [newPassword, setNewPassword] = useState('')
|
|
60
|
+
const [confirmPassword, setConfirmPassword] = useState('')
|
|
61
|
+
const [passwordError, setPasswordError] = useState('')
|
|
62
|
+
const [isSaving, setIsSaving] = useState(false)
|
|
63
|
+
|
|
64
|
+
// ── Token exchange on mount ──────────────────────────────────────────────
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!tokenHash || !type) {
|
|
67
|
+
setErrorMessage('Invalid or missing confirmation link. Please request a new one.')
|
|
68
|
+
setStage('error')
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
;(async () => {
|
|
73
|
+
const { error } = await supabase.auth.verifyOtp({
|
|
74
|
+
token_hash: tokenHash,
|
|
75
|
+
type: type as any,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
if (error) {
|
|
79
|
+
// Common GoTrue error messages — surface human-friendly versions
|
|
80
|
+
if (error.message.includes('expired') || error.message.includes('otp_expired')) {
|
|
81
|
+
setErrorMessage('This link has expired. Links are valid for 1 hour.')
|
|
82
|
+
} else if (error.message.includes('invalid')) {
|
|
83
|
+
setErrorMessage('This link is invalid or has already been used.')
|
|
84
|
+
} else {
|
|
85
|
+
setErrorMessage(error.message)
|
|
86
|
+
}
|
|
87
|
+
setStage('error')
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (type === 'recovery') {
|
|
92
|
+
setStage('set_password')
|
|
93
|
+
} else {
|
|
94
|
+
// signup, invite, magic_link — session is live, go to app
|
|
95
|
+
setStage('signup_success')
|
|
96
|
+
setTimeout(() => navigate('/', { replace: true }), 1500)
|
|
97
|
+
}
|
|
98
|
+
})()
|
|
99
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
100
|
+
|
|
101
|
+
// ── Save new password ────────────────────────────────────────────────────
|
|
102
|
+
const handleSetPassword = async (e: React.FormEvent) => {
|
|
103
|
+
e.preventDefault()
|
|
104
|
+
setPasswordError('')
|
|
105
|
+
|
|
106
|
+
if (newPassword.length < 8) {
|
|
107
|
+
setPasswordError('Password must be at least 8 characters.')
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
if (newPassword !== confirmPassword) {
|
|
111
|
+
setPasswordError('Passwords do not match.')
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
setIsSaving(true)
|
|
116
|
+
try {
|
|
117
|
+
const { error } = await supabase.auth.updateUser({ password: newPassword })
|
|
118
|
+
if (error) {
|
|
119
|
+
setPasswordError(error.message)
|
|
120
|
+
return
|
|
121
|
+
}
|
|
122
|
+
setStage('password_saved')
|
|
123
|
+
setTimeout(() => navigate('/', { replace: true }), 1500)
|
|
124
|
+
} finally {
|
|
125
|
+
setIsSaving(false)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Render ───────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
if (stage === 'verifying') {
|
|
132
|
+
return (
|
|
133
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50">
|
|
134
|
+
<div className="text-center space-y-4">
|
|
135
|
+
<LoadingSpinner className="w-8 h-8 mx-auto" />
|
|
136
|
+
<p className="text-slate-600">Verifying your link...</p>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (stage === 'error') {
|
|
143
|
+
const isExpired = errorMessage.includes('expired')
|
|
144
|
+
return (
|
|
145
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
146
|
+
<div className="max-w-md w-full text-center space-y-6">
|
|
147
|
+
<div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-red-100">
|
|
148
|
+
<svg className="h-8 w-8 text-red-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
149
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z" />
|
|
150
|
+
</svg>
|
|
151
|
+
</div>
|
|
152
|
+
<div className="space-y-2">
|
|
153
|
+
<h1 className="text-2xl font-semibold text-slate-900">Link problem</h1>
|
|
154
|
+
<p className="text-slate-600">{errorMessage}</p>
|
|
155
|
+
</div>
|
|
156
|
+
<div className="space-y-3">
|
|
157
|
+
{isExpired ? (
|
|
158
|
+
<Link
|
|
159
|
+
to="/forgot-password"
|
|
160
|
+
className="block w-full py-2 px-4 rounded-[5px] bg-slate-900 text-white text-sm font-medium hover:bg-slate-800 text-center"
|
|
161
|
+
>
|
|
162
|
+
Request a new link
|
|
163
|
+
</Link>
|
|
164
|
+
) : null}
|
|
165
|
+
<Link to="/login" className="block text-sm text-accent-blue hover:text-accent-blue-light">
|
|
166
|
+
Back to sign in
|
|
167
|
+
</Link>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (stage === 'signup_success') {
|
|
175
|
+
return (
|
|
176
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
177
|
+
<div className="text-center space-y-4">
|
|
178
|
+
<div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-green-100">
|
|
179
|
+
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
180
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
|
181
|
+
</svg>
|
|
182
|
+
</div>
|
|
183
|
+
<h1 className="text-2xl font-semibold text-slate-900">Email confirmed!</h1>
|
|
184
|
+
<p className="text-slate-600">Taking you to the app...</p>
|
|
185
|
+
<LoadingSpinner className="w-5 h-5 mx-auto" />
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (stage === 'password_saved') {
|
|
192
|
+
return (
|
|
193
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 px-4">
|
|
194
|
+
<div className="text-center space-y-4">
|
|
195
|
+
<div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-green-100">
|
|
196
|
+
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
197
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
|
198
|
+
</svg>
|
|
199
|
+
</div>
|
|
200
|
+
<h1 className="text-2xl font-semibold text-slate-900">Password updated!</h1>
|
|
201
|
+
<p className="text-slate-600">Taking you to the app...</p>
|
|
202
|
+
<LoadingSpinner className="w-5 h-5 mx-auto" />
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// stage === 'set_password'
|
|
209
|
+
return (
|
|
210
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
211
|
+
<div className="max-w-md w-full space-y-8">
|
|
212
|
+
<div>
|
|
213
|
+
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-slate-900">
|
|
214
|
+
<span className="text-white font-bold text-xl">S</span>
|
|
215
|
+
</div>
|
|
216
|
+
<h2 className="mt-6 text-center text-3xl font-extrabold text-slate-900">
|
|
217
|
+
Set a new password
|
|
218
|
+
</h2>
|
|
219
|
+
<p className="mt-2 text-center text-sm text-slate-600">
|
|
220
|
+
Choose a strong password for your account.
|
|
221
|
+
</p>
|
|
222
|
+
</div>
|
|
223
|
+
|
|
224
|
+
<form className="mt-8 space-y-6" onSubmit={handleSetPassword}>
|
|
225
|
+
{passwordError && (
|
|
226
|
+
<div className="rounded-md bg-red-50 p-4">
|
|
227
|
+
<div className="text-sm text-red-800">{passwordError}</div>
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
|
|
231
|
+
<div className="space-y-4">
|
|
232
|
+
<div>
|
|
233
|
+
<label htmlFor="new-password" className="block text-sm font-medium text-slate-700">
|
|
234
|
+
New password
|
|
235
|
+
</label>
|
|
236
|
+
<input
|
|
237
|
+
id="new-password"
|
|
238
|
+
name="new-password"
|
|
239
|
+
type="password"
|
|
240
|
+
autoComplete="new-password"
|
|
241
|
+
required
|
|
242
|
+
minLength={8}
|
|
243
|
+
value={newPassword}
|
|
244
|
+
onChange={(e) => setNewPassword(e.target.value)}
|
|
245
|
+
className="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-[5px] shadow-sm placeholder-slate-400 focus:outline-none focus:ring-accent-blue focus:border-accent-blue sm:text-sm"
|
|
246
|
+
placeholder="At least 8 characters"
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
|
|
250
|
+
<div>
|
|
251
|
+
<label htmlFor="confirm-password" className="block text-sm font-medium text-slate-700">
|
|
252
|
+
Confirm new password
|
|
253
|
+
</label>
|
|
254
|
+
<input
|
|
255
|
+
id="confirm-password"
|
|
256
|
+
name="confirm-password"
|
|
257
|
+
type="password"
|
|
258
|
+
autoComplete="new-password"
|
|
259
|
+
required
|
|
260
|
+
value={confirmPassword}
|
|
261
|
+
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
262
|
+
className="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-[5px] shadow-sm placeholder-slate-400 focus:outline-none focus:ring-accent-blue focus:border-accent-blue sm:text-sm"
|
|
263
|
+
placeholder="Repeat your new password"
|
|
264
|
+
/>
|
|
265
|
+
</div>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div>
|
|
269
|
+
<button
|
|
270
|
+
type="submit"
|
|
271
|
+
disabled={isSaving}
|
|
272
|
+
className="group relative w-full flex justify-center py-2 px-4 border border-transparent rounded-[5px] shadow-sm text-sm font-medium text-white bg-slate-900 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-blue disabled:opacity-50 disabled:cursor-not-allowed"
|
|
273
|
+
>
|
|
274
|
+
{isSaving ? (
|
|
275
|
+
<>
|
|
276
|
+
<LoadingSpinner size="sm" />
|
|
277
|
+
<span className="ml-2">Saving...</span>
|
|
278
|
+
</>
|
|
279
|
+
) : (
|
|
280
|
+
'Save new password'
|
|
281
|
+
)}
|
|
282
|
+
</button>
|
|
283
|
+
</div>
|
|
284
|
+
</form>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
)
|
|
288
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module src/pages/auth/ForgotPasswordPage
|
|
3
|
+
* @audience installer
|
|
4
|
+
* @layer frontend-page
|
|
5
|
+
* @stability stable
|
|
6
|
+
*
|
|
7
|
+
* Password-reset request page. Collects the user's email address and calls
|
|
8
|
+
* `supabase.auth.resetPasswordForEmail`, passing `emailRedirectTo` so the
|
|
9
|
+
* link in the email returns the user to the correct origin (local dev,
|
|
10
|
+
* Netlify preview, Cortex, Portal — whatever they're currently on).
|
|
11
|
+
*
|
|
12
|
+
* Supabase sends a recovery email whose link resolves to:
|
|
13
|
+
* <origin>/auth/confirm?token_hash=...&type=recovery
|
|
14
|
+
*
|
|
15
|
+
* That page (AuthConfirmPage) then handles the token exchange and prompts
|
|
16
|
+
* the user to set a new password.
|
|
17
|
+
*
|
|
18
|
+
* @seeAlso src/pages/auth/AuthConfirmPage.tsx (token exchange + new-password form)
|
|
19
|
+
* @seeAlso src/pages/auth/LoginPage.tsx (links here via /forgot-password)
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import React, { useState } from 'react'
|
|
23
|
+
import { Link } from 'react-router-dom'
|
|
24
|
+
import { supabase } from '../../lib/supabase'
|
|
25
|
+
import { LoadingSpinner } from '../../components/ui/LoadingSpinner'
|
|
26
|
+
|
|
27
|
+
export function ForgotPasswordPage() {
|
|
28
|
+
const [email, setEmail] = useState('')
|
|
29
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
30
|
+
const [submitted, setSubmitted] = useState(false)
|
|
31
|
+
const [error, setError] = useState('')
|
|
32
|
+
|
|
33
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
34
|
+
e.preventDefault()
|
|
35
|
+
setError('')
|
|
36
|
+
setIsLoading(true)
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const { error: resetError } = await supabase.auth.resetPasswordForEmail(email, {
|
|
40
|
+
redirectTo: `${window.location.origin}/auth/confirm`,
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
if (resetError) {
|
|
44
|
+
setError(resetError.message)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setSubmitted(true)
|
|
49
|
+
} finally {
|
|
50
|
+
setIsLoading(false)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (submitted) {
|
|
55
|
+
return (
|
|
56
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
57
|
+
<div className="max-w-md w-full text-center space-y-6">
|
|
58
|
+
<div className="mx-auto h-16 w-16 flex items-center justify-center rounded-full bg-green-100">
|
|
59
|
+
<svg className="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
|
|
60
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
|
|
61
|
+
</svg>
|
|
62
|
+
</div>
|
|
63
|
+
<div className="space-y-2">
|
|
64
|
+
<h1 className="text-2xl font-semibold text-slate-900">Check your email</h1>
|
|
65
|
+
<p className="text-slate-600">
|
|
66
|
+
We sent a password reset link to <span className="font-medium">{email}</span>.
|
|
67
|
+
</p>
|
|
68
|
+
</div>
|
|
69
|
+
<div className="rounded-md bg-slate-100 border border-slate-200 p-4 text-sm text-slate-700 text-left space-y-1">
|
|
70
|
+
<p>The link expires in 1 hour.</p>
|
|
71
|
+
<p>If you don't see it, check your spam folder.</p>
|
|
72
|
+
</div>
|
|
73
|
+
<Link to="/login" className="block text-sm text-accent-blue hover:text-accent-blue-light">
|
|
74
|
+
Back to sign in
|
|
75
|
+
</Link>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className="min-h-screen flex items-center justify-center bg-slate-50 py-12 px-4 sm:px-6 lg:px-8">
|
|
83
|
+
<div className="max-w-md w-full space-y-8">
|
|
84
|
+
<div>
|
|
85
|
+
<div className="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-slate-900">
|
|
86
|
+
<span className="text-white font-bold text-xl">S</span>
|
|
87
|
+
</div>
|
|
88
|
+
<h2 className="mt-6 text-center text-3xl font-extrabold text-slate-900">
|
|
89
|
+
Reset your password
|
|
90
|
+
</h2>
|
|
91
|
+
<p className="mt-2 text-center text-sm text-slate-600">
|
|
92
|
+
Enter your email and we'll send you a reset link.
|
|
93
|
+
</p>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
|
97
|
+
{error && (
|
|
98
|
+
<div className="rounded-md bg-red-50 p-4">
|
|
99
|
+
<div className="text-sm text-red-800">{error}</div>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
<div>
|
|
104
|
+
<label htmlFor="email" className="block text-sm font-medium text-slate-700">
|
|
105
|
+
Email address
|
|
106
|
+
</label>
|
|
107
|
+
<input
|
|
108
|
+
id="email"
|
|
109
|
+
name="email"
|
|
110
|
+
type="email"
|
|
111
|
+
autoComplete="email"
|
|
112
|
+
required
|
|
113
|
+
value={email}
|
|
114
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
115
|
+
className="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-[5px] shadow-sm placeholder-slate-400 focus:outline-none focus:ring-accent-blue focus:border-accent-blue sm:text-sm"
|
|
116
|
+
placeholder="Enter your email"
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<div>
|
|
121
|
+
<button
|
|
122
|
+
type="submit"
|
|
123
|
+
disabled={isLoading}
|
|
124
|
+
className="group relative w-full flex justify-center py-2 px-4 border border-transparent rounded-[5px] shadow-sm text-sm font-medium text-white bg-slate-900 hover:bg-slate-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-accent-blue disabled:opacity-50 disabled:cursor-not-allowed"
|
|
125
|
+
>
|
|
126
|
+
{isLoading ? (
|
|
127
|
+
<>
|
|
128
|
+
<LoadingSpinner size="sm" />
|
|
129
|
+
<span className="ml-2">Sending...</span>
|
|
130
|
+
</>
|
|
131
|
+
) : (
|
|
132
|
+
'Send reset link'
|
|
133
|
+
)}
|
|
134
|
+
</button>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className="text-center">
|
|
138
|
+
<Link to="/login" className="text-sm text-accent-blue hover:text-accent-blue-light">
|
|
139
|
+
Back to sign in
|
|
140
|
+
</Link>
|
|
141
|
+
</div>
|
|
142
|
+
</form>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
@@ -229,7 +229,10 @@ export function RegisterPage() {
|
|
|
229
229
|
const { data: signUpData, error: signUpError } = await supabase.auth.signUp({
|
|
230
230
|
email: invitePreview?.email ?? email,
|
|
231
231
|
password,
|
|
232
|
-
options: {
|
|
232
|
+
options: {
|
|
233
|
+
data: { full_name: fullName },
|
|
234
|
+
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
|
235
|
+
},
|
|
233
236
|
})
|
|
234
237
|
|
|
235
238
|
if (signUpError) {
|