flowlink-auth 2.8.1 → 2.8.3

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/src/SignIn.jsx CHANGED
@@ -1,6 +1,6 @@
1
- // src/signin.jsx
2
1
  'use client'
3
- import React, { useState } from 'react'
2
+ import React, { useEffect, useRef, useState } from 'react'
3
+ import Link from 'next/link'
4
4
  import { useAuth } from './provider.js'
5
5
 
6
6
  export default function SignIn({ onSuccess } = {}) {
@@ -14,13 +14,51 @@ export default function SignIn({ onSuccess } = {}) {
14
14
  completeLogin,
15
15
  fetchMe,
16
16
  setUser
17
- } = useAuth()
17
+ } = (typeof useAuth === 'function' ? useAuth() : {}) || {}
18
18
 
19
19
  const [email, setEmail] = useState('')
20
20
  const [password, setPassword] = useState('')
21
21
  const [loading, setLoading] = useState(false)
22
- const [error, setError] = useState(null)
23
- const [message, setMessage] = useState(null)
22
+ const [loadingOauth, setLoadingOauth] = useState({ google: false, github: false })
23
+
24
+ const redirectTimer = useRef(null)
25
+ const toastId = useRef(0)
26
+ const [toasts, setToasts] = useState([])
27
+
28
+ useEffect(() => {
29
+ // soft-disable pinch-zoom on mobile while mounted
30
+ const meta = document.createElement('meta')
31
+ meta.name = 'viewport'
32
+ meta.content = 'width=device-width, initial-scale=1, maximum-scale=1'
33
+ document.head.appendChild(meta)
34
+
35
+ return () => {
36
+ if (redirectTimer.current) clearTimeout(redirectTimer.current)
37
+ const existing = document.querySelector('meta[name="viewport"]')
38
+ if (existing && existing.content === meta.content) document.head.removeChild(existing)
39
+ // clear timers for toasts
40
+ toasts.forEach(t => { if (t._timer) clearTimeout(t._timer) })
41
+ }
42
+ // eslint-disable-next-line react-hooks/exhaustive-deps
43
+ }, [])
44
+
45
+ // Toast helpers (black background)
46
+ function showToast(type, message, ms = 5000) {
47
+ const id = ++toastId.current
48
+ const t = { id, type, message, _timer: null }
49
+ setToasts(prev => [t, ...prev].slice(0, 6))
50
+ const timer = setTimeout(() => {
51
+ setToasts(prev => prev.filter(x => x.id !== id))
52
+ }, ms)
53
+ t._timer = timer
54
+ }
55
+
56
+ function removeToast(id) {
57
+ setToasts(prev => {
58
+ prev.forEach(t => { if (t.id === id && t._timer) clearTimeout(t._timer) })
59
+ return prev.filter(x => x.id !== id)
60
+ })
61
+ }
24
62
 
25
63
  if (loadingUser) return null
26
64
 
@@ -32,19 +70,18 @@ export default function SignIn({ onSuccess } = {}) {
32
70
 
33
71
  async function submit(e) {
34
72
  e.preventDefault()
35
- setError(null)
36
- setMessage(null)
73
+ if (loading) return
74
+ setLoading(true)
37
75
 
38
76
  if (!email || !password) {
39
- setError('Email and password are required')
77
+ showToast('error', 'Email and password are required')
78
+ setLoading(false)
40
79
  return
41
80
  }
42
81
 
43
- setLoading(true)
82
+ const endpoint = `${(baseUrl || '').replace(/\/+$/, '')}/api/sdk/login`
44
83
 
45
84
  try {
46
- const endpoint = `${(baseUrl || '').replace(/\/+$/, '')}/api/sdk/login`
47
-
48
85
  const res = await fetch(endpoint, {
49
86
  method: 'POST',
50
87
  credentials: 'include',
@@ -57,9 +94,9 @@ export default function SignIn({ onSuccess } = {}) {
57
94
 
58
95
  const ct = res.headers.get('content-type') || ''
59
96
  let data = {}
60
- if (ct.includes('application/json')) data = await res.json()
97
+ if (ct.includes('application/json')) data = await res.json().catch(() => ({}))
61
98
  else {
62
- const text = await res.text()
99
+ const text = await res.text().catch(() => '')
63
100
  throw new Error(`Unexpected response (status ${res.status}): ${text.slice(0, 200)}`)
64
101
  }
65
102
 
@@ -84,36 +121,33 @@ export default function SignIn({ onSuccess } = {}) {
84
121
  try { onSuccess(data) } catch (_) {}
85
122
  }
86
123
 
87
- setMessage('Signed in. Redirecting...')
124
+ showToast('success', 'Signed in. Redirecting...')
88
125
  if (redirect) {
89
- setTimeout(() => {
126
+ redirectTimer.current = setTimeout(() => {
90
127
  if (typeof redirectTo === 'function') redirectTo(redirect)
91
128
  else if (typeof window !== 'undefined') window.location.assign(redirect)
92
129
  }, 250)
93
130
  }
94
131
  } catch (err) {
95
- setError(err.message || 'Network error')
132
+ showToast('error', err?.message || 'Network error')
133
+ console.error('Signin error:', err)
96
134
  } finally {
97
135
  setLoading(false)
98
136
  }
99
137
  }
100
138
 
101
- // --- OAuth start flow (Google / GitHub) ---
102
139
  async function startOAuthFlow(provider) {
103
- setError(null)
104
- setLoading(true)
105
-
140
+ // prevent double start
141
+ if (loading || loadingOauth[provider]) return
142
+ setLoadingOauth(prev => ({ ...prev, [provider]: true }))
106
143
  try {
107
144
  const rid =
108
145
  (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
109
146
  ? crypto.randomUUID()
110
147
  : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`
111
148
 
112
- // Use main app signin page as callback so user lands on /signin after flow
113
- const callbackUrl = encodeURIComponent(`${window.location.origin}/signin`)
114
-
115
- const sdkBase = (typeof process !== 'undefined' && process.env && process.env.NEXT_PUBLIC_FLOWLINK_BASE_URL)
116
- || baseUrl || 'http://localhost:3001'
149
+ const callbackUrl = encodeURIComponent(`${typeof window !== 'undefined' ? window.location.origin : ''}/signin`)
150
+ const sdkBase = baseUrl || (typeof window !== 'undefined' ? window.location.origin.replace(/\/+$/, '') : '')
117
151
  const startUrl = `${sdkBase.replace(/\/+$/, '')}/sdk/auth/start?rid=${rid}&source=${encodeURIComponent(provider)}&callbackUrl=${callbackUrl}`
118
152
 
119
153
  if (!publishableKey) {
@@ -130,11 +164,11 @@ export default function SignIn({ onSuccess } = {}) {
130
164
  if (!res.ok) throw new Error(data?.error || `OAuth start failed (${res.status})`)
131
165
  if (!data?.oauthUrl) throw new Error('SDK start did not return oauthUrl')
132
166
 
133
- window.location.href = data.oauthUrl
167
+ if (typeof window !== 'undefined') window.location.href = data.oauthUrl
134
168
  } catch (err) {
169
+ showToast('error', err?.message || 'OAuth start failed')
135
170
  console.error('OAuth start error:', err)
136
- setError(err?.message || 'OAuth start failed')
137
- setLoading(false)
171
+ setLoadingOauth(prev => ({ ...prev, [provider]: false }))
138
172
  }
139
173
  }
140
174
 
@@ -150,6 +184,28 @@ export default function SignIn({ onSuccess } = {}) {
150
184
 
151
185
  return (
152
186
  <div style={overlay}>
187
+ {/* Toasts */}
188
+ <div style={toastContainer} aria-live="polite" aria-atomic="true">
189
+ {toasts.map(t => (
190
+ <div
191
+ key={t.id}
192
+ role="status"
193
+ style={{
194
+ ...toastBase,
195
+ ...(t.type === 'error' ? toastError : t.type === 'success' ? toastSuccess : toastInfo)
196
+ }}
197
+ onMouseEnter={() => { if (t._timer) clearTimeout(t._timer) }}
198
+ onMouseLeave={() => {
199
+ const timer = setTimeout(() => removeToast(t.id), 3000)
200
+ setToasts(prev => prev.map(x => x.id === t.id ? { ...x, _timer: timer } : x))
201
+ }}
202
+ >
203
+ <div style={{ flex: 1 }}>{t.message}</div>
204
+ <button aria-label="Dismiss" onClick={() => removeToast(t.id)} style={toastCloseBtn}>✕</button>
205
+ </div>
206
+ ))}
207
+ </div>
208
+
153
209
  <div style={modal}>
154
210
  <h2 style={title}>Sign in</h2>
155
211
  <p style={subtitle}>Welcome back — enter your credentials.</p>
@@ -180,17 +236,23 @@ export default function SignIn({ onSuccess } = {}) {
180
236
  </div>
181
237
 
182
238
  <div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
183
- <button type="button" onClick={handleGoogle} style={oauthButtonGoogle} disabled={loading}>
184
- Continue with Google
239
+ <button type="button" onClick={handleGoogle} style={oauthButtonGoogle} disabled={loading || loadingOauth.google}>
240
+ <svg width={18} style={{ marginRight: 8 }} viewBox="-3 0 262 262" xmlns="http://www.w3.org/2000/svg" fill="#000000" aria-hidden>
241
+ <path d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622 38.755 30.023 2.685.268c24.659-22.774 38.875-56.282 38.875-96.027" fill="#4285F4"></path>
242
+ <path d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055-34.523 0-63.824-22.773-74.269-54.25l-1.531.13-40.298 31.187-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1" fill="#34A853"></path>
243
+ <path d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82 0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602l42.356-32.782" fill="#FBBC05"></path>
244
+ <path d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0 79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251" fill="#EB4335"></path>
245
+ </svg>
246
+ <span>{loadingOauth.google ? 'Loading...' : 'Google'}</span>
185
247
  </button>
186
248
 
187
- <button type="button" onClick={handleGithub} style={oauthButtonGithub} disabled={loading}>
188
- Continue with GitHub
249
+ <button type="button" onClick={handleGithub} style={oauthButtonGithub} disabled={loading || loadingOauth.github}>
250
+ <svg width={18} style={{ marginRight: 8 }} xmlns="http://www.w3.org/2000/svg" fill="white" viewBox="0 0 20 20" aria-hidden>
251
+ <path fillRule="evenodd" d="M10 .333A9.911 9.911 0 0 0 6.866 19.65c.5.092.678-.215.678-.477 0-.237-.01-1.017-.014-1.845-2.757.6-3.338-1.169-3.338-1.169a2.627 2.627 0 0 0-1.1-1.451c-.9-.615.07-.6.07-.6a2.084 2.084 0 0 1 1.518 1.021 2.11 2.11 0 0 0 2.884.823c.044-.503.268-.973.63-1.325-2.2-.25-4.516-1.1-4.516-4.9A3.832 3.832 0 0 1 4.7 7.068a3.56 3.56 0 0 1 .095-2.623s.832-.266 2.726 1.016a9.409 9.409 0 0 1 4.962 0c1.89-1.282 2.717-1.016 2.717-1.016.366.83.402 1.768.1 2.623a3.827 3.827 0 0 1 1.02 2.659c0 3.807-2.319 4.644-4.525 4.889a2.366 2.366 0 0 1 .673 1.834c0 1.326-.012 2.394-.012 2.72 0 .263.18.572.681.475A9.911 9.911 0 0 0 10 .333Z" clipRule="evenodd" />
252
+ </svg>
253
+ <span>{loadingOauth.github ? 'Loading...' : 'GitHub'}</span>
189
254
  </button>
190
255
  </div>
191
-
192
- {error && <div style={errorBox}>{error}</div>}
193
- {message && <div style={successBox}>{message}</div>}
194
256
  </form>
195
257
  </div>
196
258
  </div>
@@ -198,15 +260,116 @@ export default function SignIn({ onSuccess } = {}) {
198
260
  }
199
261
 
200
262
  /* styles */
201
- const overlay = { position: 'fixed', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.45)', zIndex: 9999, padding: 20 }
202
- const modal = { width: '100%', maxWidth: 420, background: '#0f1724', color: '#fff', borderRadius: 12, padding: 22, boxShadow: '0 10px 30px rgba(2,6,23,0.6)', border: '1px solid rgba(255,255,255,0.04)' }
263
+ const overlay = {
264
+ position: 'fixed',
265
+ inset: 0,
266
+ display: 'block', // allow page scroll
267
+ padding: 20,
268
+ background: 'linear-gradient(180deg, rgba(2,6,23,0.22), rgba(2,6,23,0.32))',
269
+ backdropFilter: 'blur(6px)',
270
+ overflowY: 'auto',
271
+ WebkitOverflowScrolling: 'touch',
272
+ minHeight: '100vh',
273
+ zIndex: 9999
274
+ }
275
+
276
+ const modal = {
277
+ width: '100%',
278
+ maxWidth: 460,
279
+ margin: '40px auto',
280
+ borderRadius: 14,
281
+ background: 'linear-gradient(180deg, rgba(15,19,24,0.85), rgba(10,12,16,0.85))',
282
+ border: '1px solid rgba(99,102,106,0.12)',
283
+ boxShadow: '0 18px 48px rgba(2,6,23,0.55), inset 0 1px 0 rgba(255,255,255,0.02)',
284
+ padding: 22,
285
+ color: '#fff'
286
+ }
287
+
203
288
  const title = { margin: 0, fontSize: 20, fontWeight: 600 }
204
289
  const subtitle = { marginTop: 6, marginBottom: 14, color: '#cbd5e1', fontSize: 13 }
205
290
  const label = { display: 'block', color: '#cbd5e1', fontSize: 13, marginTop: 8 }
206
- const input = { width: '100%', padding: '10px 12px', marginTop: 6, borderRadius: 8, border: '1px solid rgba(255,255,255,0.06)', background: '#0b1220', color: '#fff', boxSizing: 'border-box' }
207
- const button = { width: '100%', padding: '10px 12px', borderRadius: 8, background: 'linear-gradient(90deg,#06b6d4,#2563eb)', color: '#0b1220', border: 'none', fontWeight: 700, cursor: 'pointer' }
208
- const oauthButtonGoogle = { flex: 1, padding: '10px 12px', borderRadius: 8, background: '#db4437', color: '#fff', border: 'none', cursor: 'pointer' }
209
- const oauthButtonGithub = { flex: 1, padding: '10px 12px', borderRadius: 8, background: '#24292f', color: '#fff', border: 'none', cursor: 'pointer' }
210
- const errorBox = { marginTop: 10, color: '#ffb4b4', fontSize: 13 }
211
- const successBox = { marginTop: 10, color: '#bef264', fontSize: 13 }
291
+ const input = {
292
+ width: '100%',
293
+ padding: '10px 12px',
294
+ marginTop: 6,
295
+ borderRadius: 10,
296
+ border: '1px solid rgba(148,163,184,0.10)',
297
+ background: 'rgba(255,255,255,0.02)',
298
+ color: '#e6e6e6',
299
+ boxSizing: 'border-box'
300
+ }
301
+ const button = {
302
+ width: '100%',
303
+ padding: '10px 12px',
304
+ borderRadius: 10,
305
+ background: 'linear-gradient(90deg,#06b6d4,#2563eb)',
306
+ color: '#0b1220',
307
+ border: 'none',
308
+ fontWeight: 700,
309
+ cursor: 'pointer'
310
+ }
311
+ const oauthButtonGoogle = {
312
+ flex: 1,
313
+ padding: '10px 12px',
314
+ borderRadius: 10,
315
+ background: 'linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0.01))',
316
+ color: '#fff',
317
+ border: '1px solid rgba(148,163,184,0.08)',
318
+ cursor: 'pointer',
319
+ display: 'inline-flex',
320
+ alignItems: 'center',
321
+ justifyContent: 'center',
322
+ gap: 8
323
+ }
324
+ const oauthButtonGithub = {
325
+ flex: 1,
326
+ padding: '10px 12px',
327
+ borderRadius: 10,
328
+ background: 'linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.00))',
329
+ color: '#fff',
330
+ border: '1px solid rgba(148,163,184,0.08)',
331
+ cursor: 'pointer',
332
+ display: 'inline-flex',
333
+ alignItems: 'center',
334
+ justifyContent: 'center',
335
+ gap: 8
336
+ }
337
+
338
+ /* Toast styles (black) */
339
+ const toastContainer = {
340
+ position: 'fixed',
341
+ top: 18,
342
+ right: 18,
343
+ width: 360,
344
+ maxWidth: 'calc(100% - 36px)',
345
+ display: 'flex',
346
+ flexDirection: 'column',
347
+ gap: 10,
348
+ zIndex: 60000
349
+ }
212
350
 
351
+ const toastBase = {
352
+ display: 'flex',
353
+ gap: 10,
354
+ alignItems: 'center',
355
+ padding: '10px 12px',
356
+ borderRadius: 10,
357
+ boxShadow: '0 8px 20px rgba(2,6,23,0.6)',
358
+ color: '#fff',
359
+ fontSize: 13,
360
+ minWidth: 120
361
+ }
362
+
363
+ const toastError = { background: '#000', border: '1px solid rgba(255,255,255,0.06)' }
364
+ const toastSuccess = { background: '#000', border: '1px solid rgba(255,255,255,0.06)' }
365
+ const toastInfo = { background: '#000', border: '1px solid rgba(255,255,255,0.06)' }
366
+
367
+ const toastCloseBtn = {
368
+ marginLeft: 8,
369
+ background: 'transparent',
370
+ border: 'none',
371
+ color: 'rgba(255,255,255,0.7)',
372
+ cursor: 'pointer',
373
+ fontSize: 14,
374
+ lineHeight: 1
375
+ }