@fayz-ai/saas 0.5.0 → 0.6.6
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/dist/app/AdminShell.d.ts +14 -1
- package/dist/app/AdminShell.d.ts.map +1 -1
- package/dist/app/LoginPage.d.ts +2 -16
- package/dist/app/LoginPage.d.ts.map +1 -1
- package/dist/app/admin-app.d.ts +21 -0
- package/dist/app/admin-app.d.ts.map +1 -0
- package/dist/app/config.d.ts +40 -3
- package/dist/app/config.d.ts.map +1 -1
- package/dist/app/scaffold.d.ts +1 -2
- package/dist/app/scaffold.d.ts.map +1 -1
- package/dist/{chunk-DJW67TCY.js → chunk-DCRT47W4.js} +155 -28
- package/dist/chunk-DCRT47W4.js.map +1 -0
- package/dist/{chunk-QGMDVLLW.cjs → chunk-SRIUD35R.cjs} +155 -28
- package/dist/chunk-SRIUD35R.cjs.map +1 -0
- package/dist/crud/CrudDetailPage.d.ts.map +1 -1
- package/dist/index.cjs +1127 -5792
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +839 -5509
- package/dist/index.js.map +1 -1
- package/dist/org/adapters/supabase.d.ts +18 -0
- package/dist/org/adapters/supabase.d.ts.map +1 -1
- package/dist/org/context.d.ts.map +1 -1
- package/dist/org/index.cjs +11 -10
- package/dist/org/index.js +2 -1
- package/dist/shell/components/crud/archetypes/ClientOrdersTab.d.ts +4 -2
- package/dist/shell/components/crud/archetypes/ClientOrdersTab.d.ts.map +1 -1
- package/dist/shell/components/layout/BottomNav.d.ts.map +1 -1
- package/dist/shell/components/layout/CommandPalette.d.ts.map +1 -1
- package/dist/shell/components/organization/InviteMemberDialog.d.ts.map +1 -1
- package/dist/shell/components/organization/PermissionMatrixEditor.d.ts +6 -1
- package/dist/shell/components/organization/PermissionMatrixEditor.d.ts.map +1 -1
- package/dist/shell/components/organization/PermissionProfilesTab.d.ts.map +1 -1
- package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
- package/dist/shell/components/plugins/PluginSettingsPanel.d.ts.map +1 -1
- package/dist/shell/components/settings/ConnectedUserProfile.d.ts +2 -1
- package/dist/shell/components/settings/ConnectedUserProfile.d.ts.map +1 -1
- package/dist/shell/components/settings/SettingsPage.d.ts +8 -1
- package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
- package/dist/shell/config/theme/tokens.d.ts +12 -1
- package/dist/shell/config/theme/tokens.d.ts.map +1 -1
- package/dist/shell/config/theme/utils.d.ts +8 -0
- package/dist/shell/config/theme/utils.d.ts.map +1 -1
- package/dist/shell/lib/i18n.d.ts.map +1 -1
- package/dist/shell/types/client-orders.d.ts +4 -0
- package/dist/shell/types/client-orders.d.ts.map +1 -1
- package/dist/shell/types/crud.d.ts +4 -0
- package/dist/shell/types/crud.d.ts.map +1 -1
- package/package.json +7 -9
- package/src/app/AdminShell.tsx +103 -35
- package/src/app/LoginPage.tsx +2 -264
- package/src/app/{createFayzApp.tsx → admin-app.tsx} +98 -40
- package/src/app/config.ts +44 -2
- package/src/app/scaffold.tsx +13 -26
- package/src/crud/CrudDetailPage.tsx +43 -9
- package/src/crud/CrudPage.tsx +1 -1
- package/src/index.ts +12 -5
- package/src/org/adapters/supabase.ts +176 -28
- package/src/org/context.tsx +33 -2
- package/src/shell/components/crud/archetypes/ClientOrdersTab.tsx +12 -4
- package/src/shell/components/layout/BottomNav.tsx +39 -4
- package/src/shell/components/layout/CommandPalette.tsx +14 -10
- package/src/shell/components/organization/InviteMemberDialog.tsx +14 -3
- package/src/shell/components/organization/PermissionMatrixEditor.tsx +48 -10
- package/src/shell/components/organization/PermissionProfilesTab.tsx +65 -23
- package/src/shell/components/organization/TeamTab.tsx +36 -9
- package/src/shell/components/plugins/PluginSettingsPanel.tsx +5 -3
- package/src/shell/components/settings/ConnectedUserProfile.tsx +68 -25
- package/src/shell/components/settings/SettingsPage.tsx +12 -3
- package/src/shell/config/theme/tokens.ts +13 -1
- package/src/shell/config/theme/utils.ts +76 -15
- package/src/shell/lib/i18n.ts +4 -0
- package/src/shell/types/client-orders.ts +5 -0
- package/src/shell/types/crud.ts +4 -0
- package/dist/app/createFayzApp.d.ts +0 -11
- package/dist/app/createFayzApp.d.ts.map +0 -1
- package/dist/chunk-DJW67TCY.js.map +0 -1
- package/dist/chunk-QGMDVLLW.cjs.map +0 -1
- package/dist/shell/components/organization/ImpersonationBanner.d.ts +0 -3
- package/dist/shell/components/organization/ImpersonationBanner.d.ts.map +0 -1
- package/dist/shell/components/settings/ConnectedHolidaysSettings.d.ts +0 -3
- package/dist/shell/components/settings/ConnectedHolidaysSettings.d.ts.map +0 -1
- package/dist/shell/components/settings/ConnectedLocationsOverview.d.ts +0 -3
- package/dist/shell/components/settings/ConnectedLocationsOverview.d.ts.map +0 -1
- package/dist/shell/createSaasApp.d.ts +0 -145
- package/dist/shell/createSaasApp.d.ts.map +0 -1
- package/src/shell/components/organization/ImpersonationBanner.tsx +0 -29
- package/src/shell/components/settings/ConnectedHolidaysSettings.tsx +0 -99
- package/src/shell/components/settings/ConnectedLocationsOverview.tsx +0 -57
- package/src/shell/createSaasApp.tsx +0 -943
package/src/app/AdminShell.tsx
CHANGED
|
@@ -2,6 +2,8 @@ import * as React from 'react'
|
|
|
2
2
|
import {
|
|
3
3
|
AppShell,
|
|
4
4
|
type NavigationItem,
|
|
5
|
+
type BottomNavItem,
|
|
6
|
+
type MobileHeaderVariant,
|
|
5
7
|
ModuleLayoutProvider,
|
|
6
8
|
PageTransition,
|
|
7
9
|
type ModuleNavVariant,
|
|
@@ -14,13 +16,15 @@ import {
|
|
|
14
16
|
Avatar,
|
|
15
17
|
AvatarImage,
|
|
16
18
|
AvatarFallback,
|
|
19
|
+
useLayoutStore,
|
|
17
20
|
} from '@fayz-ai/ui'
|
|
21
|
+
import { CommandPalette, type CommandItem } from '../shell/components/layout/CommandPalette'
|
|
18
22
|
import { useOrganizationStore } from '../org/store'
|
|
19
23
|
import { useAuth } from '@fayz-ai/auth'
|
|
24
|
+
import { AuthGate } from '@fayz-ai/plugin-auth'
|
|
20
25
|
import { usePluginRuntime, resolvePluginComponent, useTranslation } from '@fayz-ai/core'
|
|
21
26
|
import type { PermissionAction, PluginNavigationEntry, PluginRouteDefinition, PluginSettingsTab } from '@fayz-ai/core'
|
|
22
27
|
import { useAdminPath, navigateTo, matchRoute, routeScore } from './routing'
|
|
23
|
-
import { LoginPage } from './LoginPage'
|
|
24
28
|
import type { CustomPage } from './config'
|
|
25
29
|
import type { AuthProvider } from '@fayz-ai/core'
|
|
26
30
|
import { setEntityRouteMap } from '../lib/entity-routes'
|
|
@@ -55,12 +59,25 @@ export interface AdminShellProps {
|
|
|
55
59
|
oauthProviders?: Exclude<AuthProvider, 'email'>[]
|
|
56
60
|
showSettings?: boolean
|
|
57
61
|
showOrgSettings?: boolean
|
|
62
|
+
/** Show the workspace/org switcher at the sidebar top. Default: true.
|
|
63
|
+
* Single-org apps (config.org.multiOrg === false) get false from the
|
|
64
|
+
* scaffold so B2C users never see an organization concept. */
|
|
65
|
+
showOrgSwitcher?: boolean
|
|
66
|
+
/** Show the branding ("Identidade Visual") + company "Geral" settings tabs.
|
|
67
|
+
* Default: true. B2C apps pass false to hide org-identity settings. */
|
|
68
|
+
showBranding?: boolean
|
|
58
69
|
/** Wrap the main content in an inset "framed" card (default: true). The
|
|
59
70
|
* sidebar is always flush/full-height. */
|
|
60
71
|
contentFrame?: boolean
|
|
61
72
|
/** How module-internal navigation renders. Defaults to 'tabs' for the
|
|
62
73
|
* 'sidebar' layout and 'rail' for 'topbar'. */
|
|
63
74
|
moduleNav?: ModuleNavVariant
|
|
75
|
+
/** Mobile bottom tab bar, passed straight through to AppShell. */
|
|
76
|
+
bottomNav?: BottomNavItem[]
|
|
77
|
+
/** Fired when a bottom-nav `action` item (e.g. the center "+") is tapped. */
|
|
78
|
+
onBottomNavAction?: (id: string) => void
|
|
79
|
+
/** Mobile header treatment (<md), passed straight through to AppShell. */
|
|
80
|
+
mobileHeader?: MobileHeaderVariant
|
|
64
81
|
}
|
|
65
82
|
|
|
66
83
|
// ---------------------------------------------------------------------------
|
|
@@ -196,10 +213,25 @@ function sectionOrder(section: NavigationItem['section']): number {
|
|
|
196
213
|
return section === 'main' ? 0 : section === 'secondary' ? 1 : 2
|
|
197
214
|
}
|
|
198
215
|
|
|
199
|
-
function compareNavigation(a:
|
|
216
|
+
function compareNavigation(a: NavigationItem & { position?: number }, b: NavigationItem & { position?: number }): number {
|
|
200
217
|
const sectionDelta = sectionOrder(a.section) - sectionOrder(b.section)
|
|
201
218
|
if (sectionDelta !== 0) return sectionDelta
|
|
202
|
-
return a.position - b.position
|
|
219
|
+
return (a.position ?? 0) - (b.position ?? 0)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function findModuleParent(
|
|
223
|
+
item: OrderedNavigationItem,
|
|
224
|
+
items: OrderedNavigationItem[],
|
|
225
|
+
): OrderedNavigationItem | null {
|
|
226
|
+
const candidates = items
|
|
227
|
+
.filter((candidate) =>
|
|
228
|
+
candidate.section === item.section &&
|
|
229
|
+
candidate.route !== item.route &&
|
|
230
|
+
item.route.startsWith(`${candidate.route}/`)
|
|
231
|
+
)
|
|
232
|
+
.sort((a, b) => b.route.length - a.route.length)
|
|
233
|
+
|
|
234
|
+
return candidates[0] ?? null
|
|
203
235
|
}
|
|
204
236
|
|
|
205
237
|
function pageToNavigationItem(page: CustomPage, fallbackPosition: number): OrderedNavigationItem | null {
|
|
@@ -214,7 +246,8 @@ function pageToNavigationItem(page: CustomPage, fallbackPosition: number): Order
|
|
|
214
246
|
permission: page.permission,
|
|
215
247
|
position: page.position ?? fallbackPosition,
|
|
216
248
|
children: page.children
|
|
217
|
-
?.
|
|
249
|
+
?.filter((child) => child.nav !== false)
|
|
250
|
+
.map((child, index) => pageToNavigationItem(child, index))
|
|
218
251
|
.filter((item): item is OrderedNavigationItem => Boolean(item)),
|
|
219
252
|
}
|
|
220
253
|
}
|
|
@@ -275,7 +308,7 @@ function buildSettingsTabs(
|
|
|
275
308
|
return settingsTabs
|
|
276
309
|
}
|
|
277
310
|
|
|
278
|
-
function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSettings = true, showOrgSettings = false, contentFrame = true, moduleNav }: AdminShellProps) {
|
|
311
|
+
function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSettings = true, showOrgSettings = false, showBranding = true, showOrgSwitcher = true, contentFrame = true, moduleNav, bottomNav, onBottomNavAction, mobileHeader }: AdminShellProps) {
|
|
279
312
|
const runtime = usePluginRuntime()
|
|
280
313
|
const t = useTranslation()
|
|
281
314
|
const can = usePermissionOptional()
|
|
@@ -293,12 +326,49 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
|
|
|
293
326
|
position: entry.position,
|
|
294
327
|
}))
|
|
295
328
|
for (const [index, page] of pages.entries()) {
|
|
329
|
+
// `nav: false` routes the page but keeps it out of the sidebar/topbar nav
|
|
330
|
+
// (mobile-only pages reached via bottomNav/avatar). Routes are still built
|
|
331
|
+
// from `pages` in collectPageRoutes below, so the page remains navigable.
|
|
332
|
+
if (page.nav === false) continue
|
|
296
333
|
const item = pageToNavigationItem(page, index + 1000)
|
|
297
|
-
if (item)
|
|
334
|
+
if (!item) continue
|
|
335
|
+
|
|
336
|
+
const parent = findModuleParent(item, items)
|
|
337
|
+
if (parent) {
|
|
338
|
+
const children = parent.children ?? [{
|
|
339
|
+
...parent,
|
|
340
|
+
id: `${parent.id}:index`,
|
|
341
|
+
children: undefined,
|
|
342
|
+
position: -1,
|
|
343
|
+
}]
|
|
344
|
+
if (!children.some((child) => child.route === item.route)) {
|
|
345
|
+
parent.children = [...children, item].sort(compareNavigation)
|
|
346
|
+
}
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
items.push(item)
|
|
298
351
|
}
|
|
299
352
|
return items.sort(compareNavigation)
|
|
300
353
|
}, [runtime.navigation, pages])
|
|
301
354
|
|
|
355
|
+
// Command palette (⌘K + the topbar Search button). The palette owns the ⌘K
|
|
356
|
+
// key listener, so it must be MOUNTED for the shortcut to work — the topbar
|
|
357
|
+
// Search button drives the same shared layout store.
|
|
358
|
+
const commandPaletteOpen = useLayoutStore((s) => s.commandPaletteOpen)
|
|
359
|
+
const setCommandPaletteOpen = useLayoutStore((s) => s.setCommandPaletteOpen)
|
|
360
|
+
const commandItems = React.useMemo<CommandItem[]>(() => {
|
|
361
|
+
const navGroup = tr('layout.commandPalette.navigation', 'Navigation')
|
|
362
|
+
const items: CommandItem[] = []
|
|
363
|
+
for (const nav of navigation) {
|
|
364
|
+
items.push({ id: nav.id, label: nav.label, icon: nav.icon, group: navGroup, action: () => navigateTo(nav.route) })
|
|
365
|
+
for (const child of nav.children ?? []) {
|
|
366
|
+
items.push({ id: child.id, label: `${nav.label} · ${child.label}`, icon: child.icon, group: navGroup, action: () => navigateTo(child.route) })
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return items
|
|
370
|
+
}, [navigation])
|
|
371
|
+
|
|
302
372
|
// Route table: plugin routes + custom pages, most-specific-first.
|
|
303
373
|
const routes = React.useMemo<RouteEntry[]>(() => {
|
|
304
374
|
const list: RouteEntry[] = []
|
|
@@ -362,7 +432,7 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
|
|
|
362
432
|
|
|
363
433
|
const activeContent = ActiveComponent ? (
|
|
364
434
|
match.route.path === '/settings' || match.route.path === '/settings/*'
|
|
365
|
-
? <SettingsPage extraTabs={settingsTabs} />
|
|
435
|
+
? <SettingsPage extraTabs={settingsTabs} showCompany={showBranding} showBranding={showBranding} />
|
|
366
436
|
: <ActiveComponent {...activeParams} />
|
|
367
437
|
) : null
|
|
368
438
|
|
|
@@ -376,10 +446,14 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
|
|
|
376
446
|
user={shellUser}
|
|
377
447
|
pageTitle={activePageTitle}
|
|
378
448
|
currentPath={path}
|
|
449
|
+
bottomNav={bottomNav}
|
|
450
|
+
onBottomNavAction={onBottomNavAction}
|
|
451
|
+
mobileHeader={mobileHeader}
|
|
379
452
|
onNavigate={(route) => navigateTo(route)}
|
|
380
453
|
onSignOut={() => { void signOut() }}
|
|
454
|
+
onProfile={() => navigateTo('/perfil')}
|
|
381
455
|
onSettings={() => navigateTo('/settings')}
|
|
382
|
-
sidebarTopContent={<WorkspaceSwitcher />}
|
|
456
|
+
sidebarTopContent={showOrgSwitcher ? <WorkspaceSwitcher /> : undefined}
|
|
383
457
|
userMenuSlot={
|
|
384
458
|
<AdminUserMenu
|
|
385
459
|
user={shellUser}
|
|
@@ -395,7 +469,7 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
|
|
|
395
469
|
>
|
|
396
470
|
{match?.route.fullBleed
|
|
397
471
|
? <div className="h-full min-h-0 overflow-hidden">{activeContent}</div>
|
|
398
|
-
: <div className="space-y-6 p-6">{activeContent}</div>}
|
|
472
|
+
: <div className="space-y-4 p-3 md:space-y-6 md:p-6">{activeContent}</div>}
|
|
399
473
|
</PageTransition>
|
|
400
474
|
) : (
|
|
401
475
|
<div className="flex h-full flex-col items-center justify-center p-12 text-center">
|
|
@@ -413,37 +487,31 @@ function AdminShellInner({ appName, layout = 'sidebar', logo, pages = [], showSe
|
|
|
413
487
|
</div>
|
|
414
488
|
)}
|
|
415
489
|
</AppShell>
|
|
490
|
+
<CommandPalette
|
|
491
|
+
commands={commandItems}
|
|
492
|
+
open={commandPaletteOpen}
|
|
493
|
+
onOpenChange={setCommandPaletteOpen}
|
|
494
|
+
/>
|
|
416
495
|
</ModuleLayoutProvider>
|
|
417
496
|
)
|
|
418
497
|
}
|
|
419
498
|
|
|
420
499
|
/** The shell + an auth gate in front of it. */
|
|
421
500
|
export function AdminShell(props: AdminShellProps) {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
<
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
loginLogo={props.loginLogo}
|
|
438
|
-
layout={props.loginLayout}
|
|
439
|
-
tagline={props.loginTagline}
|
|
440
|
-
description={props.loginDescription}
|
|
441
|
-
showOAuth={props.showOAuth}
|
|
442
|
-
oauthProviders={props.oauthProviders}
|
|
443
|
-
/>
|
|
444
|
-
)
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
return <AdminShellInner {...props} />
|
|
501
|
+
return (
|
|
502
|
+
<AuthGate
|
|
503
|
+
requireAuth={props.requireAuth ?? true}
|
|
504
|
+
appName={props.appName}
|
|
505
|
+
logo={props.logo}
|
|
506
|
+
loginLogo={props.loginLogo}
|
|
507
|
+
layout={props.loginLayout}
|
|
508
|
+
tagline={props.loginTagline}
|
|
509
|
+
description={props.loginDescription}
|
|
510
|
+
showOAuth={props.showOAuth}
|
|
511
|
+
oauthProviders={props.oauthProviders}
|
|
512
|
+
>
|
|
513
|
+
<AdminShellInner {...props} />
|
|
514
|
+
</AuthGate>
|
|
515
|
+
)
|
|
448
516
|
}
|
|
449
517
|
AdminShell.displayName = 'AdminShell'
|
package/src/app/LoginPage.tsx
CHANGED
|
@@ -1,264 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
import { useAuth } from '@fayz-ai/auth'
|
|
4
|
-
import { useTranslation } from '@fayz-ai/core'
|
|
5
|
-
import type { AuthProvider } from '@fayz-ai/core'
|
|
6
|
-
|
|
7
|
-
export interface LoginPageProps {
|
|
8
|
-
appName?: string
|
|
9
|
-
logo?: React.ReactNode
|
|
10
|
-
loginLogo?: React.ReactNode
|
|
11
|
-
layout?: 'split' | 'centered'
|
|
12
|
-
tagline?: string
|
|
13
|
-
description?: string
|
|
14
|
-
showOAuth?: boolean
|
|
15
|
-
oauthProviders?: Exclude<AuthProvider, 'email'>[]
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
type View = 'login' | 'signup' | 'recovery'
|
|
19
|
-
|
|
20
|
-
function providerLabel(provider: string): string {
|
|
21
|
-
return provider.charAt(0).toUpperCase() + provider.slice(1)
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function LoginPage({
|
|
25
|
-
appName = 'Fayz',
|
|
26
|
-
logo,
|
|
27
|
-
loginLogo,
|
|
28
|
-
layout = 'split',
|
|
29
|
-
tagline,
|
|
30
|
-
description,
|
|
31
|
-
showOAuth = false,
|
|
32
|
-
oauthProviders = ['google'],
|
|
33
|
-
}: LoginPageProps) {
|
|
34
|
-
const t = useTranslation()
|
|
35
|
-
const tr = (key: string, fallback: string) => {
|
|
36
|
-
const v = t(key)
|
|
37
|
-
return !v || v === key || v.includes('{{') ? fallback : v
|
|
38
|
-
}
|
|
39
|
-
const { signIn, signUp, signInWithOAuth, resetPassword, isLoading, error } = useAuth()
|
|
40
|
-
const [view, setView] = React.useState<View>('login')
|
|
41
|
-
const [email, setEmail] = React.useState('')
|
|
42
|
-
const [password, setPassword] = React.useState('')
|
|
43
|
-
const [fullName, setFullName] = React.useState('')
|
|
44
|
-
const [notice, setNotice] = React.useState<string | null>(null)
|
|
45
|
-
const [submitting, setSubmitting] = React.useState(false)
|
|
46
|
-
|
|
47
|
-
const busy = submitting || isLoading
|
|
48
|
-
const brandLogo = loginLogo ?? logo
|
|
49
|
-
const heading = view === 'login'
|
|
50
|
-
? tr('auth.login.title', 'Welcome back')
|
|
51
|
-
: view === 'signup'
|
|
52
|
-
? tr('auth.signup.title', 'Create account')
|
|
53
|
-
: tr('auth.recovery.title', 'Reset password')
|
|
54
|
-
const subtitle = view === 'login'
|
|
55
|
-
? tr('auth.login.enterCredentials', 'Enter your credentials to continue')
|
|
56
|
-
: view === 'signup'
|
|
57
|
-
? tr('auth.signup.getStarted', 'Create your account to get started')
|
|
58
|
-
: tr('auth.recovery.resetSubtitle', 'Enter your email and we will send reset instructions')
|
|
59
|
-
|
|
60
|
-
async function submitLogin(e: React.FormEvent) {
|
|
61
|
-
e.preventDefault()
|
|
62
|
-
setNotice(null)
|
|
63
|
-
setSubmitting(true)
|
|
64
|
-
try {
|
|
65
|
-
await signIn(email, password)
|
|
66
|
-
} catch {
|
|
67
|
-
// error is surfaced by useAuth().error
|
|
68
|
-
} finally {
|
|
69
|
-
setSubmitting(false)
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function submitSignup(e: React.FormEvent) {
|
|
74
|
-
e.preventDefault()
|
|
75
|
-
setNotice(null)
|
|
76
|
-
setSubmitting(true)
|
|
77
|
-
try {
|
|
78
|
-
await signUp(email, password, fullName)
|
|
79
|
-
} catch {
|
|
80
|
-
// error is surfaced by useAuth().error
|
|
81
|
-
} finally {
|
|
82
|
-
setSubmitting(false)
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
async function submitRecovery(e: React.FormEvent) {
|
|
87
|
-
e.preventDefault()
|
|
88
|
-
setSubmitting(true)
|
|
89
|
-
try {
|
|
90
|
-
await resetPassword(email)
|
|
91
|
-
setNotice(tr('auth.recovery.sent', 'Password reset instructions sent.'))
|
|
92
|
-
} catch {
|
|
93
|
-
// error is surfaced by useAuth().error
|
|
94
|
-
} finally {
|
|
95
|
-
setSubmitting(false)
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function submitOAuth(provider: AuthProvider) {
|
|
100
|
-
setSubmitting(true)
|
|
101
|
-
try {
|
|
102
|
-
await signInWithOAuth(provider)
|
|
103
|
-
} finally {
|
|
104
|
-
setSubmitting(false)
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const LoginForm = (
|
|
109
|
-
<form onSubmit={view === 'signup' ? submitSignup : view === 'recovery' ? submitRecovery : submitLogin} className="space-y-4">
|
|
110
|
-
{view === 'signup' && (
|
|
111
|
-
<div className="space-y-1.5">
|
|
112
|
-
<label className="text-sm font-medium text-foreground" htmlFor="login-name">
|
|
113
|
-
{tr('auth.fullName', 'Full name')}
|
|
114
|
-
</label>
|
|
115
|
-
<Input id="login-name" value={fullName} onChange={(e) => setFullName(e.target.value)} required disabled={busy} />
|
|
116
|
-
</div>
|
|
117
|
-
)}
|
|
118
|
-
<div className="space-y-1.5">
|
|
119
|
-
<label className="text-sm font-medium text-foreground" htmlFor="login-email">
|
|
120
|
-
{tr('auth.email', 'Email')}
|
|
121
|
-
</label>
|
|
122
|
-
<Input
|
|
123
|
-
id="login-email"
|
|
124
|
-
type="email"
|
|
125
|
-
autoComplete="username"
|
|
126
|
-
value={email}
|
|
127
|
-
onChange={(e) => setEmail(e.target.value)}
|
|
128
|
-
required
|
|
129
|
-
disabled={busy}
|
|
130
|
-
/>
|
|
131
|
-
</div>
|
|
132
|
-
{view !== 'recovery' && (
|
|
133
|
-
<div className="space-y-1.5">
|
|
134
|
-
<div className="flex items-center justify-between gap-3">
|
|
135
|
-
<label className="text-sm font-medium text-foreground" htmlFor="login-password">
|
|
136
|
-
{tr('auth.password', 'Password')}
|
|
137
|
-
</label>
|
|
138
|
-
{view === 'login' && (
|
|
139
|
-
<button type="button" className="text-xs font-medium text-primary hover:underline" onClick={() => setView('recovery')}>
|
|
140
|
-
{tr('auth.forgotPassword', 'Forgot password?')}
|
|
141
|
-
</button>
|
|
142
|
-
)}
|
|
143
|
-
</div>
|
|
144
|
-
<Input
|
|
145
|
-
id="login-password"
|
|
146
|
-
type="password"
|
|
147
|
-
autoComplete={view === 'signup' ? 'new-password' : 'current-password'}
|
|
148
|
-
value={password}
|
|
149
|
-
onChange={(e) => setPassword(e.target.value)}
|
|
150
|
-
required
|
|
151
|
-
disabled={busy}
|
|
152
|
-
/>
|
|
153
|
-
</div>
|
|
154
|
-
)}
|
|
155
|
-
{error && <p className="text-sm text-destructive">{String(error.message ?? error)}</p>}
|
|
156
|
-
{notice && <p className="text-sm text-success">{notice}</p>}
|
|
157
|
-
<Button type="submit" className="w-full" disabled={busy}>
|
|
158
|
-
{busy
|
|
159
|
-
? tr('common.loading', 'Loading...')
|
|
160
|
-
: view === 'signup'
|
|
161
|
-
? tr('auth.signUp', 'Sign up')
|
|
162
|
-
: view === 'recovery'
|
|
163
|
-
? tr('auth.recovery.submit', 'Send reset link')
|
|
164
|
-
: tr('auth.signIn', 'Sign in')}
|
|
165
|
-
</Button>
|
|
166
|
-
{showOAuth && view === 'login' && oauthProviders.length > 0 && (
|
|
167
|
-
<div className="space-y-2">
|
|
168
|
-
{oauthProviders.map((provider) => (
|
|
169
|
-
<Button
|
|
170
|
-
key={provider}
|
|
171
|
-
type="button"
|
|
172
|
-
variant="outline"
|
|
173
|
-
className="w-full"
|
|
174
|
-
disabled={busy}
|
|
175
|
-
onClick={() => { void submitOAuth(provider) }}
|
|
176
|
-
>
|
|
177
|
-
{tr(`auth.oauth.${provider}`, `Continue with ${providerLabel(provider)}`)}
|
|
178
|
-
</Button>
|
|
179
|
-
))}
|
|
180
|
-
</div>
|
|
181
|
-
)}
|
|
182
|
-
</form>
|
|
183
|
-
)
|
|
184
|
-
|
|
185
|
-
const viewSwitch = view === 'login' ? (
|
|
186
|
-
<p className="text-center text-sm text-muted-foreground">
|
|
187
|
-
{tr('auth.login.noAccount', 'Do not have an account?')}{' '}
|
|
188
|
-
<button type="button" onClick={() => setView('signup')} className="font-medium text-primary hover:underline">
|
|
189
|
-
{tr('auth.signUp', 'Sign up')}
|
|
190
|
-
</button>
|
|
191
|
-
</p>
|
|
192
|
-
) : (
|
|
193
|
-
<p className="text-center text-sm text-muted-foreground">
|
|
194
|
-
{view === 'signup' ? tr('auth.signup.hasAccount', 'Already have an account?') : tr('auth.recovery.remembered', 'Remembered your password?')}{' '}
|
|
195
|
-
<button type="button" onClick={() => setView('login')} className="font-medium text-primary hover:underline">
|
|
196
|
-
{tr('auth.signIn', 'Sign in')}
|
|
197
|
-
</button>
|
|
198
|
-
</p>
|
|
199
|
-
)
|
|
200
|
-
|
|
201
|
-
if (layout === 'centered') {
|
|
202
|
-
return (
|
|
203
|
-
<div className="flex min-h-screen items-center justify-center bg-sidebar p-4">
|
|
204
|
-
<div className="relative w-full max-w-md rounded-2xl border border-border/50 bg-card p-8 shadow-2xl sm:p-10">
|
|
205
|
-
<div className="mb-8 text-center">
|
|
206
|
-
{brandLogo && <div className="mb-4 flex justify-center">{brandLogo}</div>}
|
|
207
|
-
<h1 className="text-2xl font-bold text-foreground">{heading}</h1>
|
|
208
|
-
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
|
209
|
-
</div>
|
|
210
|
-
<div className="space-y-6">
|
|
211
|
-
{LoginForm}
|
|
212
|
-
{viewSwitch}
|
|
213
|
-
</div>
|
|
214
|
-
</div>
|
|
215
|
-
</div>
|
|
216
|
-
)
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return (
|
|
220
|
-
<div className="flex min-h-screen bg-sidebar">
|
|
221
|
-
<div className="relative hidden w-1/2 overflow-hidden lg:block">
|
|
222
|
-
<div className="absolute inset-0 bg-gradient-to-br from-sidebar-accent/50 via-transparent to-sidebar/80" />
|
|
223
|
-
<div className="absolute -left-20 -top-20 h-80 w-80 rounded-full bg-primary/10" />
|
|
224
|
-
<div className="absolute -bottom-10 -right-10 h-60 w-60 rounded-full bg-primary/5" />
|
|
225
|
-
<div className="absolute right-1/4 top-1/3 h-40 w-40 rounded-full bg-primary/5" />
|
|
226
|
-
<div className="relative flex h-full flex-col justify-end p-12">
|
|
227
|
-
<div className="space-y-4">
|
|
228
|
-
<p className="text-sm font-medium text-sidebar-muted">{appName}</p>
|
|
229
|
-
<h2 className="text-3xl font-bold text-sidebar-foreground">
|
|
230
|
-
{tagline ?? tr('auth.login.welcomeTo', `Welcome to ${appName}`)}
|
|
231
|
-
</h2>
|
|
232
|
-
{description && <p className="max-w-md text-sm leading-relaxed text-sidebar-muted">{description}</p>}
|
|
233
|
-
<div className="mt-8 flex items-center gap-3 rounded-xl bg-sidebar-accent/40 p-4">
|
|
234
|
-
<div className="flex -space-x-2">
|
|
235
|
-
{['bg-primary', 'bg-success', 'bg-warning'].map((bg, i) => (
|
|
236
|
-
<div key={i} className={`${bg} h-8 w-8 rounded-full border-2 border-sidebar`} />
|
|
237
|
-
))}
|
|
238
|
-
</div>
|
|
239
|
-
<p className="text-xs text-sidebar-foreground/80">
|
|
240
|
-
{tr('auth.login.socialProof', `${appName} keeps your operation moving.`)}
|
|
241
|
-
</p>
|
|
242
|
-
</div>
|
|
243
|
-
</div>
|
|
244
|
-
</div>
|
|
245
|
-
</div>
|
|
246
|
-
<div className="flex flex-1 flex-col lg:p-3 lg:pl-3">
|
|
247
|
-
<div className="flex flex-1 flex-col justify-center bg-background px-6 py-12 lg:rounded-[1.25rem] lg:px-16">
|
|
248
|
-
<div className="mx-auto w-full max-w-sm">
|
|
249
|
-
{brandLogo && <div className="mb-8">{brandLogo}</div>}
|
|
250
|
-
<div className="mb-8">
|
|
251
|
-
<h1 className="text-2xl font-bold text-foreground">{heading}</h1>
|
|
252
|
-
<p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
|
|
253
|
-
</div>
|
|
254
|
-
<div className="space-y-6">
|
|
255
|
-
{LoginForm}
|
|
256
|
-
{viewSwitch}
|
|
257
|
-
</div>
|
|
258
|
-
</div>
|
|
259
|
-
</div>
|
|
260
|
-
</div>
|
|
261
|
-
</div>
|
|
262
|
-
)
|
|
263
|
-
}
|
|
264
|
-
LoginPage.displayName = 'AdminLoginPage'
|
|
1
|
+
export { LoginPage } from '@fayz-ai/plugin-auth'
|
|
2
|
+
export type { LoginPageProps } from '@fayz-ai/plugin-auth'
|