spine-framework 0.3.101 → 0.3.102

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.
@@ -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 => {
@@ -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: { data: { full_name: fullName } },
232
+ options: {
233
+ data: { full_name: fullName },
234
+ emailRedirectTo: `${window.location.origin}/auth/confirm`,
235
+ },
233
236
  })
234
237
 
235
238
  if (signUpError) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.101",
3
+ "version": "0.3.102",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {