@fayz-ai/plugin-auth 0.8.0 → 0.9.0-next.1

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/pages.tsx CHANGED
@@ -1,8 +1,10 @@
1
1
  import * as React from 'react'
2
2
  import { useAuth } from '@fayz-ai/auth'
3
3
  import type { AuthProvider } from '@fayz-ai/core'
4
- import { ForgotPasswordForm, LoginForm, ResetPasswordForm, SignupForm } from './forms'
5
- import type { AuthFormView, AuthLayout } from './types'
4
+ import { ForgotPasswordForm, LoginForm, SignupForm } from './forms'
5
+ import { SetPasswordPage } from './set-password'
6
+ import { AmbassadorBubbles } from './AmbassadorBubbles'
7
+ import type { AuthFormView, AuthLayout, LoginAmbassador } from './types'
6
8
 
7
9
  export interface LoginPageProps {
8
10
  appName?: string
@@ -11,6 +13,11 @@ export interface LoginPageProps {
11
13
  layout?: AuthLayout
12
14
  tagline?: string
13
15
  description?: string
16
+ /** Product ambassadors shown as a small social-proof avatar strip above the
17
+ * tagline on the split-layout brand panel. Renders nothing when empty/imageless. */
18
+ loginAmbassadors?: LoginAmbassador[]
19
+ /** Optional microtext beside the ambassador strip, e.g. "+2 mil profissionais". */
20
+ loginAmbassadorsLabel?: string
14
21
  showOAuth?: boolean
15
22
  oauthProviders?: Exclude<AuthProvider, 'email'>[]
16
23
  resetPasswordUrl?: string
@@ -24,12 +31,18 @@ export function LoginPage({
24
31
  layout = 'split',
25
32
  tagline,
26
33
  description,
34
+ loginAmbassadors,
35
+ loginAmbassadorsLabel,
27
36
  showOAuth,
28
37
  oauthProviders,
29
38
  resetPasswordUrl,
30
39
  onSuccess,
31
40
  }: LoginPageProps) {
32
41
  const [view, setView] = React.useState<AuthFormView>('login')
42
+ // Lifted out of the forms so switching view carries the e-mail across: someone
43
+ // who typed it and then remembered they forgot the password should not have to
44
+ // type it again (and back on the login form it is still there).
45
+ const [email, setEmail] = React.useState('')
33
46
  const brandLogo = loginLogo ?? logo
34
47
  const heading = view === 'login' ? 'Welcome back' : view === 'signup' ? 'Create account' : 'Reset password'
35
48
  const subtitle =
@@ -45,11 +58,18 @@ export function LoginPage({
45
58
  onForgotPassword={() => setView('recovery')}
46
59
  showOAuth={showOAuth}
47
60
  oauthProviders={oauthProviders}
61
+ initialEmail={email}
62
+ onEmailChange={setEmail}
48
63
  />
49
64
  ) : view === 'signup' ? (
50
65
  <SignupForm onSuccess={onSuccess} />
51
66
  ) : (
52
- <ForgotPasswordForm redirectTo={resetPasswordUrl} onBackToLogin={() => setView('login')} />
67
+ <ForgotPasswordForm
68
+ redirectTo={resetPasswordUrl}
69
+ onBackToLogin={() => setView('login')}
70
+ initialEmail={email}
71
+ onEmailChange={setEmail}
72
+ />
53
73
  )
54
74
  const switcher =
55
75
  view === 'login' ? (
@@ -90,8 +110,9 @@ export function LoginPage({
90
110
  <div className="flex min-h-screen bg-sidebar">
91
111
  <div className="relative hidden w-1/2 overflow-hidden lg:block">
92
112
  <div className="absolute inset-0 bg-gradient-to-br from-sidebar-accent/50 via-transparent to-sidebar/80" />
93
- <div className="relative flex h-full flex-col justify-end p-12">
113
+ <div className="relative z-20 flex h-full flex-col justify-end p-12">
94
114
  <div className="space-y-4">
115
+ <AmbassadorBubbles ambassadors={loginAmbassadors} label={loginAmbassadorsLabel} />
95
116
  <p className="text-sm font-medium text-sidebar-muted">{appName}</p>
96
117
  <h2 className="text-3xl font-bold text-sidebar-foreground">{tagline ?? `Welcome to ${appName}`}</h2>
97
118
  {description && <p className="max-w-md text-sm leading-relaxed text-sidebar-muted">{description}</p>}
@@ -181,16 +202,8 @@ export interface ResetPasswordPageProps {
181
202
  onSuccess?: () => void
182
203
  }
183
204
 
205
+ /** Kept for apps that route `/auth/reset-password` themselves. The shell no
206
+ * longer needs a route: SetPasswordPage is mounted by usePasswordSetupGate. */
184
207
  export function ResetPasswordPage({ onSuccess }: ResetPasswordPageProps) {
185
- return (
186
- <div className="flex min-h-screen items-center justify-center bg-background p-4">
187
- <div className="w-full max-w-md rounded-2xl border bg-card p-8 shadow-xl">
188
- <div className="mb-6">
189
- <h1 className="text-2xl font-bold text-foreground">Update password</h1>
190
- <p className="mt-1 text-sm text-muted-foreground">Choose a new password for your account.</p>
191
- </div>
192
- <ResetPasswordForm onSuccess={onSuccess} />
193
- </div>
194
- </div>
195
- )
208
+ return <SetPasswordPage reason="recovery" onSuccess={onSuccess} />
196
209
  }
package/src/runtime.ts CHANGED
@@ -24,8 +24,12 @@ export function resolveAuthAdapter(options: AuthPluginOptions = {}): AuthAdapter
24
24
  supabaseAnonKey: anonKey,
25
25
  supabaseClient: options.supabase?.client,
26
26
  callbackUrl: options.supabase?.callbackUrl ?? absoluteUrl(options.routes?.callback ?? '/auth/callback'),
27
+ // Only when the host asked for a specific URL. Left undefined, the adapter
28
+ // points recovery at the app root, which needs no route and no extra entry
29
+ // in Supabase's redirect allow-list.
27
30
  resetPasswordUrl:
28
- options.supabase?.resetPasswordUrl ?? absoluteUrl(options.routes?.resetPassword ?? '/auth/reset-password'),
31
+ options.supabase?.resetPasswordUrl ??
32
+ (options.routes?.resetPassword ? absoluteUrl(options.routes.resetPassword) : undefined),
29
33
  })
30
34
  }
31
35
 
@@ -41,6 +45,8 @@ export function createAuthRuntime(options: AuthPluginOptions = {}): ResolvedAuth
41
45
  logo: options.logo,
42
46
  tagline: options.tagline,
43
47
  description: options.description,
48
+ loginAmbassadors: options.loginAmbassadors,
49
+ loginAmbassadorsLabel: options.loginAmbassadorsLabel,
44
50
  oauth: {
45
51
  enabled: options.oauth?.enabled ?? false,
46
52
  providers: options.oauth?.providers ?? ['google'],
@@ -48,7 +54,9 @@ export function createAuthRuntime(options: AuthPluginOptions = {}): ResolvedAuth
48
54
  routes: {
49
55
  login: options.routes?.login ?? '/login',
50
56
  callback: options.routes?.callback ?? '/auth/callback',
51
- resetPassword: options.routes?.resetPassword ?? '/auth/reset-password',
57
+ // The app root — recovery links are recognised by their own params, not by
58
+ // the path they land on. See resetPassword() in the Supabase adapter.
59
+ resetPassword: options.routes?.resetPassword ?? '/',
52
60
  afterSignIn: options.routes?.afterSignIn ?? '/',
53
61
  },
54
62
  }
@@ -0,0 +1,229 @@
1
+ import * as React from 'react'
2
+ import { useAuth, readAuthUrlIntent, clearAuthUrlTokens, decidePasswordSetup } from '@fayz-ai/auth'
3
+ import { useTranslation } from '@fayz-ai/core'
4
+ import { ForgotPasswordForm } from './forms'
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Set-password surface — the one screen behind two flows:
8
+ //
9
+ // • recovery — the user clicked "forgot password" and came back on a recovery
10
+ // link. GoTrue already signed them in from the link, so all that is missing
11
+ // is `updateUser({ password })`. Without this screen they land straight in
12
+ // the app (or on the login form) and are never asked, which is exactly the
13
+ // "the link does nothing" bug.
14
+ //
15
+ // • invite — the user was added to an organisation. The invite is a magic link,
16
+ // so the account exists with NO password: unless they choose one here, every
17
+ // future sign-in needs another e-mail.
18
+ //
19
+ // Mounted by the shell above its auth gate, driven by usePasswordSetupGate().
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export type SetPasswordReason = 'recovery' | 'invite'
23
+
24
+ const MIN_LENGTH = 8
25
+
26
+ function fieldClass() {
27
+ return 'h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50'
28
+ }
29
+
30
+ function buttonClass() {
31
+ return 'inline-flex h-10 w-full items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:pointer-events-none disabled:opacity-50'
32
+ }
33
+
34
+ function useTr() {
35
+ const t = useTranslation()
36
+ return React.useCallback(
37
+ (key: string, fallback: string, params?: Record<string, string | number>) => {
38
+ const value = t(key, params)
39
+ return !value || value === key ? fallback : value
40
+ },
41
+ [t],
42
+ )
43
+ }
44
+
45
+ export interface PasswordSetupGate {
46
+ /** null when nothing is pending and the shell should render normally. */
47
+ reason: SetPasswordReason | null
48
+ /** The link is dead (already used / expired) — offer a fresh one instead. */
49
+ expired: boolean
50
+ /** Still resolving the session the link carries. */
51
+ checking: boolean
52
+ /** Called by the screen once the password is set (or the user gives up). */
53
+ complete: () => void
54
+ }
55
+
56
+ /**
57
+ * Decides whether the user owes us a password before they see the app.
58
+ *
59
+ * The URL intent is read from a snapshot taken at module load: supabase-js wipes
60
+ * the token fragment as soon as it initialises, so the reason the user is here is
61
+ * gone by the time React renders.
62
+ */
63
+ export function usePasswordSetupGate(): PasswordSetupGate {
64
+ const { user, isLoading, isAuthenticated } = useAuth()
65
+ const [done, setDone] = React.useState(false)
66
+ const intent = React.useMemo(() => readAuthUrlIntent(), [])
67
+
68
+ const complete = React.useCallback(() => {
69
+ clearAuthUrlTokens()
70
+ setDone(true)
71
+ }, [])
72
+
73
+ if (done) return { reason: null, expired: false, checking: false, complete }
74
+
75
+ const decision = decidePasswordSetup({
76
+ intent,
77
+ isLoading,
78
+ isAuthenticated,
79
+ mustSetPassword: user?.mustSetPassword,
80
+ })
81
+ return { ...decision, complete }
82
+ }
83
+
84
+ export interface SetPasswordPageProps {
85
+ reason: SetPasswordReason
86
+ appName?: string
87
+ logo?: React.ReactNode
88
+ /** Show the "ask for a new link" form instead of the password fields. */
89
+ expired?: boolean
90
+ /** Waiting on the link's session — renders a quiet placeholder. */
91
+ checking?: boolean
92
+ onSuccess?: () => void
93
+ }
94
+
95
+ export function SetPasswordPage({
96
+ reason,
97
+ appName = 'App',
98
+ logo,
99
+ expired,
100
+ checking,
101
+ onSuccess,
102
+ }: SetPasswordPageProps) {
103
+ const tr = useTr()
104
+ const { user, updatePassword, signOut, error, isLoading } = useAuth()
105
+ const [password, setPassword] = React.useState('')
106
+ const [confirmPassword, setConfirmPassword] = React.useState('')
107
+ const [localError, setLocalError] = React.useState<string | null>(null)
108
+ const [busy, setBusy] = React.useState(false)
109
+ const disabled = busy || isLoading
110
+
111
+ const invite = reason === 'invite'
112
+ const title = invite
113
+ ? tr('auth.setPassword.welcomeTitle', `Welcome to ${appName}`, { appName })
114
+ : tr('auth.setPassword.recoveryTitle', 'Choose a new password')
115
+ const subtitle = invite
116
+ ? tr('auth.setPassword.welcomeSubtitle', 'Choose a password to finish setting up your access.')
117
+ : tr('auth.setPassword.recoverySubtitle', 'Set a new password to get back into your account.')
118
+
119
+ async function submit(event: React.FormEvent) {
120
+ event.preventDefault()
121
+ setLocalError(null)
122
+ if (password.length < MIN_LENGTH) {
123
+ setLocalError(tr('auth.setPassword.tooShort', `Password must be at least ${MIN_LENGTH} characters.`))
124
+ return
125
+ }
126
+ if (password !== confirmPassword) {
127
+ setLocalError(tr('auth.setPassword.mismatch', 'Passwords do not match.'))
128
+ return
129
+ }
130
+ setBusy(true)
131
+ try {
132
+ await updatePassword(password)
133
+ onSuccess?.()
134
+ } catch {
135
+ // useAuth already surfaced it; keep the user on the form.
136
+ } finally {
137
+ setBusy(false)
138
+ }
139
+ }
140
+
141
+ return (
142
+ <div className="flex min-h-screen items-center justify-center bg-sidebar p-4">
143
+ <div className="w-full max-w-md rounded-2xl border border-border/50 bg-card p-8 shadow-2xl sm:p-10">
144
+ {logo && <div className="mb-6 flex justify-center">{logo}</div>}
145
+ {checking ? (
146
+ <p className="py-6 text-center text-sm text-muted-foreground">
147
+ {tr('auth.setPassword.checking', 'Checking your link...')}
148
+ </p>
149
+ ) : expired ? (
150
+ <>
151
+ <div className="mb-6">
152
+ <h1 className="text-2xl font-bold text-foreground">
153
+ {tr('auth.setPassword.expiredTitle', 'This link has expired')}
154
+ </h1>
155
+ <p className="mt-1 text-sm text-muted-foreground">
156
+ {tr(
157
+ 'auth.setPassword.expiredSubtitle',
158
+ 'Reset links can only be used once. Enter your e-mail and we will send a new one.',
159
+ )}
160
+ </p>
161
+ </div>
162
+ <ForgotPasswordForm
163
+ initialEmail={user?.email}
164
+ submitLabel={tr('auth.setPassword.sendNewLink', 'Send new link')}
165
+ sendingLabel={tr('auth.setPassword.sending', 'Sending...')}
166
+ sentLabel={tr('auth.setPassword.sent', 'Check your inbox — a new link is on the way.')}
167
+ emailLabel={tr('auth.setPassword.email', 'E-mail')}
168
+ backLabel={tr('auth.setPassword.backToLogin', 'Back to sign in')}
169
+ onBackToLogin={onSuccess}
170
+ />
171
+ </>
172
+ ) : (
173
+ <>
174
+ <div className="mb-6">
175
+ <h1 className="text-2xl font-bold text-foreground">{title}</h1>
176
+ <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
177
+ </div>
178
+ <form onSubmit={submit} className="space-y-4">
179
+ <label className="block space-y-1.5 text-sm font-medium">
180
+ <span>{tr('auth.setPassword.newPassword', 'New password')}</span>
181
+ <input
182
+ className={fieldClass()}
183
+ type="password"
184
+ autoComplete="new-password"
185
+ autoFocus
186
+ required
187
+ value={password}
188
+ onChange={(e) => setPassword(e.target.value)}
189
+ disabled={disabled}
190
+ />
191
+ </label>
192
+ <label className="block space-y-1.5 text-sm font-medium">
193
+ <span>{tr('auth.setPassword.confirmPassword', 'Confirm password')}</span>
194
+ <input
195
+ className={fieldClass()}
196
+ type="password"
197
+ autoComplete="new-password"
198
+ required
199
+ value={confirmPassword}
200
+ onChange={(e) => setConfirmPassword(e.target.value)}
201
+ disabled={disabled}
202
+ />
203
+ </label>
204
+ {(localError || error) && (
205
+ <p className="text-sm text-destructive">{localError ?? error?.message}</p>
206
+ )}
207
+ <button className={buttonClass()} type="submit" disabled={disabled}>
208
+ {disabled
209
+ ? tr('auth.setPassword.saving', 'Saving...')
210
+ : tr('auth.setPassword.submit', 'Save password')}
211
+ </button>
212
+ </form>
213
+ {/* An invitee who does not want to continue needs a way out that does
214
+ not leave them stuck on this screen with a live session. */}
215
+ <button
216
+ type="button"
217
+ className="mt-4 w-full text-center text-sm text-muted-foreground hover:text-foreground"
218
+ onClick={() => {
219
+ void signOut().finally(() => onSuccess?.())
220
+ }}
221
+ >
222
+ {tr('auth.setPassword.backToLogin', 'Back to sign in')}
223
+ </button>
224
+ </>
225
+ )}
226
+ </div>
227
+ </div>
228
+ )
229
+ }
package/src/types.ts CHANGED
@@ -5,6 +5,14 @@ import type { SupabaseAuthConfig } from '@fayz-ai/auth'
5
5
  export type AuthProviderKey = 'supabase' | 'mock' | (string & {})
6
6
  export type AuthLayout = 'split' | 'centered'
7
7
 
8
+ /** A product ambassador rendered as a floating circular photo bubble on the
9
+ * split-login decorative panel. `image` may be a local path or a data URI. */
10
+ export interface LoginAmbassador {
11
+ name: string
12
+ role?: string
13
+ image: string
14
+ }
15
+
8
16
  export interface AuthRoutesConfig {
9
17
  login?: string
10
18
  callback?: string
@@ -34,6 +42,8 @@ export interface AuthPluginOptions {
34
42
  logo?: React.ReactNode
35
43
  tagline?: string
36
44
  description?: string
45
+ loginAmbassadors?: LoginAmbassador[]
46
+ loginAmbassadorsLabel?: string
37
47
  oauth?: AuthOAuthConfig
38
48
  routes?: AuthRoutesConfig
39
49
  }
@@ -46,6 +56,8 @@ export interface ResolvedAuthPlugin {
46
56
  logo?: React.ReactNode
47
57
  tagline?: string
48
58
  description?: string
59
+ loginAmbassadors?: LoginAmbassador[]
60
+ loginAmbassadorsLabel?: string
49
61
  oauth: Required<AuthOAuthConfig>
50
62
  routes: Required<AuthRoutesConfig>
51
63
  }