@edgedev/create-edge-app 1.1.28 → 1.1.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.
@@ -120,12 +136,22 @@ const showInviteOrgSelect = computed(() => inviteOrgOptions.value.length > 1)
120
136
  const showEditOrgSelect = computed(() => editOrgOptions.value.length > 1)
121
137
  const showRemoveOrgSelect = computed(() => removeOrgOptions.value.length > 1)
122
138
 
139
+ const adminCount = computed(() => {
140
+ return users.value.filter((item) => {
141
+ return item.roles.find((role) => {
142
+ return role.collectionPath === edgeGlobal.edgeState.organizationDocPath.replaceAll('/', '-') && role.role === 'admin'
143
+ })
144
+ }).length
145
+ })
146
+
123
147
  const selfRemoveBlocked = computed(() => {
124
148
  return state.workingItem.userId === edgeFirebase.user.uid
125
149
  && adminCount.value === 1
126
150
  && state.removeOrgIds.includes(edgeGlobal.edgeState.currentOrganization)
127
151
  })
128
152
 
153
+ const emailDisabledHint = 'This field is tied to the user\'s username and can only be changed by them.'
154
+
129
155
  const WIDTHS = {
130
156
  1: 'md:col-span-1',
131
157
  2: 'md:col-span-2',
@@ -143,6 +169,42 @@ const WIDTHS = {
143
169
 
144
170
  const numColsToTailwind = cols => WIDTHS[cols] || 'md:col-span-12'
145
171
 
172
+ const disabledNoteText = 'Contact admin to change.'
173
+
174
+ const getDisabledNote = (field) => {
175
+ if (!field?.disabled)
176
+ return ''
177
+ return field?.disabledNote || disabledNoteText
178
+ }
179
+
180
+ const mergeDisabledNote = (text, field) => {
181
+ const note = getDisabledNote(field)
182
+ if (!note)
183
+ return text || ''
184
+ if (text)
185
+ return `${text} ${note}`
186
+ return note
187
+ }
188
+
189
+ const userKey = (user) => {
190
+ return user?.docId || user?.userId || user?.id || user?.uid || ''
191
+ }
192
+
193
+ const PROFILE_IMAGE_SIZE = 96
194
+ const PROFILE_IMAGE_VARIANT = `width=${PROFILE_IMAGE_SIZE},height=${PROFILE_IMAGE_SIZE},fit=cover,quality=85`
195
+
196
+ const profileImageUrl = (url) => {
197
+ if (!url || typeof url !== 'string')
198
+ return ''
199
+ if (url.includes('/cdn-cgi/image/'))
200
+ return url
201
+ if (url.includes('width=') && url.includes('height='))
202
+ return url
203
+ if (url.endsWith('/thumbnail'))
204
+ return url.replace(/\/thumbnail$/, `/${PROFILE_IMAGE_VARIANT}`)
205
+ return url
206
+ }
207
+
146
208
  // Helpers to read/write nested keys like "profile.firstName" on plain objects
147
209
  function getByPath(obj, path, fallback = undefined) {
148
210
  if (!obj || !path)
@@ -176,36 +238,33 @@ function setByPath(obj, path, value) {
176
238
  }
177
239
 
178
240
  const sortedFilteredUsers = computed(() => {
179
- const filter = state.filter.toLowerCase()
241
+ const filter = String(state.filter || '').toLowerCase()
180
242
 
181
243
  const getLastName = (fullName) => {
182
244
  if (!fullName)
183
245
  return ''
184
- const parts = fullName.trim().split(/\s+/)
246
+ const parts = String(fullName).trim().split(/\s+/)
185
247
  return parts[parts.length - 1] || ''
186
248
  }
187
249
 
188
250
  return users.value
189
- .filter(user => user.meta.name.toLowerCase().includes(filter))
251
+ .filter((user) => {
252
+ const name = String(user?.meta?.name || '')
253
+ return name.toLowerCase().includes(filter)
254
+ })
190
255
  .sort((a, b) => {
191
- const lastA = getLastName(a.meta.name).toLowerCase()
192
- const lastB = getLastName(b.meta.name).toLowerCase()
256
+ const lastA = getLastName(a?.meta?.name).toLowerCase()
257
+ const lastB = getLastName(b?.meta?.name).toLowerCase()
193
258
  return lastA.localeCompare(lastB)
194
259
  })
195
260
  })
196
261
 
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
203
- })
204
-
205
262
  const addItem = () => {
206
263
  state.saveButton = 'Invite User'
207
264
  const newItem = edgeGlobal.dupObject(state.newItem)
208
265
  newItem.meta.email = ''
266
+ newItem.meta.name = ''
267
+ newItem.meta.profilephoto = ''
209
268
  state.workingItem = newItem
210
269
  state.workingItem.id = edgeGlobal.generateShortId()
211
270
  state.currentTitle = 'Invite User'
@@ -367,10 +426,48 @@ const onSubmit = async () => {
367
426
  state.dialog = false
368
427
  }
369
428
 
429
+ const roleSchema = z
430
+ .string({ required_error: 'Role is required' })
431
+ .min(1, { message: 'Role is required' })
432
+
433
+ const baseMetaSchema = z.object({
434
+ name: z
435
+ .string({ required_error: 'Name is required' })
436
+ .min(1, { message: 'Name is required' }),
437
+ email: z
438
+ .string({ required_error: 'Email is required' })
439
+ .email({ message: 'Invalid email address' })
440
+ .min(6, { message: 'Email must be at least 6 characters long' })
441
+ .max(50, { message: 'Email must be less than 50 characters long' }),
442
+ })
443
+
444
+ const buildMetaSchema = () => {
445
+ const extra = props.metaFieldsSchema
446
+ if (!extra)
447
+ return baseMetaSchema
448
+ if (extra?.shape && typeof extra.shape === 'object')
449
+ return baseMetaSchema.merge(extra)
450
+ if (typeof extra === 'object')
451
+ return baseMetaSchema.extend(extra)
452
+ return baseMetaSchema
453
+ }
454
+
455
+ const computedNewUserSchema = computed(() => {
456
+ if (!props.metaFieldsSchema)
457
+ return props.newUserSchema
458
+ return toTypedSchema(z.object({ meta: buildMetaSchema(), role: roleSchema }))
459
+ })
460
+
461
+ const computedUpdateUserSchema = computed(() => {
462
+ if (!props.metaFieldsSchema)
463
+ return props.updateUserSchema
464
+ return toTypedSchema(z.object({ meta: buildMetaSchema(), role: roleSchema }))
465
+ })
466
+
370
467
  const computedUserSchema = computed(() =>
371
468
  state.saveButton === 'Invite User'
372
- ? props.newUserSchema
373
- : props.updateUserSchema,
469
+ ? computedNewUserSchema.value
470
+ : computedUpdateUserSchema.value,
374
471
  )
375
472
 
376
473
  const currentOrganization = computed(() => {
@@ -406,267 +503,405 @@ onBeforeMount(async () => {
406
503
  </script>
407
504
 
408
505
  <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 }}
506
+ <div v-if="state.loaded" class="w-full flex-1 min-h-0 h-[calc(100vh-58px)] overflow-hidden">
507
+ <ResizablePanelGroup direction="horizontal" class="w-full h-full flex-1">
508
+ <ResizablePanel class="bg-sidebar text-sidebar-foreground min-w-[400px]" :default-size="22" :min-size="30">
509
+ <div class="flex flex-col h-full">
510
+ <div class="px-3 py-3 border-b border-sidebar-border bg-sidebar/90">
511
+ <div class="flex items-center justify-between gap-2">
512
+ <div class="flex items-center gap-2 text-sm font-semibold">
513
+ <component :is="edgeGlobal.iconFromMenu(route)" class="h-4 w-4" />
514
+ <span>Members</span>
447
515
  </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>
465
- </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>
516
+ <edge-shad-button size="sm" class="h-7 text-xs bg-primary" @click="addItem()">
517
+ Invite
518
+ </edge-shad-button>
514
519
  </div>
520
+ <Input
521
+ v-model="state.filter"
522
+ class="mt-3 h-8 w-full"
523
+ placeholder="Filter members..."
524
+ />
515
525
  </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)"
526
+ <div class="flex-1 overflow-y-auto">
527
+ <SidebarMenu class="px-2 py-2 space-y-0">
528
+ <SidebarMenuItem
529
+ v-for="user in sortedFilteredUsers"
530
+ :key="userKey(user)"
531
+ >
532
+ <SidebarMenuButton
533
+ class="w-full !h-auto items-start px-3 py-2"
534
+ :class="state.dialog && userKey(state.workingItem) && userKey(state.workingItem) === userKey(user) ? 'bg-sidebar-accent text-sidebar-accent-foreground' : ''"
535
+ @click="editItem(user)"
536
+ >
537
+ <div class="flex w-full items-start gap-3" :class="!user.userId ? 'opacity-70' : ''">
538
+ <Avatar class="h-12 w-12 rounded-md border bg-muted/40 flex items-center justify-center overflow-hidden">
539
+ <img
540
+ v-if="user?.meta?.profilephoto"
541
+ :src="profileImageUrl(user.meta.profilephoto)"
542
+ alt=""
543
+ class="h-full w-full object-cover"
544
+ >
545
+ <User v-else width="24" height="24" />
546
+ </Avatar>
547
+ <div class="min-w-0 flex-1">
548
+ <div class="flex items-center gap-2">
549
+ <span class="text-sm font-medium leading-snug whitespace-normal uppercase">
550
+ {{ user?.meta?.name || user?.meta?.email || 'Unnamed Member' }}
551
+ </span>
552
+ <!-- <span v-if="!user.userId" class="text-[10px] uppercase tracking-wide text-muted-foreground">
553
+ -
554
+ </span> -->
555
+ <edge-chip v-if="user.userId === edgeFirebase.user.uid">
556
+ You
557
+ </edge-chip>
558
+ </div>
559
+ <div class="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground leading-snug">
560
+ <span class="rounded-full bg-secondary px-2 py-0.5 text-[10px] text-secondary-foreground">
561
+ {{ edgeGlobal.getRoleName(user.roles, edgeGlobal.edgeState.currentOrganization) }}
562
+ </span>
563
+ <span v-if="!user.userId && user.docId" class="inline-flex items-center gap-1 whitespace-normal">
564
+ {{ user.docId }}
565
+ <edge-clipboard-button class="relative top-[1px]" :text="user.docId" />
566
+ </span>
567
+ </div>
568
+ </div>
569
+ <edge-shad-button
570
+ size="icon"
571
+ variant="ghost"
572
+ class="h-7 w-7 text-destructive/80 hover:text-destructive"
573
+ @click.stop="deleteConfirm(user)"
568
574
  >
569
- {{ org.name }}
570
- </edge-shad-checkbox>
575
+ <Trash2 class="h-4 w-4" />
576
+ </edge-shad-button>
571
577
  </div>
572
- </div>
578
+ </SidebarMenuButton>
579
+ <Separator class="my-1" />
580
+ </SidebarMenuItem>
581
+ <div v-if="sortedFilteredUsers.length === 0" class="px-4 py-6 text-xs text-muted-foreground">
582
+ No members found.
573
583
  </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
584
+ </SidebarMenu>
585
+ </div>
586
+ </div>
587
+ </ResizablePanel>
588
+ <ResizablePanel class="bg-background">
589
+ <div class="h-full flex flex-col">
590
+ <div v-if="state.dialog" class="h-full flex flex-col">
591
+ <edge-shad-form
592
+ :key="userKey(state.workingItem) || state.workingItem?.id || 'member-form'"
593
+ :initial-values="state.workingItem"
594
+ :schema="computedUserSchema"
595
+ class="flex flex-col h-full"
596
+ @submit="onSubmit"
597
+ >
598
+ <div class="flex items-center justify-between border-b bg-secondary px-4 py-3">
599
+ <div class="text-sm font-semibold">
600
+ {{ state.currentTitle || 'Member' }}
577
601
  </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)"
584
- >
585
- {{ org.name }}
586
- </edge-shad-checkbox>
587
- </div>
602
+ <div class="flex items-center gap-2">
603
+ <edge-shad-button variant="text" class="text-xs text-red-700" @click="closeDialog">
604
+ Close
605
+ </edge-shad-button>
606
+ <edge-shad-button
607
+ type="submit"
608
+ class="text-xs bg-primary"
609
+ :disabled="state.loading"
610
+ >
611
+ <Loader2 v-if="state.loading" class="w-4 h-4 mr-2 animate-spin" />
612
+ {{ state.saveButton }}
613
+ </edge-shad-button>
588
614
  </div>
589
615
  </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
- />
616
+ <div class="flex-1 overflow-y-auto p-6 space-y-4">
617
+ <slot name="edit-fields" :working-item="state.workingItem">
618
+ <div class="rounded-xl border bg-card p-4 space-y-4 shadow-sm">
619
+ <div class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
620
+ Member Details
621
+ </div>
622
+ <div class="flex flex-col gap-4 md:flex-row md:items-stretch">
623
+ <div class="w-full md:w-[260px] self-stretch">
624
+ <edge-image-picker
625
+ v-model="state.workingItem.meta.profilephoto"
626
+ label="Profile Photo"
627
+ dialog-title="Select Profile Photo"
628
+ site="clearwater-hub-images"
629
+ :default-tags="props.defaultImageTags"
630
+ height-class="h-full min-h-[180px]"
631
+ :include-cms-all="false"
632
+ />
633
+ </div>
634
+ <div class="flex-1 space-y-4">
635
+ <edge-g-input
636
+ v-model="state.workingItem.role"
637
+ name="role"
638
+ :disable-tracking="true"
639
+ :items="roleNamesOnly"
640
+ field-type="select"
641
+ label="Role"
642
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
643
+ :disabled="state.workingItem.userId === edgeFirebase.user.uid"
644
+ />
645
+ <edge-g-input
646
+ v-model="state.workingItem.meta.name"
647
+ name="meta.name"
648
+ :disable-tracking="true"
649
+ field-type="text"
650
+ label="Name"
651
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
652
+ />
653
+ <edge-g-input
654
+ v-model="state.workingItem.meta.email"
655
+ name="meta.email"
656
+ :disable-tracking="true"
657
+ field-type="text"
658
+ label="Email"
659
+ :disabled="state.saveButton !== 'Invite User'"
660
+ :hint="state.saveButton !== 'Invite User' ? emailDisabledHint : ''"
661
+ :persistent-hint="state.saveButton !== 'Invite User'"
662
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
663
+ />
664
+ <edge-g-input
665
+ v-model="state.workingItem.meta.phone"
666
+ name="meta.phone"
667
+ :disable-tracking="true"
668
+ field-type="text"
669
+ label="Phone"
670
+ :mask-options="{ mask: '(###) ###-####' }"
671
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
672
+ />
673
+ </div>
674
+ </div>
675
+ </div>
676
+ <div v-if="state.saveButton !== 'Invite User' && showEditOrgSelect" class="mt-4 w-full">
677
+ <div class="text-sm font-medium text-foreground">
678
+ Organizations
679
+ </div>
680
+ <div class="mt-2 w-full flex flex-wrap gap-2">
681
+ <div v-for="org in editOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
682
+ <edge-shad-checkbox
683
+ :name="`edit-add-org-${org.docId}`"
684
+ :model-value="state.editOrgIds.includes(org.docId)"
685
+ @update:model-value="val => updateEditOrgSelection(org.docId, val)"
686
+ >
687
+ {{ org.name }}
688
+ </edge-shad-checkbox>
689
+ </div>
690
+ </div>
691
+ </div>
692
+ <div v-if="state.saveButton === 'Invite User' && showInviteOrgSelect" class="mt-4 w-full">
693
+ <div class="text-sm font-medium text-foreground">
694
+ Add to organizations
695
+ </div>
696
+ <div class="mt-2 w-full flex flex-wrap gap-2">
697
+ <div v-for="org in inviteOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
698
+ <edge-shad-checkbox
699
+ :name="`invite-org-${org.docId}`"
700
+ :model-value="state.inviteOrgIds.includes(org.docId)"
701
+ @update:model-value="val => updateInviteOrgSelection(org.docId, val)"
702
+ >
703
+ {{ org.name }}
704
+ </edge-shad-checkbox>
705
+ </div>
706
+ </div>
707
+ </div>
708
+ <Separator class="my-6" />
709
+ <div class="grid grid-cols-12 gap-2">
710
+ <div v-for="field in props.metaFields" :key="field.field" class="mb-3 col-span-12" :class="numColsToTailwind(field.cols)">
711
+ <!-- Use explicit model binding so dotted paths (e.g., "address.street") work -->
712
+ <edge-image-picker
713
+ v-if="field?.type === 'imagePicker'"
714
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
715
+ :label="field?.label || 'Photo'"
716
+ :dialog-title="field?.dialogTitle || 'Select Image'"
717
+ :site="field?.site || 'clearwater-hub-images'"
718
+ :default-tags="field?.tags || []"
719
+ :height-class="field?.heightClass || 'h-[160px]'"
720
+ :disabled="field?.disabled || false"
721
+ :include-cms-all="false"
722
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
723
+ />
724
+ <p v-if="field?.type === 'imagePicker' && field?.disabled" class="mt-1 text-xs text-muted-foreground">
725
+ {{ getDisabledNote(field) }}
726
+ </p>
727
+ <div v-else-if="field?.type === 'richText'" class="member-richtext">
728
+ <edge-shad-html
729
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
730
+ :name="`meta.${field.field}`"
731
+ :label="field?.label"
732
+ :disabled="field?.disabled || false"
733
+ :description="mergeDisabledNote(field?.description, field)"
734
+ :enabled-toggles="field?.enabledToggles || ['bold', 'italic', 'strike', 'bulletlist', 'orderedlist', 'underline']"
735
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
736
+ />
737
+ </div>
738
+ <edge-shad-select-tags
739
+ v-else-if="field?.type === 'selectTags'"
740
+ :model-value="getByPath(state.workingItem.meta, field.field, [])"
741
+ :name="`meta.${field.field}`"
742
+ :label="field?.label"
743
+ :description="mergeDisabledNote(field?.description, field)"
744
+ :items="field?.items || []"
745
+ :item-title="field?.itemTitle || 'title'"
746
+ :item-value="field?.itemValue || 'name'"
747
+ :allow-additions="field?.allowAdditions || false"
748
+ :placeholder="field?.placeholder"
749
+ :disabled="field?.disabled || false"
750
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
751
+ />
752
+ <div v-else-if="field?.type === 'boolean'" class="space-y-1 -mt-3">
753
+ <div class="text-sm font-medium leading-none opacity-0 select-none h-4">
754
+ {{ field?.label || '' }}
755
+ </div>
756
+ <edge-g-input
757
+ :model-value="getByPath(state.workingItem.meta, field.field, false)"
758
+ :name="`meta.${field.field}`"
759
+ :field-type="field?.type"
760
+ :label="field?.label"
761
+ parent-tracker-id="user-settings"
762
+ :disable-tracking="true"
763
+ :disabled="field?.disabled || false"
764
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
765
+ />
766
+ <p v-if="mergeDisabledNote(field?.hint, field)" class="text-xs text-muted-foreground">
767
+ {{ mergeDisabledNote(field?.hint, field) }}
768
+ </p>
769
+ </div>
770
+ <edge-g-input
771
+ v-else-if="field?.type === 'textarea'"
772
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
773
+ :name="`meta.${field.field}`"
774
+ :field-type="field?.type"
775
+ :label="field?.label"
776
+ parent-tracker-id="user-settings"
777
+ :hint="mergeDisabledNote(field?.hint, field)"
778
+ :persistent-hint="Boolean(mergeDisabledNote(field?.hint, field))"
779
+ :disable-tracking="true"
780
+ :bindings="{ class: 'h-60' }"
781
+ :mask-options="field?.maskOptions"
782
+ :disabled="field?.disabled || false"
783
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
784
+ />
785
+ <edge-shad-tags
786
+ v-else-if="field?.type === 'tags' || field?.type === 'commaTags'"
787
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
788
+ :name="`meta.${field.field}`"
789
+ :field-type="field?.type"
790
+ :label="field?.label"
791
+ parent-tracker-id="user-settings"
792
+ :description="mergeDisabledNote(field?.description || field?.hint, field)"
793
+ :disable-tracking="true"
794
+ :disabled="field?.disabled || false"
795
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
796
+ />
797
+ <edge-g-input
798
+ v-else
799
+ :model-value="getByPath(state.workingItem.meta, field.field, '')"
800
+ :name="`meta.${field.field}`"
801
+ :field-type="field?.type"
802
+ :label="field?.label"
803
+ parent-tracker-id="user-settings"
804
+ :hint="mergeDisabledNote(field?.hint, field)"
805
+ :persistent-hint="Boolean(mergeDisabledNote(field?.hint, field))"
806
+ :disable-tracking="true"
807
+ :mask-options="field?.maskOptions"
808
+ :disabled="field?.disabled || false"
809
+ @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
810
+ />
811
+ </div>
812
+ </div>
813
+
626
814
  <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"
815
+ v-if="state.saveButton === 'Invite User'"
816
+ v-model="state.workingItem.isTemplate"
817
+ name="isTemplate"
634
818
  :disable-tracking="true"
635
- @update:model-value="val => setByPath(state.workingItem.meta, field.field, val)"
819
+ field-type="boolean"
820
+ label="Template User"
821
+ :parent-tracker-id="`inviteUser-${state.workingItem.id}`"
636
822
  />
637
- </div>
823
+ </slot>
638
824
  </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>
825
+ </edge-shad-form>
826
+ </div>
827
+ <div v-else class="p-4 text-center flex text-slate-500 h-[calc(100vh-4rem)] justify-center items-center overflow-y-auto">
828
+ <div class="text-4xl">
829
+ <ArrowLeft class="inline-block w-12 h-12 mr-2" /> Select a member to view details.
830
+ </div>
831
+ </div>
832
+ </div>
833
+ </ResizablePanel>
834
+ </ResizablePanelGroup>
835
+
836
+ <edge-shad-dialog
837
+ v-model="state.deleteDialog"
838
+ >
839
+ <DialogContent>
840
+ <DialogHeader>
841
+ <DialogTitle>
842
+ <span v-if="state.workingItem.userId === edgeFirebase.user.uid">
843
+ Remove Yourself?
844
+ </span>
845
+ <span v-else>
846
+ Remove "{{ state.workingItem.meta.name }}"
847
+ </span>
848
+ </DialogTitle>
849
+ <DialogDescription />
850
+ </DialogHeader>
851
+
852
+ <h3 v-if="selfRemoveBlocked">
853
+ You cannot remove yourself from this organization because you are the only admin. You can delete the organization or add another admin.
854
+ </h3>
855
+ <h3 v-else-if="state.workingItem.userId === edgeFirebase.user.uid">
856
+ <span v-if="showRemoveOrgSelect">Select the organizations you want to leave.</span>
857
+ <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>
858
+ </h3>
859
+ <h3 v-else>
860
+ <span v-if="showRemoveOrgSelect">Select the organizations you want to remove "{{ state.workingItem.meta.name }}" from.</span>
861
+ <span v-else>Are you sure you want to remove "{{ state.workingItem.meta.name }}" from the organization "{{ currentOrganization.name }}"?</span>
862
+ </h3>
863
+ <div v-if="showRemoveOrgSelect" class="mt-4 w-full flex flex-wrap gap-2">
864
+ <div v-for="org in removeOrgOptions" :key="org.docId" class="flex-1 min-w-[220px]">
865
+ <edge-shad-checkbox
866
+ :name="`remove-org-${org.docId}`"
867
+ :model-value="state.removeOrgIds.includes(org.docId)"
868
+ @update:model-value="val => updateRemoveOrgSelection(org.docId, val)"
869
+ >
870
+ {{ org.name }}
871
+ </edge-shad-checkbox>
872
+ </div>
873
+ </div>
874
+ <DialogFooter class="pt-6 flex justify-between">
875
+ <edge-shad-button class="text-white bg-slate-800 hover:bg-slate-400" @click="state.deleteDialog = false">
876
+ Cancel
877
+ </edge-shad-button>
878
+ <edge-shad-button
879
+ :disabled="selfRemoveBlocked"
880
+ class="w-full"
881
+ variant="destructive"
882
+ @click="deleteAction()"
883
+ >
884
+ <span v-if="state.workingItem.userId === edgeFirebase.user.uid">
885
+ Leave
886
+ </span>
887
+ <span v-else>
888
+ Remove
889
+ </span>
890
+ </edge-shad-button>
891
+ </DialogFooter>
892
+ </DialogContent>
893
+ </edge-shad-dialog>
894
+ </div>
668
895
  </template>
669
896
 
670
897
  <style lang="scss" scoped>
898
+ :deep(.member-richtext .tiptap) {
899
+ min-height: 220px;
900
+ padding: 0.75rem 1rem;
901
+ }
671
902
 
903
+ :deep(.member-richtext .tiptap p) {
904
+ margin-top: 0;
905
+ margin-bottom: 1rem;
906
+ }
672
907
  </style>