@edgedev/create-edge-app 1.1.28 → 1.2.29

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.
@@ -1,12 +1,19 @@
1
1
  <script setup>
2
2
  // TODO: pass possible roles in prop
3
3
  import { toTypedSchema } from '@vee-validate/zod'
4
+ import { ArrowLeft, Loader2, Trash2, User } from 'lucide-vue-next'
4
5
  import * as z from 'zod'
5
6
  const props = defineProps({
6
7
  usersCollectionPath: {
7
8
  type: String,
8
9
  default: () => `organizations/${edgeGlobal.edgeState.currentOrganization}`,
9
10
  },
11
+ defaultImageTags: {
12
+ type: Array,
13
+ default: () => [
14
+ 'Headshots',
15
+ ],
16
+ },
10
17
  metaFields: {
11
18
  type: Array,
12
19
  default: () => [
@@ -49,6 +56,11 @@ const props = defineProps({
49
56
  name: z
50
57
  .string({ required_error: 'Name is required' })
51
58
  .min(1, { message: 'Name is required' }),
59
+ email: z
60
+ .string({ required_error: 'Email is required' })
61
+ .email({ message: 'Invalid email address' })
62
+ .min(6, { message: 'Email must be at least 6 characters long' })
63
+ .max(50, { message: 'Email must be less than 50 characters long' }),
52
64
  }),
53
65
  role: z
54
66
  .string({ required_error: 'Role is required' })
@@ -56,6 +68,10 @@ const props = defineProps({
56
68
  }),
57
69
  ),
58
70
  },
71
+ metaFieldsSchema: {
72
+ type: Object,
73
+ default: null,
74
+ },
59
75
  })
60
76
  // TODO: If a removed user no longer has roles to any organiztions, need to a create new organization for them with
61
77
  // default name of "Personal". This will allow them to continue to use the app.
@@ -65,6 +81,7 @@ const route = useRoute()
65
81
  const edgeFirebase = inject('edgeFirebase')
66
82
  const state = reactive({
67
83
  filter: '',
84
+ roleFilter: 'all',
68
85
  workingItem: {},
69
86
  dialog: false,
70
87
  form: false,
@@ -120,12 +137,22 @@ const showInviteOrgSelect = computed(() => inviteOrgOptions.value.length > 1)
120
137
  const showEditOrgSelect = computed(() => editOrgOptions.value.length > 1)
121
138
  const showRemoveOrgSelect = computed(() => removeOrgOptions.value.length > 1)
122
139
 
140
+ const adminCount = computed(() => {
141
+ return users.value.filter((item) => {
142
+ return item.roles.find((role) => {
143
+ return role.collectionPath === edgeGlobal.edgeState.organizationDocPath.replaceAll('/', '-') && role.role === 'admin'
144
+ })
145
+ }).length
146
+ })
147
+
123
148
  const selfRemoveBlocked = computed(() => {
124
149
  return state.workingItem.userId === edgeFirebase.user.uid
125
150
  && adminCount.value === 1
126
151
  && state.removeOrgIds.includes(edgeGlobal.edgeState.currentOrganization)
127
152
  })
128
153
 
154
+ const emailDisabledHint = 'This field is tied to the user\'s username and can only be changed by them.'
155
+
129
156
  const WIDTHS = {
130
157
  1: 'md:col-span-1',
131
158
  2: 'md:col-span-2',
@@ -143,6 +170,78 @@ const WIDTHS = {
143
170
 
144
171
  const numColsToTailwind = cols => WIDTHS[cols] || 'md:col-span-12'
145
172
 
173
+ const disabledNoteText = 'Contact admin to change.'
174
+
175
+ const getDisabledNote = (field) => {
176
+ if (!field?.disabled)
177
+ return ''
178
+ return field?.disabledNote || disabledNoteText
179
+ }
180
+
181
+ const mergeDisabledNote = (text, field) => {
182
+ const note = getDisabledNote(field)
183
+ if (!note)
184
+ return text || ''
185
+ if (text)
186
+ return `${text} ${note}`
187
+ return note
188
+ }
189
+
190
+ const userKey = (user) => {
191
+ return user?.docId || user?.userId || user?.id || user?.uid || ''
192
+ }
193
+
194
+ const userRoleName = (user) => {
195
+ return String(edgeGlobal.getRoleName(user?.roles, edgeGlobal.edgeState.currentOrganization) || '').trim()
196
+ }
197
+
198
+ const selectedRole = computed(() => {
199
+ if (state.roleFilter === 'all' || state.roleFilter === 'no-role')
200
+ return ''
201
+ return String(state.roleFilter || '').trim()
202
+ })
203
+
204
+ const roleFilterOptions = computed(() => {
205
+ const allRoleNames = Array.from(new Set([
206
+ ...roleNamesOnly.value,
207
+ ...users.value.map(user => userRoleName(user)),
208
+ ]
209
+ .map(name => String(name || '').trim())
210
+ .filter(Boolean)))
211
+ .sort((a, b) => a.localeCompare(b))
212
+
213
+ return [
214
+ { name: 'All Roles', docId: 'all' },
215
+ { name: 'No Role', docId: 'no-role' },
216
+ ...allRoleNames.map(role => ({ name: role, docId: role })),
217
+ ]
218
+ })
219
+
220
+ const usersByRoleFilter = computed(() => {
221
+ if (state.roleFilter === 'all')
222
+ return users.value
223
+ if (state.roleFilter === 'no-role')
224
+ return users.value.filter(user => !userRoleName(user))
225
+ if (!selectedRole.value)
226
+ return users.value
227
+ return users.value.filter(user => userRoleName(user) === selectedRole.value)
228
+ })
229
+
230
+ const PROFILE_IMAGE_SIZE = 96
231
+ const PROFILE_IMAGE_VARIANT = `width=${PROFILE_IMAGE_SIZE},height=${PROFILE_IMAGE_SIZE},fit=cover,quality=85`
232
+
233
+ const profileImageUrl = (url) => {
234
+ if (!url || typeof url !== 'string')
235
+ return ''
236
+ if (url.includes('/cdn-cgi/image/'))
237
+ return url
238
+ if (url.includes('width=') && url.includes('height='))
239
+ return url
240
+ if (url.endsWith('/thumbnail'))
241
+ return url.replace(/\/thumbnail$/, `/${PROFILE_IMAGE_VARIANT}`)
242
+ return url
243
+ }
244
+
146
245
  // Helpers to read/write nested keys like "profile.firstName" on plain objects
147
246
  function getByPath(obj, path, fallback = undefined) {
148
247
  if (!obj || !path)
@@ -176,36 +275,40 @@ function setByPath(obj, path, value) {
176
275
  }
177
276
 
178
277
  const sortedFilteredUsers = computed(() => {
179
- const filter = state.filter.toLowerCase()
278
+ const filter = String(state.filter || '').toLowerCase()
180
279
 
181
280
  const getLastName = (fullName) => {
182
281
  if (!fullName)
183
282
  return ''
184
- const parts = fullName.trim().split(/\s+/)
283
+ const parts = String(fullName).trim().split(/\s+/)
185
284
  return parts[parts.length - 1] || ''
186
285
  }
187
286
 
188
- return users.value
189
- .filter(user => user.meta.name.toLowerCase().includes(filter))
287
+ return usersByRoleFilter.value
288
+ .filter((user) => {
289
+ const name = String(user?.meta?.name || '').toLowerCase()
290
+ const email = String(user?.meta?.email || '').toLowerCase()
291
+ return name.includes(filter) || email.includes(filter)
292
+ })
190
293
  .sort((a, b) => {
191
- const lastA = getLastName(a.meta.name).toLowerCase()
192
- const lastB = getLastName(b.meta.name).toLowerCase()
294
+ const lastA = getLastName(a?.meta?.name).toLowerCase()
295
+ const lastB = getLastName(b?.meta?.name).toLowerCase()
193
296
  return lastA.localeCompare(lastB)
194
297
  })
195
298
  })
196
299
 
197
- const adminCount = computed(() => {
198
- return users.value.filter((item) => {
199
- return item.roles.find((role) => {
200
- return role.collectionPath === edgeGlobal.edgeState.organizationDocPath.replaceAll('/', '-') && role.role === 'admin'
201
- })
202
- }).length
300
+ const detailViewKey = computed(() => {
301
+ if (!state.dialog)
302
+ return 'member-empty'
303
+ return `member-${userKey(state.workingItem) || state.workingItem?.id || 'new'}-${state.saveButton}`
203
304
  })
204
305
 
205
306
  const addItem = () => {
206
307
  state.saveButton = 'Invite User'
207
308
  const newItem = edgeGlobal.dupObject(state.newItem)
208
309
  newItem.meta.email = ''
310
+ newItem.meta.name = ''
311
+ newItem.meta.profilephoto = ''
209
312
  state.workingItem = newItem
210
313
  state.workingItem.id = edgeGlobal.generateShortId()
211
314
  state.currentTitle = 'Invite User'
@@ -367,10 +470,48 @@ const onSubmit = async () => {
367
470
  state.dialog = false
368
471
  }
369
472
 
473
+ const roleSchema = z
474
+ .string({ required_error: 'Role is required' })
475
+ .min(1, { message: 'Role is required' })
476
+
477
+ const baseMetaSchema = z.object({
478
+ name: z
479
+ .string({ required_error: 'Name is required' })
480
+ .min(1, { message: 'Name is required' }),
481
+ email: z
482
+ .string({ required_error: 'Email is required' })
483
+ .email({ message: 'Invalid email address' })
484
+ .min(6, { message: 'Email must be at least 6 characters long' })
485
+ .max(50, { message: 'Email must be less than 50 characters long' }),
486
+ })
487
+
488
+ const buildMetaSchema = () => {
489
+ const extra = props.metaFieldsSchema
490
+ if (!extra)
491
+ return baseMetaSchema
492
+ if (extra?.shape && typeof extra.shape === 'object')
493
+ return baseMetaSchema.merge(extra)
494
+ if (typeof extra === 'object')
495
+ return baseMetaSchema.extend(extra)
496
+ return baseMetaSchema
497
+ }
498
+
499
+ const computedNewUserSchema = computed(() => {
500
+ if (!props.metaFieldsSchema)
501
+ return props.newUserSchema
502
+ return toTypedSchema(z.object({ meta: buildMetaSchema(), role: roleSchema }))
503
+ })
504
+
505
+ const computedUpdateUserSchema = computed(() => {
506
+ if (!props.metaFieldsSchema)
507
+ return props.updateUserSchema
508
+ return toTypedSchema(z.object({ meta: buildMetaSchema(), role: roleSchema }))
509
+ })
510
+
370
511
  const computedUserSchema = computed(() =>
371
512
  state.saveButton === 'Invite User'
372
- ? props.newUserSchema
373
- : props.updateUserSchema,
513
+ ? computedNewUserSchema.value
514
+ : computedUpdateUserSchema.value,
374
515
  )
375
516
 
376
517
  const currentOrganization = computed(() => {
@@ -406,267 +547,434 @@ onBeforeMount(async () => {
406
547
  </script>
407
548
 
408
549
  <template>
409
- <Card v-if="state.loaded" class="w-full flex-1 bg-muted/50 mx-auto w-full border-none shadow-none pt-2">
410
- <slot name="header" :add-item="addItem">
411
- <edge-menu class="bg-secondary text-foreground rounded-none sticky top-0 py-6">
412
- <template #start>
413
- <slot name="header-start">
414
- <component :is="edgeGlobal.iconFromMenu(route)" class="mr-2" />
415
- <span class="capitalize">Members</span>
416
- </slot>
417
- </template>
418
- <template #center>
419
- <slot name="header-center">
420
- <div class="w-full px-6" />
421
- </slot>
422
- </template>
423
- <template #end>
424
- <slot name="header-end" :add-item="addItem">
425
- <edge-shad-button class="bg-primary mx-2 h-6 text-xs" @click="addItem()">
426
- Invite Member
427
- </edge-shad-button>
428
- </slot>
429
- </template>
430
- </edge-menu>
431
- </slot>
432
- <CardContent class="p-3 w-full overflow-y-auto scroll-area">
433
- <Input
434
- v-model="state.filter"
435
- class="mb-2"
436
- placeholder="Filter members..."
437
- />
438
- <div v-if="sortedFilteredUsers.length > 0">
439
- <div v-for="user in sortedFilteredUsers" :key="user.id" class="flex w-full py-2 justify-between items-center cursor-pointer" @click="editItem(user)">
440
- <slot name="user" :user="user">
441
- <Avatar class="handle pointer p-0 h-6 w-6 mr-2">
442
- <User width="18" height="18" />
443
- </Avatar>
444
- <div class="flex gap-2 mr-2 items-center">
445
- <div class="text-md text-bold mr-2">
446
- {{ user.meta.name }}
550
+ <div v-if="state.loaded" class="w-full flex-1 min-h-0 h-[calc(100vh-58px)] overflow-hidden">
551
+ <ResizablePanelGroup direction="horizontal" class="w-full h-full flex-1">
552
+ <ResizablePanel class="bg-sidebar text-sidebar-foreground min-w-[400px]" :default-size="22" :min-size="30">
553
+ <div class="flex flex-col h-full">
554
+ <div class="px-3 py-3 border-b border-sidebar-border bg-sidebar/90">
555
+ <div class="flex items-center justify-between gap-2">
556
+ <div class="flex items-center gap-2 text-sm font-semibold">
557
+ <component :is="edgeGlobal.iconFromMenu(route)" class="h-4 w-4" />
558
+ <span>Members</span>
447
559
  </div>
448
- <edge-chip v-if="user.userId === edgeFirebase.user.uid">
449
- You
450
- </edge-chip>
451
- <!-- <edge-chip v-if="!user.userId" class="bg-primary">
452
- Invited, Not Registered
453
- </edge-chip> -->
454
- </div>
455
- <div class="grow flex gap-2 justify-end">
456
- <template v-if="!user.userId">
457
- <edge-chip class="bg-slate-600 w-[200px]">
458
- {{ user.docId }}
459
- <edge-clipboard-button class="relative ml-1 top-[2px] mt-0" :text="user.docId" />
460
- </edge-chip>
461
- </template>
462
- <edge-chip>
463
- {{ edgeGlobal.getRoleName(user.roles, edgeGlobal.edgeState.currentOrganization) }}
464
- </edge-chip>
560
+ <edge-shad-button size="sm" class="h-7 text-xs bg-primary" @click="addItem()">
561
+ Invite
562
+ </edge-shad-button>
465
563
  </div>
466
- <edge-shad-button
467
- :disabled="users.length === 1"
468
- class="bg-red-400 mx-2 h-6 w-[80px] text-xs"
469
- variant="outline"
470
- @click.stop="deleteConfirm(user)"
471
- >
472
- <span v-if="user.userId === edgeFirebase.user.uid">Leave</span>
473
- <span v-else>Remove</span>
474
- </edge-shad-button>
475
- </slot>
476
- </div>
477
- </div>
478
- <edge-shad-dialog
479
- v-model="state.deleteDialog"
480
- >
481
- <DialogContent>
482
- <DialogHeader>
483
- <DialogTitle>
484
- <span v-if="state.workingItem.userId === edgeFirebase.user.uid">
485
- Remove Yourself?
486
- </span>
487
- <span v-else>
488
- Remove "{{ state.workingItem.meta.name }}"
489
- </span>
490
- </DialogTitle>
491
- <DialogDescription />
492
- </DialogHeader>
493
-
494
- <h3 v-if="selfRemoveBlocked">
495
- You cannot remove yourself from this organization because you are the only admin. You can delete the organization or add another admin.
496
- </h3>
497
- <h3 v-else-if="state.workingItem.userId === edgeFirebase.user.uid">
498
- <span v-if="showRemoveOrgSelect">Select the organizations you want to leave.</span>
499
- <span v-else>Are you sure you want to remove yourself from the organization "{{ currentOrganization.name }}"? You will no longer have access to any of the organization's data.</span>
500
- </h3>
501
- <h3 v-else>
502
- <span v-if="showRemoveOrgSelect">Select the organizations you want to remove "{{ state.workingItem.meta.name }}" from.</span>
503
- <span v-else>Are you sure you want to remove "{{ state.workingItem.meta.name }}" from the organization "{{ currentOrganization.name }}"?</span>
504
- </h3>
505
- <div v-if="showRemoveOrgSelect" class="mt-4 w-full flex flex-wrap gap-2">
506
- <div v-for="org in removeOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
507
- <edge-shad-checkbox
508
- :name="`remove-org-${org.docId}`"
509
- :model-value="state.removeOrgIds.includes(org.docId)"
510
- @update:model-value="val => updateRemoveOrgSelection(org.docId, val)"
511
- >
512
- {{ org.name }}
513
- </edge-shad-checkbox>
564
+ <div class="mt-3 flex items-center gap-2">
565
+ <div class="w-1/2 min-w-0">
566
+ <edge-shad-combobox
567
+ v-model="state.roleFilter"
568
+ :items="roleFilterOptions"
569
+ name="roleFilter"
570
+ item-title="name"
571
+ item-value="docId"
572
+ placeholder="Select role"
573
+ class="w-full !h-8"
574
+ />
575
+ </div>
576
+ <div class="w-1/2 min-w-0">
577
+ <edge-shad-input
578
+ v-model="state.filter"
579
+ label=""
580
+ name="filter"
581
+ class="h-8 w-full"
582
+ placeholder="Filter members..."
583
+ />
584
+ </div>
514
585
  </div>
515
586
  </div>
516
- <DialogFooter class="pt-6 flex justify-between">
517
- <edge-shad-button class="text-white bg-slate-800 hover:bg-slate-400" @click="state.deleteDialog = false">
518
- Cancel
519
- </edge-shad-button>
520
- <edge-shad-button
521
- :disabled="selfRemoveBlocked"
522
- class="w-full"
523
- variant="destructive"
524
- @click="deleteAction()"
525
- >
526
- <span v-if="state.workingItem.userId === edgeFirebase.user.uid">
527
- Leave
528
- </span>
529
- <span v-else>
530
- Remove
531
- </span>
532
- </edge-shad-button>
533
- </DialogFooter>
534
- </DialogContent>
535
- </edge-shad-dialog>
536
- <edge-shad-dialog
537
- v-model="state.dialog"
538
- >
539
- <DialogContent class="w-full max-w-[1200px]">
540
- <edge-shad-form :initial-values="state.workingItem" :schema="computedUserSchema" @submit="onSubmit">
541
- <DialogHeader class="mb-4">
542
- <DialogTitle>
543
- {{ state.currentTitle }}
544
- </DialogTitle>
545
- <DialogDescription />
546
- </DialogHeader>
547
- <slot name="edit-fields" :working-item="state.workingItem">
548
- <edge-g-input
549
- v-model="state.workingItem.role"
550
- name="role"
551
- :disable-tracking="true"
552
- :items="roleNamesOnly"
553
- field-type="select"
554
- label="Role"
555
- :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
556
- :disabled="state.workingItem.userId === edgeFirebase.user.uid"
557
- />
558
- <div v-if="state.saveButton !== 'Invite User' && showEditOrgSelect" class="mt-4 w-full">
559
- <div class="text-sm font-medium text-foreground">
560
- Organizations
561
- </div>
562
- <div class="mt-2 w-full flex flex-wrap gap-2">
563
- <div v-for="org in editOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
564
- <edge-shad-checkbox
565
- :name="`edit-add-org-${org.docId}`"
566
- :model-value="state.editOrgIds.includes(org.docId)"
567
- @update:model-value="val => updateEditOrgSelection(org.docId, val)"
587
+ <div class="flex-1 overflow-y-auto">
588
+ <SidebarMenu class="px-2 py-2 space-y-0">
589
+ <SidebarMenuItem
590
+ v-for="user in sortedFilteredUsers"
591
+ :key="userKey(user)"
592
+ >
593
+ <SidebarMenuButton
594
+ class="w-full !h-auto items-start px-3 py-2"
595
+ :class="state.dialog && userKey(state.workingItem) && userKey(state.workingItem) === userKey(user) ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''"
596
+ @click="editItem(user)"
597
+ >
598
+ <div class="flex w-full items-start gap-3" :class="!user.userId ? 'opacity-70' : ''">
599
+ <Avatar class="h-12 w-12 rounded-md border bg-muted/40 flex items-center justify-center overflow-hidden">
600
+ <img
601
+ v-if="user?.meta?.profilephoto"
602
+ :src="profileImageUrl(user.meta.profilephoto)"
603
+ alt=""
604
+ class="h-full w-full object-cover"
605
+ >
606
+ <User v-else width="24" height="24" />
607
+ </Avatar>
608
+ <div class="min-w-0 flex-1">
609
+ <div class="flex items-center gap-2">
610
+ <span class="text-sm font-medium leading-snug whitespace-normal uppercase">
611
+ {{ user?.meta?.name || user?.meta?.email || 'Unnamed Member' }}
612
+ </span>
613
+ <!-- <span v-if="!user.userId" class="text-[10px] uppercase tracking-wide text-muted-foreground">
614
+ -
615
+ </span> -->
616
+ <edge-chip v-if="user.userId === edgeFirebase.user.uid">
617
+ You
618
+ </edge-chip>
619
+ </div>
620
+ <div class="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground leading-snug">
621
+ <span class="rounded-full bg-secondary px-2 py-0.5 text-[10px] text-secondary-foreground">
622
+ {{ edgeGlobal.getRoleName(user.roles, edgeGlobal.edgeState.currentOrganization) }}
623
+ </span>
624
+ <span v-if="!user.userId && user.docId" class="inline-flex items-center gap-1 whitespace-normal">
625
+ {{ user.docId }}
626
+ <edge-clipboard-button class="relative top-[1px]" :text="user.docId" />
627
+ </span>
628
+ </div>
629
+ </div>
630
+ <edge-shad-button
631
+ size="icon"
632
+ variant="ghost"
633
+ class="h-7 w-7 text-destructive/80 hover:text-destructive"
634
+ @click.stop="deleteConfirm(user)"
568
635
  >
569
- {{ org.name }}
570
- </edge-shad-checkbox>
636
+ <Trash2 class="h-4 w-4" />
637
+ </edge-shad-button>
571
638
  </div>
572
- </div>
639
+ </SidebarMenuButton>
640
+ <Separator class="my-1" />
641
+ </SidebarMenuItem>
642
+ <div v-if="sortedFilteredUsers.length === 0" class="px-4 py-6 text-xs text-muted-foreground">
643
+ No members found.
573
644
  </div>
574
- <div v-if="state.saveButton === 'Invite User' && showInviteOrgSelect" class="mt-4 w-full">
575
- <div class="text-sm font-medium text-foreground">
576
- Add to organizations
577
- </div>
578
- <div class="mt-2 w-full flex flex-wrap gap-2">
579
- <div v-for="org in inviteOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
580
- <edge-shad-checkbox
581
- :name="`invite-org-${org.docId}`"
582
- :model-value="state.inviteOrgIds.includes(org.docId)"
583
- @update:model-value="val => updateInviteOrgSelection(org.docId, val)"
645
+ </SidebarMenu>
646
+ </div>
647
+ </div>
648
+ </ResizablePanel>
649
+ <ResizablePanel class="bg-background">
650
+ <div class="h-full flex flex-col">
651
+ <Transition name="fade" mode="out-in">
652
+ <div v-if="state.dialog" :key="detailViewKey" class="h-full flex flex-col">
653
+ <edge-shad-form
654
+ :key="userKey(state.workingItem) || state.workingItem?.id || 'member-form'"
655
+ :initial-values="state.workingItem"
656
+ :schema="computedUserSchema"
657
+ class="flex flex-col h-full"
658
+ @submit="onSubmit"
659
+ >
660
+ <div class="flex items-center justify-between border-b bg-secondary px-4 py-3">
661
+ <div class="text-sm font-semibold">
662
+ {{ state.currentTitle || 'Member' }}
663
+ </div>
664
+ <div class="flex items-center gap-2">
665
+ <edge-shad-button variant="text" class="text-xs text-red-700" @click="closeDialog">
666
+ Close
667
+ </edge-shad-button>
668
+ <edge-shad-button
669
+ type="submit"
670
+ class="text-xs bg-primary"
671
+ :disabled="state.loading"
584
672
  >
585
- {{ org.name }}
586
- </edge-shad-checkbox>
673
+ <Loader2 v-if="state.loading" class="w-4 h-4 mr-2 animate-spin" />
674
+ {{ state.saveButton }}
675
+ </edge-shad-button>
587
676
  </div>
588
677
  </div>
589
- </div>
590
- <edge-g-input
591
- v-if="state.saveButton === 'Invite User'"
592
- v-model="state.workingItem.meta.email"
593
- name="meta.email"
594
- :disable-tracking="true"
595
- field-type="text"
596
- label="Email"
597
- :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
598
- />
599
- <Separator class="my-6" />
600
- <div class="grid grid-cols-12 gap-2">
601
- <div v-for="field in props.metaFields" :key="field.field" class="mb-3 col-span-12" :class="numColsToTailwind(field.cols)">
602
- <!-- Use explicit model binding so dotted paths (e.g., "address.street") work -->
603
- <edge-g-input
604
- v-if="field?.type === 'textarea'"
605
- :model-value="getByPath(state.workingItem.meta, field.field, '')"
606
- :name="`meta.${field.field}`"
607
- :field-type="field?.type"
608
- :label="field?.label"
609
- parent-tracker-id="user-settings"
610
- :hint="field?.hint"
611
- :disable-tracking="true"
612
- :bindings="{ class: 'h-60' }"
613
- @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
614
- />
615
- <edge-shad-tags
616
- v-else-if="field?.type === 'tags' || field?.type === 'commaTags'"
617
- :model-value="getByPath(state.workingItem.meta, field.field, '')"
618
- :name="`meta.${field.field}`"
619
- :field-type="field?.type"
620
- :label="field?.label"
621
- parent-tracker-id="user-settings"
622
- :hint="field?.hint"
623
- :disable-tracking="true"
624
- @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
625
- />
626
- <edge-g-input
627
- v-else
628
- :model-value="getByPath(state.workingItem.meta, field.field, '')"
629
- :name="`meta.${field.field}`"
630
- :field-type="field?.type"
631
- :label="field?.label"
632
- parent-tracker-id="user-settings"
633
- :hint="field?.hint"
634
- :disable-tracking="true"
635
- @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
636
- />
678
+ <div class="flex-1 overflow-y-auto p-6 space-y-4">
679
+ <slot name="edit-fields" :working-item="state.workingItem">
680
+ <div class="rounded-xl border bg-card p-4 space-y-4 shadow-sm">
681
+ <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
682
+ Member Details
683
+ </div>
684
+ <div class="flex flex-col gap-4 md:flex-row md:items-stretch">
685
+ <div class="w-full md:w-[260px] self-stretch">
686
+ <edge-image-picker
687
+ v-model="state.workingItem.meta.profilephoto"
688
+ label="Profile Photo"
689
+ dialog-title="Select Profile Photo"
690
+ site="clearwater-hub-images"
691
+ :default-tags="props.defaultImageTags"
692
+ height-class="h-full min-h-[180px]"
693
+ :include-cms-all="false"
694
+ />
695
+ </div>
696
+ <div class="flex-1 space-y-4">
697
+ <edge-g-input
698
+ v-model="state.workingItem.role"
699
+ name="role"
700
+ :disable-tracking="true"
701
+ :items="roleNamesOnly"
702
+ field-type="select"
703
+ label="Role"
704
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
705
+ :disabled="state.workingItem.userId === edgeFirebase.user.uid"
706
+ />
707
+ <edge-g-input
708
+ v-model="state.workingItem.meta.name"
709
+ name="meta.name"
710
+ :disable-tracking="true"
711
+ field-type="text"
712
+ label="Name"
713
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
714
+ />
715
+ <edge-g-input
716
+ v-model="state.workingItem.meta.email"
717
+ name="meta.email"
718
+ :disable-tracking="true"
719
+ field-type="text"
720
+ label="Email"
721
+ :disabled="state.saveButton !== 'Invite User'"
722
+ :hint="state.saveButton !== 'Invite User' ? emailDisabledHint : ''"
723
+ :persistent-hint="state.saveButton !== 'Invite User'"
724
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
725
+ />
726
+ <edge-g-input
727
+ v-model="state.workingItem.meta.phone"
728
+ name="meta.phone"
729
+ :disable-tracking="true"
730
+ field-type="text"
731
+ label="Phone"
732
+ :mask-options="{ mask: '(###) ###-####' }"
733
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
734
+ />
735
+ </div>
736
+ </div>
737
+ </div>
738
+ <div v-if="state.saveButton !== 'Invite User' && showEditOrgSelect" class="mt-4 w-full">
739
+ <div class="text-sm font-medium text-foreground">
740
+ Organizations
741
+ </div>
742
+ <div class="mt-2 w-full flex flex-wrap gap-2">
743
+ <div v-for="org in editOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
744
+ <edge-shad-checkbox
745
+ :name="`edit-add-org-${org.docId}`"
746
+ :model-value="state.editOrgIds.includes(org.docId)"
747
+ @update:model-value="val => updateEditOrgSelection(org.docId, val)"
748
+ >
749
+ {{ org.name }}
750
+ </edge-shad-checkbox>
751
+ </div>
752
+ </div>
753
+ </div>
754
+ <div v-if="state.saveButton === 'Invite User' && showInviteOrgSelect" class="mt-4 w-full">
755
+ <div class="text-sm font-medium text-foreground">
756
+ Add to organizations
757
+ </div>
758
+ <div class="mt-2 w-full flex flex-wrap gap-2">
759
+ <div v-for="org in inviteOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
760
+ <edge-shad-checkbox
761
+ :name="`invite-org-${org.docId}`"
762
+ :model-value="state.inviteOrgIds.includes(org.docId)"
763
+ @update:model-value="val => updateInviteOrgSelection(org.docId, val)"
764
+ >
765
+ {{ org.name }}
766
+ </edge-shad-checkbox>
767
+ </div>
768
+ </div>
769
+ </div>
770
+ <Separator class="my-6" />
771
+ <div class="grid grid-cols-12 gap-2">
772
+ <div v-for="field in props.metaFields" :key="field.field" class="mb-3 col-span-12" :class="numColsToTailwind(field.cols)">
773
+ <!-- Use explicit model binding so dotted paths (e.g., "address.street") work -->
774
+ <edge-image-picker
775
+ v-if="field?.type === 'imagePicker'"
776
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
777
+ :label="field?.label || 'Photo'"
778
+ :dialog-title="field?.dialogTitle || 'Select Image'"
779
+ :site="field?.site || 'clearwater-hub-images'"
780
+ :default-tags="field?.tags || []"
781
+ :height-class="field?.heightClass || 'h-[160px]'"
782
+ :disabled="field?.disabled || false"
783
+ :include-cms-all="false"
784
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
785
+ />
786
+ <p v-if="field?.type === 'imagePicker' && field?.disabled" class="mt-1 text-xs text-muted-foreground">
787
+ {{ getDisabledNote(field) }}
788
+ </p>
789
+ <div v-else-if="field?.type === 'richText'" class="member-richtext">
790
+ <edge-shad-html
791
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
792
+ :name="`meta.${field.field}`"
793
+ :label="field?.label"
794
+ :disabled="field?.disabled || false"
795
+ :description="mergeDisabledNote(field?.description, field)"
796
+ :enabled-toggles="field?.enabledToggles || ['bold', 'italic', 'strike', 'bulletlist', 'orderedlist', 'underline']"
797
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
798
+ />
799
+ </div>
800
+ <edge-shad-select-tags
801
+ v-else-if="field?.type === 'selectTags'"
802
+ :model-value="getByPath(state.workingItem.meta, field.field, [])"
803
+ :name="`meta.${field.field}`"
804
+ :label="field?.label"
805
+ :description="mergeDisabledNote(field?.description, field)"
806
+ :items="field?.items || []"
807
+ :item-title="field?.itemTitle || 'title'"
808
+ :item-value="field?.itemValue || 'name'"
809
+ :allow-additions="field?.allowAdditions || false"
810
+ :placeholder="field?.placeholder"
811
+ :disabled="field?.disabled || false"
812
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
813
+ />
814
+ <div v-else-if="field?.type === 'boolean'" class="space-y-1 -mt-3">
815
+ <div class="text-sm font-medium leading-none opacity-0 select-none h-4">
816
+ {{ field?.label || '' }}
817
+ </div>
818
+ <edge-g-input
819
+ :model-value="getByPath(state.workingItem.meta, field.field, false)"
820
+ :name="`meta.${field.field}`"
821
+ :field-type="field?.type"
822
+ :label="field?.label"
823
+ parent-tracker-id="user-settings"
824
+ :disable-tracking="true"
825
+ :disabled="field?.disabled || false"
826
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
827
+ />
828
+ <p v-if="mergeDisabledNote(field?.hint, field)" class="text-xs text-muted-foreground">
829
+ {{ mergeDisabledNote(field?.hint, field) }}
830
+ </p>
831
+ </div>
832
+ <edge-g-input
833
+ v-else-if="field?.type === 'textarea'"
834
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
835
+ :name="`meta.${field.field}`"
836
+ :field-type="field?.type"
837
+ :label="field?.label"
838
+ parent-tracker-id="user-settings"
839
+ :hint="mergeDisabledNote(field?.hint, field)"
840
+ :persistent-hint="Boolean(mergeDisabledNote(field?.hint, field))"
841
+ :disable-tracking="true"
842
+ :bindings="{ class: 'h-60' }"
843
+ :mask-options="field?.maskOptions"
844
+ :disabled="field?.disabled || false"
845
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
846
+ />
847
+ <edge-shad-tags
848
+ v-else-if="field?.type === 'tags' || field?.type === 'commaTags'"
849
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
850
+ :name="`meta.${field.field}`"
851
+ :field-type="field?.type"
852
+ :label="field?.label"
853
+ parent-tracker-id="user-settings"
854
+ :description="mergeDisabledNote(field?.description || field?.hint, field)"
855
+ :disable-tracking="true"
856
+ :disabled="field?.disabled || false"
857
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
858
+ />
859
+ <edge-g-input
860
+ v-else
861
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
862
+ :name="`meta.${field.field}`"
863
+ :field-type="field?.type"
864
+ :label="field?.label"
865
+ parent-tracker-id="user-settings"
866
+ :hint="mergeDisabledNote(field?.hint, field)"
867
+ :persistent-hint="Boolean(mergeDisabledNote(field?.hint, field))"
868
+ :disable-tracking="true"
869
+ :mask-options="field?.maskOptions"
870
+ :disabled="field?.disabled || false"
871
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
872
+ />
873
+ </div>
874
+ </div>
875
+
876
+ <edge-g-input
877
+ v-if="state.saveButton === 'Invite User'"
878
+ v-model="state.workingItem.isTemplate"
879
+ name="isTemplate"
880
+ :disable-tracking="true"
881
+ field-type="boolean"
882
+ label="Template User"
883
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
884
+ />
885
+ </slot>
637
886
  </div>
887
+ </edge-shad-form>
888
+ </div>
889
+ <div v-else :key="detailViewKey" class="p-4 text-center flex text-slate-500 h-[calc(100vh-4rem)] justify-center items-center overflow-y-auto">
890
+ <div class="text-4xl">
891
+ <ArrowLeft class="inline-block w-12 h-12 mr-2" /> Select a member to view details.
638
892
  </div>
639
-
640
- <edge-g-input
641
- v-if="state.saveButton === 'Invite User'"
642
- v-model="state.workingItem.isTemplate"
643
- name="isTemplate"
644
- :disable-tracking="true"
645
- field-type="boolean"
646
- label="Template User"
647
- :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
648
- />
649
- </slot>
650
- <DialogFooter class="pt-6 flex justify-between">
651
- <edge-shad-button variant="destructive" @click="closeDialog">
652
- Cancel
653
- </edge-shad-button>
654
- <edge-shad-button
655
- :disabled="state.loading"
656
- class="text-white w-100 bg-slate-800 hover:bg-slate-400"
657
- type="submit"
658
- >
659
- <Loader2 v-if="state.loading" class="w-4 h-4 mr-2 animate-spin" />
660
- {{ state.saveButton }}
661
- </edge-shad-button>
662
- </DialogFooter>
663
- </edge-shad-form>
664
- </DialogContent>
665
- </edge-shad-dialog>
666
- </CardContent>
667
- </Card>
893
+ </div>
894
+ </Transition>
895
+ </div>
896
+ </ResizablePanel>
897
+ </ResizablePanelGroup>
898
+
899
+ <edge-shad-dialog
900
+ v-model="state.deleteDialog"
901
+ >
902
+ <DialogContent>
903
+ <DialogHeader>
904
+ <DialogTitle>
905
+ <span v-if="state.workingItem.userId === edgeFirebase.user.uid">
906
+ Remove Yourself?
907
+ </span>
908
+ <span v-else>
909
+ Remove "{{ state.workingItem.meta.name }}"
910
+ </span>
911
+ </DialogTitle>
912
+ <DialogDescription />
913
+ </DialogHeader>
914
+
915
+ <h3 v-if="selfRemoveBlocked">
916
+ You cannot remove yourself from this organization because you are the only admin. You can delete the organization or add another admin.
917
+ </h3>
918
+ <h3 v-else-if="state.workingItem.userId === edgeFirebase.user.uid">
919
+ <span v-if="showRemoveOrgSelect">Select the organizations you want to leave.</span>
920
+ <span v-else>Are you sure you want to remove yourself from the organization "{{ currentOrganization.name }}"? You will no longer have access to any of the organization's data.</span>
921
+ </h3>
922
+ <h3 v-else>
923
+ <span v-if="showRemoveOrgSelect">Select the organizations you want to remove "{{ state.workingItem.meta.name }}" from.</span>
924
+ <span v-else>Are you sure you want to remove "{{ state.workingItem.meta.name }}" from the organization "{{ currentOrganization.name }}"?</span>
925
+ </h3>
926
+ <div v-if="showRemoveOrgSelect" class="mt-4 w-full flex flex-wrap gap-2">
927
+ <div v-for="org in removeOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
928
+ <edge-shad-checkbox
929
+ :name="`remove-org-${org.docId}`"
930
+ :model-value="state.removeOrgIds.includes(org.docId)"
931
+ @update:model-value="val => updateRemoveOrgSelection(org.docId, val)"
932
+ >
933
+ {{ org.name }}
934
+ </edge-shad-checkbox>
935
+ </div>
936
+ </div>
937
+ <DialogFooter class="pt-6 flex justify-between">
938
+ <edge-shad-button class="text-white bg-slate-800 hover:bg-slate-400" @click="state.deleteDialog = false">
939
+ Cancel
940
+ </edge-shad-button>
941
+ <edge-shad-button
942
+ :disabled="selfRemoveBlocked"
943
+ class="w-full"
944
+ variant="destructive"
945
+ @click="deleteAction()"
946
+ >
947
+ <span v-if="state.workingItem.userId === edgeFirebase.user.uid">
948
+ Leave
949
+ </span>
950
+ <span v-else>
951
+ Remove
952
+ </span>
953
+ </edge-shad-button>
954
+ </DialogFooter>
955
+ </DialogContent>
956
+ </edge-shad-dialog>
957
+ </div>
668
958
  </template>
669
959
 
670
960
  <style lang="scss" scoped>
961
+ :deep(.member-richtext .tiptap) {
962
+ min-height: 220px;
963
+ padding: 0.75rem 1rem;
964
+ }
671
965
 
966
+ :deep(.member-richtext .tiptap p) {
967
+ margin-top: 0;
968
+ margin-bottom: 1rem;
969
+ }
970
+
971
+ .fade-enter-active,
972
+ .fade-leave-active {
973
+ transition: opacity 0.3s ease;
974
+ }
975
+
976
+ .fade-enter-from,
977
+ .fade-leave-to {
978
+ opacity: 0;
979
+ }
672
980
  </style>