@fayz-ai/saas 0.6.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 +6 -8
- 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
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
<
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
onClick={() => handleDelete(profile)}
|
|
162
|
+
title={t('organization.permissions.duplicate')}
|
|
163
|
+
onClick={() => handleDuplicate(profile)}
|
|
138
164
|
>
|
|
139
|
-
<
|
|
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
|
-
|
|
20
|
-
import {
|
|
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(
|
|
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
|
-
|
|
74
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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.
|
|
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">
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
3
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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('
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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('
|
|
92
|
+
toast.error('Falha ao enviar avatar', { description: err?.message })
|
|
55
93
|
}
|
|
56
94
|
}
|
|
57
95
|
|
|
58
|
-
|
|
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
|