@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.
Files changed (91) hide show
  1. package/dist/app/AdminShell.d.ts +14 -1
  2. package/dist/app/AdminShell.d.ts.map +1 -1
  3. package/dist/app/LoginPage.d.ts +2 -16
  4. package/dist/app/LoginPage.d.ts.map +1 -1
  5. package/dist/app/admin-app.d.ts +21 -0
  6. package/dist/app/admin-app.d.ts.map +1 -0
  7. package/dist/app/config.d.ts +40 -3
  8. package/dist/app/config.d.ts.map +1 -1
  9. package/dist/app/scaffold.d.ts +1 -2
  10. package/dist/app/scaffold.d.ts.map +1 -1
  11. package/dist/{chunk-DJW67TCY.js → chunk-DCRT47W4.js} +155 -28
  12. package/dist/chunk-DCRT47W4.js.map +1 -0
  13. package/dist/{chunk-QGMDVLLW.cjs → chunk-SRIUD35R.cjs} +155 -28
  14. package/dist/chunk-SRIUD35R.cjs.map +1 -0
  15. package/dist/crud/CrudDetailPage.d.ts.map +1 -1
  16. package/dist/index.cjs +1127 -5792
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.ts +4 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +839 -5509
  21. package/dist/index.js.map +1 -1
  22. package/dist/org/adapters/supabase.d.ts +18 -0
  23. package/dist/org/adapters/supabase.d.ts.map +1 -1
  24. package/dist/org/context.d.ts.map +1 -1
  25. package/dist/org/index.cjs +11 -10
  26. package/dist/org/index.js +2 -1
  27. package/dist/shell/components/crud/archetypes/ClientOrdersTab.d.ts +4 -2
  28. package/dist/shell/components/crud/archetypes/ClientOrdersTab.d.ts.map +1 -1
  29. package/dist/shell/components/layout/BottomNav.d.ts.map +1 -1
  30. package/dist/shell/components/layout/CommandPalette.d.ts.map +1 -1
  31. package/dist/shell/components/organization/InviteMemberDialog.d.ts.map +1 -1
  32. package/dist/shell/components/organization/PermissionMatrixEditor.d.ts +6 -1
  33. package/dist/shell/components/organization/PermissionMatrixEditor.d.ts.map +1 -1
  34. package/dist/shell/components/organization/PermissionProfilesTab.d.ts.map +1 -1
  35. package/dist/shell/components/organization/TeamTab.d.ts.map +1 -1
  36. package/dist/shell/components/plugins/PluginSettingsPanel.d.ts.map +1 -1
  37. package/dist/shell/components/settings/ConnectedUserProfile.d.ts +2 -1
  38. package/dist/shell/components/settings/ConnectedUserProfile.d.ts.map +1 -1
  39. package/dist/shell/components/settings/SettingsPage.d.ts +8 -1
  40. package/dist/shell/components/settings/SettingsPage.d.ts.map +1 -1
  41. package/dist/shell/config/theme/tokens.d.ts +12 -1
  42. package/dist/shell/config/theme/tokens.d.ts.map +1 -1
  43. package/dist/shell/config/theme/utils.d.ts +8 -0
  44. package/dist/shell/config/theme/utils.d.ts.map +1 -1
  45. package/dist/shell/lib/i18n.d.ts.map +1 -1
  46. package/dist/shell/types/client-orders.d.ts +4 -0
  47. package/dist/shell/types/client-orders.d.ts.map +1 -1
  48. package/dist/shell/types/crud.d.ts +4 -0
  49. package/dist/shell/types/crud.d.ts.map +1 -1
  50. package/package.json +7 -9
  51. package/src/app/AdminShell.tsx +103 -35
  52. package/src/app/LoginPage.tsx +2 -264
  53. package/src/app/{createFayzApp.tsx → admin-app.tsx} +98 -40
  54. package/src/app/config.ts +44 -2
  55. package/src/app/scaffold.tsx +13 -26
  56. package/src/crud/CrudDetailPage.tsx +43 -9
  57. package/src/crud/CrudPage.tsx +1 -1
  58. package/src/index.ts +12 -5
  59. package/src/org/adapters/supabase.ts +176 -28
  60. package/src/org/context.tsx +33 -2
  61. package/src/shell/components/crud/archetypes/ClientOrdersTab.tsx +12 -4
  62. package/src/shell/components/layout/BottomNav.tsx +39 -4
  63. package/src/shell/components/layout/CommandPalette.tsx +14 -10
  64. package/src/shell/components/organization/InviteMemberDialog.tsx +14 -3
  65. package/src/shell/components/organization/PermissionMatrixEditor.tsx +48 -10
  66. package/src/shell/components/organization/PermissionProfilesTab.tsx +65 -23
  67. package/src/shell/components/organization/TeamTab.tsx +36 -9
  68. package/src/shell/components/plugins/PluginSettingsPanel.tsx +5 -3
  69. package/src/shell/components/settings/ConnectedUserProfile.tsx +68 -25
  70. package/src/shell/components/settings/SettingsPage.tsx +12 -3
  71. package/src/shell/config/theme/tokens.ts +13 -1
  72. package/src/shell/config/theme/utils.ts +76 -15
  73. package/src/shell/lib/i18n.ts +4 -0
  74. package/src/shell/types/client-orders.ts +5 -0
  75. package/src/shell/types/crud.ts +4 -0
  76. package/dist/app/createFayzApp.d.ts +0 -11
  77. package/dist/app/createFayzApp.d.ts.map +0 -1
  78. package/dist/chunk-DJW67TCY.js.map +0 -1
  79. package/dist/chunk-QGMDVLLW.cjs.map +0 -1
  80. package/dist/shell/components/organization/ImpersonationBanner.d.ts +0 -3
  81. package/dist/shell/components/organization/ImpersonationBanner.d.ts.map +0 -1
  82. package/dist/shell/components/settings/ConnectedHolidaysSettings.d.ts +0 -3
  83. package/dist/shell/components/settings/ConnectedHolidaysSettings.d.ts.map +0 -1
  84. package/dist/shell/components/settings/ConnectedLocationsOverview.d.ts +0 -3
  85. package/dist/shell/components/settings/ConnectedLocationsOverview.d.ts.map +0 -1
  86. package/dist/shell/createSaasApp.d.ts +0 -145
  87. package/dist/shell/createSaasApp.d.ts.map +0 -1
  88. package/src/shell/components/organization/ImpersonationBanner.tsx +0 -29
  89. package/src/shell/components/settings/ConnectedHolidaysSettings.tsx +0 -99
  90. package/src/shell/components/settings/ConnectedLocationsOverview.tsx +0 -57
  91. package/src/shell/createSaasApp.tsx +0 -943
@@ -1,10 +1,10 @@
1
1
  import * as React from 'react'
2
2
  import { Card } from '@fayz-ai/ui'
3
3
  import { Input } from '@fayz-ai/ui'
4
- import { Button } from '@fayz-ai/ui'
5
4
  import { Badge } from '@fayz-ai/ui'
6
5
  import { Checkbox, type CheckboxColor } from '@fayz-ai/ui'
7
- import { ChevronDown, ChevronRight, Search } from 'lucide-react'
6
+ import { useSaveBar, useBackHandler } from '@fayz-ai/ui'
7
+ import { ArrowLeft, ChevronDown, ChevronRight, Search } from 'lucide-react'
8
8
  import { useTranslation } from '../../hooks/useTranslation'
9
9
  import type { FeatureDeclaration, PermissionAction, PermissionProfile, SystemPermission } from '../../types/permissions'
10
10
 
@@ -28,9 +28,14 @@ interface PermissionMatrixEditorProps {
28
28
  onSave: (data: { name: string; description?: string; systemPermissions: SystemPermission[]; grants: Record<string, PermissionAction[]> }) => void
29
29
  onCancel: () => void
30
30
  saving?: boolean
31
+ /** True when creating/duplicating (not editing an existing custom role). Drives
32
+ * the SaveBar dirty state + the breadcrumb crumb. */
33
+ isNew?: boolean
34
+ /** Label of the parent list, shown in the back breadcrumb (e.g. "Permissões"). */
35
+ parentLabel?: string
31
36
  }
32
37
 
33
- export function PermissionMatrixEditor({ features, profile, onSave, onCancel, saving }: PermissionMatrixEditorProps) {
38
+ export function PermissionMatrixEditor({ features, profile, onSave, onCancel, saving, isNew, parentLabel }: PermissionMatrixEditorProps) {
34
39
  const { t } = useTranslation()
35
40
  const [name, setName] = React.useState(profile?.name ?? '')
36
41
  const [description, setDescription] = React.useState(profile?.description ?? '')
@@ -39,6 +44,22 @@ export function PermissionMatrixEditor({ features, profile, onSave, onCancel, sa
39
44
  const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set())
40
45
  const [search, setSearch] = React.useState('')
41
46
 
47
+ // Snapshot the initial form so an EDIT only shows the SaveBar once something
48
+ // actually changes. A NEW role (create/duplicate) is unsaved by definition, so
49
+ // it's "dirty" as soon as it has a name.
50
+ const initial = React.useRef({
51
+ name: profile?.name ?? '',
52
+ description: profile?.description ?? '',
53
+ systemPerms: JSON.stringify(profile?.systemPermissions ?? []),
54
+ grants: JSON.stringify(profile?.grants ?? {}),
55
+ })
56
+ const dirty = isNew
57
+ ? name.trim() !== ''
58
+ : name !== initial.current.name ||
59
+ description !== initial.current.description ||
60
+ JSON.stringify(systemPerms) !== initial.current.systemPerms ||
61
+ JSON.stringify(grants) !== initial.current.grants
62
+
42
63
  const toggleSystemPerm = (perm: SystemPermission) => {
43
64
  setSystemPerms((prev) =>
44
65
  prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]
@@ -68,6 +89,17 @@ export function PermissionMatrixEditor({ features, profile, onSave, onCancel, sa
68
89
  onSave({ name: name.trim(), description: description.trim() || undefined, systemPermissions: systemPerms, grants })
69
90
  }
70
91
 
92
+ // Surface Save/Discard through the app-wide floating SaveBar (same pattern as
93
+ // CRUD forms), and wire Escape → back-to-list.
94
+ useSaveBar({
95
+ dirty,
96
+ saving,
97
+ onSave: handleSave,
98
+ onDiscard: onCancel,
99
+ saveLabel: isNew ? t('organization.permissions.createProfile') : t('organization.permissions.updateProfile'),
100
+ })
101
+ useBackHandler(onCancel)
102
+
71
103
  // Group features, filtering by search
72
104
  const groups = React.useMemo(() => {
73
105
  const filtered = search
@@ -84,6 +116,18 @@ export function PermissionMatrixEditor({ features, profile, onSave, onCancel, sa
84
116
 
85
117
  return (
86
118
  <div className="space-y-6">
119
+ {/* Back-to-list breadcrumb, matching the CRUD subpage pattern. */}
120
+ <nav className="flex items-center gap-1.5 text-sm text-muted-foreground">
121
+ <button type="button" onClick={onCancel} className="inline-flex items-center gap-1 transition-colors hover:text-foreground">
122
+ <ArrowLeft className="h-3.5 w-3.5" />
123
+ {parentLabel ?? t('organization.permissions.title')}
124
+ </button>
125
+ <span>/</span>
126
+ <span className="max-w-[240px] truncate font-medium text-foreground">
127
+ {name.trim() || t('organization.permissions.createCustom')}
128
+ </span>
129
+ </nav>
130
+
87
131
  <div className="grid gap-4 sm:grid-cols-2">
88
132
  <div>
89
133
  <label className="text-sm font-medium">{t('organization.permissions.profileName')}</label>
@@ -194,13 +238,7 @@ export function PermissionMatrixEditor({ features, profile, onSave, onCancel, sa
194
238
  </table>
195
239
  </div>
196
240
  </Card>
197
-
198
- <div className="flex justify-end gap-2">
199
- <Button variant="outline" onClick={onCancel}>{t('common.cancel')}</Button>
200
- <Button onClick={handleSave} disabled={!name.trim() || saving}>
201
- {saving ? t('common.saving') : profile ? t('organization.permissions.updateProfile') : t('organization.permissions.createProfile')}
202
- </Button>
203
- </div>
241
+ {/* Save / Discard are provided by the app-wide floating SaveBar (useSaveBar). */}
204
242
  </div>
205
243
  )
206
244
  }
@@ -1,12 +1,15 @@
1
1
  import * as React from 'react'
2
- import { Shield, Pencil, Trash2, Plus, Eye } from 'lucide-react'
2
+ import { Shield, Pencil, Trash2, Plus, Eye, Copy } from 'lucide-react'
3
3
  import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@fayz-ai/ui'
4
4
  import { Button } from '@fayz-ai/ui'
5
5
  import { Badge } from '@fayz-ai/ui'
6
+ import { Skeleton } from '@fayz-ai/ui'
6
7
  import { PermissionMatrixEditor } from './PermissionMatrixEditor'
7
8
  import { usePermissionsStore } from '../../stores/permissions.store'
8
9
  import { useOrganizationStore } from '../../stores/organization.store'
9
- import { useOrgAdapterOptional } from '../../lib/org-context'
10
+ // Use the NATIVE org adapter (the one the app actually provides). The shell
11
+ // lib/org-context is a separate, un-provided context that returns null here.
12
+ import { useOrgAdapterOptional } from '../../../org'
10
13
  import { useTranslation } from '../../hooks/useTranslation'
11
14
  import type { PermissionProfile, PermissionAction, SystemPermission } from '../../types/permissions'
12
15
 
@@ -25,8 +28,22 @@ export function PermissionProfilesTab() {
25
28
  const { t } = useTranslation()
26
29
  const [editingProfile, setEditingProfile] = React.useState<PermissionProfile | null>(null)
27
30
  const [creating, setCreating] = React.useState(false)
31
+ // When duplicating, we open the CREATE flow pre-filled from a source role (its
32
+ // grants/system perms), with a fresh name — system roles are static, so users
33
+ // customize by copying rather than editing them in place.
34
+ const [duplicateSource, setDuplicateSource] = React.useState<PermissionProfile | null>(null)
28
35
  const [saving, setSaving] = React.useState(false)
29
36
 
37
+ const handleDuplicate = (profile: PermissionProfile) => {
38
+ setEditingProfile(null)
39
+ setCreating(false)
40
+ setDuplicateSource({
41
+ ...profile,
42
+ name: t('organization.permissions.copyName', { name: profile.name }) || `${profile.name} (copy)`,
43
+ isSystem: false,
44
+ })
45
+ }
46
+
30
47
  const memberCountByProfile = React.useMemo(() => {
31
48
  const counts: Record<string, number> = {}
32
49
  for (const m of members) {
@@ -48,6 +65,7 @@ export function PermissionProfilesTab() {
48
65
  setProfiles(updated)
49
66
  setEditingProfile(null)
50
67
  setCreating(false)
68
+ setDuplicateSource(null)
51
69
  } catch {
52
70
  // ignore
53
71
  } finally {
@@ -66,20 +84,17 @@ export function PermissionProfilesTab() {
66
84
  }
67
85
  }
68
86
 
69
- if (creating || editingProfile) {
87
+ if (creating || editingProfile || duplicateSource) {
70
88
  return (
71
- <div className="space-y-4">
72
- <h3 className="text-lg font-semibold">
73
- {editingProfile ? t('organization.permissions.editProfile', { name: editingProfile.name }) : t('organization.permissions.createCustom')}
74
- </h3>
75
- <PermissionMatrixEditor
76
- features={features}
77
- profile={editingProfile ?? undefined}
78
- onSave={handleSave}
79
- onCancel={() => { setEditingProfile(null); setCreating(false) }}
80
- saving={saving}
81
- />
82
- </div>
89
+ <PermissionMatrixEditor
90
+ features={features}
91
+ profile={editingProfile ?? duplicateSource ?? undefined}
92
+ isNew={!editingProfile}
93
+ parentLabel={t('organization.permissions.title')}
94
+ onSave={handleSave}
95
+ onCancel={() => { setEditingProfile(null); setCreating(false); setDuplicateSource(null) }}
96
+ saving={saving}
97
+ />
83
98
  )
84
99
  }
85
100
 
@@ -97,7 +112,20 @@ export function PermissionProfilesTab() {
97
112
  </div>
98
113
 
99
114
  <div className="grid gap-3">
100
- {profiles.map((profile) => (
115
+ {profiles.length === 0 ? (
116
+ // Roles load via central org hydration; show skeletons until they arrive.
117
+ Array.from({ length: 4 }).map((_, i) => (
118
+ <Card key={`sk-${i}`}>
119
+ <CardContent className="flex items-center gap-4 p-4">
120
+ <Skeleton className="h-10 w-10 shrink-0 rounded-lg" />
121
+ <div className="flex-1 space-y-2">
122
+ <Skeleton className="h-4 w-40" />
123
+ <Skeleton className="h-3 w-64" />
124
+ </div>
125
+ </CardContent>
126
+ </Card>
127
+ ))
128
+ ) : profiles.map((profile) => (
101
129
  <Card key={profile.id}>
102
130
  <CardContent className="flex items-center gap-4 p-4">
103
131
  <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
@@ -126,18 +154,32 @@ export function PermissionProfilesTab() {
126
154
  <Eye className="h-3.5 w-3.5" />
127
155
  </Button>
128
156
  )}
129
- <Button variant="ghost" size="sm" onClick={() => setEditingProfile(profile)}>
130
- <Pencil className="h-3.5 w-3.5" />
131
- </Button>
157
+ {/* Duplicate — always available; the primary (and only) way to
158
+ customize a static system role. */}
132
159
  <Button
133
160
  variant="ghost"
134
161
  size="sm"
135
- className="text-destructive hover:text-destructive"
136
- disabled={profile.isSystem}
137
- onClick={() => handleDelete(profile)}
162
+ title={t('organization.permissions.duplicate')}
163
+ onClick={() => handleDuplicate(profile)}
138
164
  >
139
- <Trash2 className="h-3.5 w-3.5" />
165
+ <Copy className="h-3.5 w-3.5" />
140
166
  </Button>
167
+ {/* Edit + delete are for custom roles only — system roles are static. */}
168
+ {!profile.isSystem && (
169
+ <>
170
+ <Button variant="ghost" size="sm" onClick={() => setEditingProfile(profile)}>
171
+ <Pencil className="h-3.5 w-3.5" />
172
+ </Button>
173
+ <Button
174
+ variant="ghost"
175
+ size="sm"
176
+ className="text-destructive hover:text-destructive"
177
+ onClick={() => handleDelete(profile)}
178
+ >
179
+ <Trash2 className="h-3.5 w-3.5" />
180
+ </Button>
181
+ </>
182
+ )}
141
183
  </div>
142
184
  </CardContent>
143
185
  </Card>
@@ -3,6 +3,7 @@ import { UserPlus, MoreHorizontal, Mail, Clock } from 'lucide-react'
3
3
  import { Card } from '@fayz-ai/ui'
4
4
  import { Button } from '@fayz-ai/ui'
5
5
  import { Badge } from '@fayz-ai/ui'
6
+ import { Skeleton } from '@fayz-ai/ui'
6
7
  import { Avatar, AvatarFallback, AvatarImage } from '@fayz-ai/ui'
7
8
  import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@fayz-ai/ui'
8
9
  import {
@@ -13,11 +14,15 @@ import {
13
14
  DropdownSeparator,
14
15
  } from '@fayz-ai/ui'
15
16
  import { InviteMemberDialog } from './InviteMemberDialog'
17
+ import { toast } from '../notifications/ToastProvider'
16
18
  import { useOrganizationStore } from '../../stores/organization.store'
17
19
  import { usePermissionsStore } from '../../stores/permissions.store'
18
20
  import { useInviteStore } from '../../stores/invite.store'
19
- import { useOrgAdapterOptional } from '../../lib/org-context'
20
- import { useAuthStore } from '../../stores/auth.store'
21
+ // Native org adapter (see InviteMemberDialog) — the shell context is un-provided.
22
+ import { useOrgAdapterOptional as useNativeOrgAdapter } from '../../../org'
23
+ import type { OrgAdapter } from '../../types/org-adapter'
24
+ const useOrgAdapterOptional = () => useNativeOrgAdapter() as unknown as OrgAdapter | null
25
+ import { useAuthStore } from '@fayz-ai/auth'
21
26
  import { usePermission } from '../../hooks/usePermission'
22
27
  import { useTranslation } from '../../hooks/useTranslation'
23
28
  import { dedup } from '../../lib/dedup'
@@ -30,7 +35,7 @@ export function TeamTab() {
30
35
  const profiles = usePermissionsStore((s) => s.profiles)
31
36
  const invites = useInviteStore((s) => s.invites)
32
37
  const setInvites = useInviteStore((s) => s.setInvites)
33
- const user = useAuthStore((s) => s.user)
38
+ const { user } = useAuthStore()
34
39
  const { hasSystemPermission } = usePermission()
35
40
 
36
41
  const [inviteOpen, setInviteOpen] = React.useState(false)
@@ -70,8 +75,9 @@ export function TeamTab() {
70
75
  await adapter.revokeInvite(currentOrg.id, inviteId)
71
76
  const updated = await adapter.listInvites(currentOrg.id)
72
77
  setInvites(updated)
73
- } catch {
74
- // ignore
78
+ toast.success(t('organization.team.inviteRevoked'))
79
+ } catch (err: any) {
80
+ toast.error(t('organization.team.inviteActionFailed'), { description: err?.message })
75
81
  }
76
82
  }
77
83
 
@@ -81,8 +87,9 @@ export function TeamTab() {
81
87
  await adapter.resendInvite(currentOrg.id, inviteId)
82
88
  const updated = await adapter.listInvites(currentOrg.id)
83
89
  setInvites(updated)
84
- } catch {
85
- // ignore
90
+ toast.success(t('organization.team.inviteResent'))
91
+ } catch (err: any) {
92
+ toast.error(t('organization.team.inviteActionFailed'), { description: err?.message })
86
93
  }
87
94
  }
88
95
 
@@ -121,7 +128,25 @@ export function TeamTab() {
121
128
  </tr>
122
129
  </thead>
123
130
  <tbody className="divide-y">
124
- {members.map((member) => {
131
+ {members.length === 0 ? (
132
+ // Members load via central org hydration; show skeleton rows meanwhile.
133
+ Array.from({ length: 3 }).map((_, i) => (
134
+ <tr key={`sk-${i}`}>
135
+ <td className="p-3">
136
+ <div className="flex items-center gap-3">
137
+ <Skeleton className="h-8 w-8 shrink-0 rounded-full" />
138
+ <div className="space-y-1.5">
139
+ <Skeleton className="h-3.5 w-32" />
140
+ <Skeleton className="h-3 w-40" />
141
+ </div>
142
+ </div>
143
+ </td>
144
+ <td className="p-3"><Skeleton className="h-6 w-24 rounded-full" /></td>
145
+ <td className="p-3"><Skeleton className="h-3 w-20" /></td>
146
+ {canManageTeam && <td className="p-3" />}
147
+ </tr>
148
+ ))
149
+ ) : members.map((member) => {
125
150
  const initials = member.user.fullName
126
151
  .split(' ')
127
152
  .map((n) => n[0])
@@ -163,7 +188,9 @@ export function TeamTab() {
163
188
  </SelectContent>
164
189
  </Select>
165
190
  ) : (
166
- <Badge variant="secondary" className="text-xs">{member.profileName}</Badge>
191
+ <Badge variant="secondary" className="text-xs">
192
+ {profiles.find((p) => p.id === member.profileId)?.name ?? member.profileName}
193
+ </Badge>
167
194
  )}
168
195
  </td>
169
196
  <td className="p-3 text-sm text-muted-foreground">
@@ -99,7 +99,7 @@ function PropertiesTab({ registries, routeBase, regLabel }: {
99
99
  )}
100
100
  >
101
101
  {Icon && <Icon className="h-3.5 w-3.5" />}
102
- {regLabel(r.id, r.entity.name)}
102
+ {regLabel(r.id, r.entity.namePlural ?? r.entity.name)}
103
103
  </button>
104
104
  )
105
105
  })}
@@ -161,7 +161,7 @@ export function PluginSettingsPanel({ title, subtitle, generalSettings, registri
161
161
  }
162
162
  if (customTabs) {
163
163
  for (const tab of customTabs) {
164
- list.push({ id: `_custom_${tab.id}`, label: tab.label, icon: tab.icon, type: 'custom' })
164
+ list.push({ id: tab.id, label: tab.label, icon: tab.icon, type: 'custom' })
165
165
  }
166
166
  }
167
167
  if (connectors.length > 0) {
@@ -184,6 +184,8 @@ export function PluginSettingsPanel({ title, subtitle, generalSettings, registri
184
184
  const rest = hash.slice(routeBase.length + 1).split('/')[0]
185
185
  const match = tabs.find((t) => t.id === rest)
186
186
  if (match) return match.id
187
+ const legacyCustom = rest.startsWith('_custom_') ? rest.slice('_custom_'.length) : null
188
+ if (legacyCustom && tabs.find((t) => t.id === legacyCustom && t.type === 'custom')) return legacyCustom
187
189
  // Legacy/nested: a registry id (flat or under _properties) → Properties tab.
188
190
  if (registries?.some((r) => r.id === rest) && tabs.some((t) => t.id === '_properties')) return '_properties'
189
191
  }
@@ -207,7 +209,7 @@ export function PluginSettingsPanel({ title, subtitle, generalSettings, registri
207
209
  }
208
210
 
209
211
  const activeTabDef = tabs.find((t) => t.id === activeTab) ?? tabs[0]
210
- const customTabContent = customTabs?.find((t) => `_custom_${t.id}` === activeTab)
212
+ const customTabContent = customTabs?.find((t) => t.id === activeTab)
211
213
 
212
214
  return (
213
215
  <div className="space-y-4">
@@ -1,41 +1,74 @@
1
+ import * as React from 'react'
1
2
  import { UserProfile } from './UserProfile'
2
- import { useAuthStore } from '../../stores/auth.store'
3
- import { getSupabaseClient } from '../../lib/supabase'
3
+ // The signed-in user lives in @fayz-ai/auth's store (the one AuthProvider fills) —
4
+ // the shell stores/auth.store is a separate, unpopulated store.
5
+ import { useAuthStore } from '@fayz-ai/auth'
6
+ import { getSupabaseClient, getSupabaseClientSafe, CORE_SCHEMA } from '../../lib/supabase'
4
7
  import { toast } from '../notifications/ToastProvider'
5
8
 
9
+ interface ProfileRow {
10
+ full_name: string | null
11
+ avatar_url: string | null
12
+ email: string | null
13
+ }
14
+
15
+ // The "/me" surface: the current user's canonical profile = the auth user (id,
16
+ // e-mail) merged with their saas_core.profiles row (name, avatar). Reads/writes go
17
+ // to saas_core.profiles (RLS: id = auth.uid(), so a user only ever sees/edits their
18
+ // own), and keep the auth-user metadata in sync so both stay coherent.
6
19
  export function ConnectedUserProfile() {
7
20
  const user = useAuthStore((s) => s.user)
8
21
  const setUser = useAuthStore((s) => s.setUser)
22
+ const [profile, setProfile] = React.useState<ProfileRow | null>(null)
23
+
24
+ // Fill: load the canonical profile row on mount (falls back to auth metadata).
25
+ React.useEffect(() => {
26
+ const supabase = getSupabaseClientSafe()
27
+ if (!user || !supabase) return // mock/no-backend: just use the auth-store user
28
+ let cancelled = false
29
+ void supabase
30
+ .schema(CORE_SCHEMA)
31
+ .from('profiles')
32
+ .select('full_name, avatar_url, email')
33
+ .eq('id', user.id)
34
+ .maybeSingle()
35
+ .then(({ data }) => {
36
+ if (!cancelled && data) setProfile(data as ProfileRow)
37
+ })
38
+ return () => {
39
+ cancelled = true
40
+ }
41
+ }, [user?.id])
42
+
9
43
  const handleSave = async (data: { fullName: string }) => {
44
+ if (!user) return
10
45
  try {
11
46
  const supabase = getSupabaseClient()
47
+ // Upsert the canonical row (INSERT if the profile doesn't exist yet).
48
+ const { error: profileError } = await supabase
49
+ .schema(CORE_SCHEMA)
50
+ .from('profiles')
51
+ .upsert({ id: user.id, full_name: data.fullName, email: user.email }, { onConflict: 'id' })
52
+ if (profileError) throw profileError
12
53
 
13
- if (data.fullName !== user?.fullName) {
14
- const { error: metaError } = await supabase.auth.updateUser({
15
- data: { full_name: data.fullName },
16
- })
17
- if (metaError) throw metaError
54
+ // Keep the auth-user metadata in sync (used across the app's session).
55
+ const { error: metaError } = await supabase.auth.updateUser({ data: { full_name: data.fullName } })
56
+ if (metaError) throw metaError
18
57
 
19
- const { error: profileError } = await supabase
20
- .from('profiles')
21
- .update({ full_name: data.fullName })
22
- .eq('id', user!.id)
23
- if (profileError) throw profileError
24
-
25
- setUser(user ? { ...user, fullName: data.fullName } : null)
26
- }
27
-
28
- toast.success('Profile updated')
58
+ setUser({ ...user, fullName: data.fullName })
59
+ setProfile({ full_name: data.fullName, avatar_url: profile?.avatar_url ?? null, email: user.email })
60
+ toast.success('Perfil atualizado')
29
61
  } catch (err: any) {
30
- toast.error('Failed to update', { description: err?.message })
62
+ toast.error('Falha ao atualizar', { description: err?.message })
31
63
  }
32
64
  }
33
65
 
34
66
  const handleAvatarChange = async (file: File) => {
67
+ if (!user) return
35
68
  try {
36
69
  const supabase = getSupabaseClient()
37
70
  const ext = file.name.split('.').pop()
38
- const path = `avatars/${user!.id}.${ext}`
71
+ const path = `avatars/${user.id}.${ext}`
39
72
 
40
73
  const { error: uploadError } = await supabase.storage
41
74
  .from('avatars')
@@ -45,15 +78,25 @@ export function ConnectedUserProfile() {
45
78
  const { data: urlData } = supabase.storage.from('avatars').getPublicUrl(path)
46
79
  const avatarUrl = urlData.publicUrl
47
80
 
48
- await supabase.auth.updateUser({ data: { avatar_url: avatarUrl } })
49
- await supabase.from('profiles').update({ avatar_url: avatarUrl }).eq('id', user!.id)
81
+ const { error: profileError } = await supabase
82
+ .schema(CORE_SCHEMA)
83
+ .from('profiles')
84
+ .upsert({ id: user.id, avatar_url: avatarUrl, email: user.email }, { onConflict: 'id' })
85
+ if (profileError) throw profileError
50
86
 
51
- setUser(user ? { ...user, avatarUrl } : null)
52
- toast.success('Avatar updated')
87
+ await supabase.auth.updateUser({ data: { avatar_url: avatarUrl } })
88
+ setUser({ ...user, avatarUrl })
89
+ setProfile({ full_name: profile?.full_name ?? null, avatar_url: avatarUrl, email: user.email })
90
+ toast.success('Avatar atualizado')
53
91
  } catch (err: any) {
54
- toast.error('Failed to upload avatar', { description: err?.message })
92
+ toast.error('Falha ao enviar avatar', { description: err?.message })
55
93
  }
56
94
  }
57
95
 
58
- return <UserProfile user={user as any} onSave={handleSave} onAvatarChange={handleAvatarChange} />
96
+ // Merge: prefer the canonical profile row, fall back to auth metadata.
97
+ const merged = user
98
+ ? { ...user, fullName: profile?.full_name ?? user.fullName, avatarUrl: profile?.avatar_url ?? user.avatarUrl }
99
+ : null
100
+
101
+ return <UserProfile user={merged as any} onSave={handleSave} onAvatarChange={handleAvatarChange} />
59
102
  }
@@ -21,6 +21,13 @@ interface SettingsPageProps {
21
21
  className?: string
22
22
  beforeContent?: React.ReactNode
23
23
  afterContent?: React.ReactNode
24
+ /** Show the company "Geral" (organization general) tab. Default: true.
25
+ * B2C apps pass false to hide org-identity settings. Ignored when `tabs` is
26
+ * supplied explicitly. */
27
+ showCompany?: boolean
28
+ /** Show the branding ("Identidade Visual") tab. Default: true. Ignored when
29
+ * `tabs` is supplied explicitly. */
30
+ showBranding?: boolean
24
31
  }
25
32
 
26
33
  export function SettingsPage({
@@ -30,15 +37,17 @@ export function SettingsPage({
30
37
  className,
31
38
  beforeContent,
32
39
  afterContent,
40
+ showCompany = true,
41
+ showBranding = true,
33
42
  }: SettingsPageProps) {
34
43
  const { t } = useTranslation()
35
44
 
36
45
  const defaultTabs: SettingsTab[] = React.useMemo(() => [
37
- { id: 'general', label: t('settings.general'), icon: <Building2 className="h-4 w-4" />, component: <ConnectedCompanySettings /> },
46
+ ...(showCompany ? [{ id: 'general', label: t('settings.general'), icon: <Building2 className="h-4 w-4" />, component: <ConnectedCompanySettings /> }] : []),
38
47
  { id: 'profile', label: t('settings.profile'), icon: <User className="h-4 w-4" />, component: <ConnectedUserProfile /> },
39
48
  { id: 'security', label: t('settings.security'), icon: <Shield className="h-4 w-4" />, component: <ConnectedSecuritySettings /> },
40
- { id: 'branding', label: t('settings.branding'), icon: <Palette className="h-4 w-4" />, component: <ConnectedBrandingSettings /> },
41
- ], [t])
49
+ ...(showBranding ? [{ id: 'branding', label: t('settings.branding'), icon: <Palette className="h-4 w-4" />, component: <ConnectedBrandingSettings /> }] : []),
50
+ ], [t, showCompany, showBranding])
42
51
 
43
52
  const baseTabs = tabs ?? defaultTabs
44
53
  const resolvedTabs = extraTabs ? [...baseTabs, ...extraTabs] : baseTabs
@@ -82,7 +82,17 @@ export type ThemeDensity = 'compact' | 'comfortable' | 'spacious'
82
82
  export type ThemeShadow = 'none' | 'subtle' | 'medium' | 'bold'
83
83
  export type ThemeFont = 'system' | 'inter' | 'dm-sans' | 'poppins' | 'geist' | 'plus-jakarta' | 'outfit' | 'nunito' | 'source-sans' | 'raleway' | 'manrope'
84
84
 
85
+ /** Native sidebar palette selector. 'neutral' (default) keeps the light Polaris
86
+ * rail; 'brand' opts the sidebar into the app's `brand` color with a readable
87
+ * light foreground (icons, active item, workspace switcher, user row). */
88
+ export type SidebarStyle = 'brand' | 'neutral'
89
+
85
90
  export interface SaasTheme {
91
+ /** Explicit discriminator so config code never has to duck-type the theme
92
+ * shape (SaasTheme vs CreateThemeOptions share keys like `brand`/`name`).
93
+ * Optional for hand-written configs — `isSaasTheme` falls back to a
94
+ * structural check — but generated configs should always set it. */
95
+ __kind?: 'saas-theme'
86
96
  name: string
87
97
  preset?: FayzThemePresetId
88
98
  brand: string
@@ -90,7 +100,9 @@ export interface SaasTheme {
90
100
  density?: ThemeDensity
91
101
  shadow?: ThemeShadow
92
102
  font?: ThemeFont
93
- sidebar?: {
103
+ /** 'brand' | 'neutral' to derive the sidebar palette natively, or an explicit
104
+ * token object for full control. Defaults to neutral when omitted. */
105
+ sidebar?: SidebarStyle | {
94
106
  background: string
95
107
  foreground: string
96
108
  border?: string