@mohamedatia/fly-design-system 1.9.2 → 2.3.0

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,16 +1,63 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, signal, computed, Injectable, inject, ErrorHandler, PLATFORM_ID, Pipe, DOCUMENT, ElementRef, input, output, HostListener, ViewChild, ChangeDetectionStrategy, Component, EventEmitter, DestroyRef, Output, Input, viewChild, effect, ViewEncapsulation, model } from '@angular/core';
2
+ import { InjectionToken, signal, computed, Injectable, inject, ErrorHandler, PLATFORM_ID, Pipe, DOCUMENT, ElementRef, input, output, HostListener, ViewChild, ChangeDetectionStrategy, Component, EventEmitter, DestroyRef, Output, Input, forwardRef, viewChild, effect, ViewEncapsulation, model } from '@angular/core';
3
+ import * as i1$1 from '@angular/common';
3
4
  import { isPlatformBrowser, CommonModule } from '@angular/common';
4
5
  import { Router } from '@angular/router';
5
- import { of, ReplaySubject } from 'rxjs';
6
+ import { of, ReplaySubject, Subject } from 'rxjs';
6
7
  import * as i1 from '@angular/forms';
7
- import { FormsModule } from '@angular/forms';
8
+ import { FormsModule, NG_VALUE_ACCESSOR, NG_VALIDATORS } from '@angular/forms';
8
9
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
9
- import { switchMap } from 'rxjs/operators';
10
+ import { switchMap, debounceTime, distinctUntilChanged, filter } from 'rxjs/operators';
10
11
  import { HttpClient, HttpEventType } from '@angular/common/http';
11
12
  import Cropper from 'cropperjs';
12
13
 
13
14
  const WINDOW_DATA = new InjectionToken('WINDOW_DATA');
15
+ /**
16
+ * Per-window injection token carrying the active `LaunchContext` as a signal.
17
+ * Resolves to `null` outside the desktop shell (e.g. mock-host dev mode).
18
+ *
19
+ * Remotes typically consume it as:
20
+ * ```ts
21
+ * private readonly launchCtx = inject(LAUNCH_CONTEXT, { optional: true });
22
+ * constructor() {
23
+ * effect(() => {
24
+ * const ctx = this.launchCtx?.();
25
+ * if (ctx?.route) this.router.navigateByUrl(ctx.route);
26
+ * });
27
+ * }
28
+ * ```
29
+ */
30
+ const LAUNCH_CONTEXT = new InjectionToken('LAUNCH_CONTEXT');
31
+
32
+ /** Discriminator string values — re-exported so hosts can build kind menus dynamically. */
33
+ const AUDIENCE_TERM_KINDS = [
34
+ 'users',
35
+ 'roles',
36
+ 'ou',
37
+ 'app-everyone',
38
+ 'chart',
39
+ 'preset',
40
+ ];
41
+ /**
42
+ * Runtime values for {@link AudiencePresetKind}. Hosts iterate this when surfacing a
43
+ * preset picker; the type union mirrors this list 1:1.
44
+ */
45
+ const AUDIENCE_PRESETS = [
46
+ 'self',
47
+ 'ou-managers',
48
+ 'direct-reports',
49
+ ];
50
+ /**
51
+ * Hard caps mirrored from `AudienceLimits.cs`. Hosts use these for client-side validation
52
+ * before submit — backend re-validates regardless. Keep these in sync with the C# constants.
53
+ */
54
+ const AUDIENCE_LIMITS = {
55
+ maxTermsPerFilter: 64,
56
+ maxUserIdsPerTerm: 500,
57
+ maxRoleKeysPerTerm: 64,
58
+ maxOuIdsPerTerm: 32,
59
+ maxResolvedUsers: 10_000,
60
+ };
14
61
 
15
62
  /**
16
63
  * Shared AuthService for Business Apps.
@@ -768,10 +815,30 @@ class SharePanelComponent {
768
815
  revokePermission;
769
816
  /** When true, show “apply to children” (e.g. folders). */
770
817
  showApplyToChildren = false;
818
+ /**
819
+ * When true, surfaces an "Add as deny" toggle in the add-permission row and renders
820
+ * existing deny grants with red styling. Hosts whose backend does not support deny
821
+ * grants (legacy callers) leave this false and the panel hides all deny affordances.
822
+ */
823
+ supportsDeny = false;
771
824
  /** Override level dropdown options (e.g. notes: View / Edit only). */
772
825
  permissionLevels;
773
826
  /** i18n key for wildcard app grant label */
774
827
  everyoneLabelKey = 'files.share.everyone';
828
+ /**
829
+ * Optional apps-chart role-OU index for the tenant. When supplied, the panel:
830
+ * <list type="bullet">
831
+ * <item>Renders existing OU grants whose ouId matches a row as <c>RolePrincipal</c>
832
+ * chips ("FFO in Circles") instead of opaque OU labels.</item>
833
+ * <item>Surfaces an "Add role" picker that translates the picked role to its
834
+ * role-OU UUID and dispatches via {@link grantToOu}. Backend stores it as a
835
+ * plain OU grant; <see cref="PermissionResolver"/> resolves at access time via
836
+ * the user's effective OU set.</item>
837
+ * </list>
838
+ * Hosts that don't have access to the apps-chart (or don't want the role flow) omit
839
+ * this input — OU grants then render as generic OU principals.
840
+ */
841
+ loadRoleOus;
775
842
  close = new EventEmitter();
776
843
  destroyRef = inject(DestroyRef);
777
844
  i18n = inject(I18nService);
@@ -780,6 +847,20 @@ class SharePanelComponent {
780
847
  searchQuery = '';
781
848
  newLevel = '';
782
849
  applyToChildren = false;
850
+ /** Apps-chart role-OU index, populated lazily when {@link loadRoleOus} is provided. */
851
+ roleOus = signal([], ...(ngDevMode ? [{ debugName: "roleOus" }] : /* istanbul ignore next */ []));
852
+ /** Map<ouId, role row> — derived from roleOus for O(1) lookup during rendering. */
853
+ roleOusByOuId = signal({}, ...(ngDevMode ? [{ debugName: "roleOusByOuId" }] : /* istanbul ignore next */ []));
854
+ /** Two-way bound to the role picker's filter input. */
855
+ roleSearchQuery = '';
856
+ /** True while the role picker section is expanded. */
857
+ showRolePicker = signal(false, ...(ngDevMode ? [{ debugName: "showRolePicker" }] : /* istanbul ignore next */ []));
858
+ /**
859
+ * Two-way bound to the "Add as deny" toggle. When true, the next user/OU pick is
860
+ * persisted as a deny grant. Reset to false after each successful grant so the next
861
+ * pick defaults back to allow.
862
+ */
863
+ addAsDeny = false;
783
864
  searchResults = signal([], ...(ngDevMode ? [{ debugName: "searchResults" }] : /* istanbul ignore next */ []));
784
865
  ouTree = signal([], ...(ngDevMode ? [{ debugName: "ouTree" }] : /* istanbul ignore next */ []));
785
866
  showOuPicker = signal(false, ...(ngDevMode ? [{ debugName: "showOuPicker" }] : /* istanbul ignore next */ []));
@@ -794,6 +875,23 @@ class SharePanelComponent {
794
875
  const levels = this.levelOptions;
795
876
  this.newLevel = levels[0]?.value ?? 'View';
796
877
  this.refreshPermissions();
878
+ // Load the role-OU lookup table once. Failures degrade gracefully — OU grants then
879
+ // render as generic OU principals (the v1 behaviour) instead of Role chips.
880
+ this.loadRoleOus?.()
881
+ .pipe(takeUntilDestroyed(this.destroyRef))
882
+ .subscribe({
883
+ next: (rows) => {
884
+ this.roleOus.set(rows);
885
+ const byId = {};
886
+ for (const r of rows)
887
+ byId[r.ouId] = r;
888
+ this.roleOusByOuId.set(byId);
889
+ },
890
+ error: () => {
891
+ this.roleOus.set([]);
892
+ this.roleOusByOuId.set({});
893
+ },
894
+ });
797
895
  this.loadOuLabelMap()
798
896
  .pipe(takeUntilDestroyed(this.destroyRef))
799
897
  .subscribe({
@@ -866,14 +964,55 @@ class SharePanelComponent {
866
964
  onGrantToUser(user) {
867
965
  this.searchResults.set([]);
868
966
  this.searchQuery = '';
869
- this.grantToUser(user.id, this.newLevel, this.applyToChildren)
967
+ const isDeny = this.supportsDeny && this.addAsDeny;
968
+ this.grantToUser(user.id, this.newLevel, this.applyToChildren, isDeny)
870
969
  .pipe(takeUntilDestroyed(this.destroyRef))
871
- .subscribe({ next: () => this.refreshPermissions() });
970
+ .subscribe({
971
+ next: () => {
972
+ // Reset deny toggle after each successful grant so allow is the default for the
973
+ // next pick. Preserves the level dropdown which is genuinely sticky.
974
+ this.addAsDeny = false;
975
+ this.refreshPermissions();
976
+ },
977
+ });
872
978
  }
873
979
  onGrantToOu(ou) {
874
- this.grantToOu(ou.id, this.newLevel, this.applyToChildren)
980
+ const isDeny = this.supportsDeny && this.addAsDeny;
981
+ this.grantToOu(ou.id, this.newLevel, this.applyToChildren, isDeny)
875
982
  .pipe(takeUntilDestroyed(this.destroyRef))
876
- .subscribe({ next: () => this.refreshPermissions() });
983
+ .subscribe({
984
+ next: () => {
985
+ this.addAsDeny = false;
986
+ this.refreshPermissions();
987
+ },
988
+ });
989
+ }
990
+ /**
991
+ * Add a role grant. Resolves to the role-OU UUID and dispatches via {@link grantToOu}
992
+ * so the backend stores it as a plain OU grant; PermissionResolver matches it at access
993
+ * time via the user's effective OU set (apps-chart computed-from-role membership).
994
+ */
995
+ onGrantToRole(role) {
996
+ const isDeny = this.supportsDeny && this.addAsDeny;
997
+ this.grantToOu(role.ouId, this.newLevel, this.applyToChildren, isDeny)
998
+ .pipe(takeUntilDestroyed(this.destroyRef))
999
+ .subscribe({
1000
+ next: () => {
1001
+ this.addAsDeny = false;
1002
+ this.roleSearchQuery = '';
1003
+ this.refreshPermissions();
1004
+ },
1005
+ });
1006
+ }
1007
+ /** Filtered role list — empty query returns all roles, otherwise case-insensitive substring match. */
1008
+ filteredRoles() {
1009
+ const q = this.roleSearchQuery.trim().toLowerCase();
1010
+ const all = this.roleOus();
1011
+ if (!q)
1012
+ return all;
1013
+ return all.filter((r) => r.displayName.toLowerCase().includes(q)
1014
+ || r.roleKey.toLowerCase().includes(q)
1015
+ || r.appId.toLowerCase().includes(q));
877
1016
  }
878
1017
  onUpdateLevel(perm, level) {
879
1018
  this.updatePermission(perm.id, level)
@@ -886,13 +1025,25 @@ class SharePanelComponent {
886
1025
  .subscribe({ next: () => this.refreshPermissions() });
887
1026
  }
888
1027
  getPermIcon(perm) {
889
- if (perm.grantedToUserId)
890
- return 'pi pi-user';
891
- if (perm.grantedToOuId)
892
- return 'pi pi-sitemap';
893
- if (perm.grantedToAppId)
894
- return 'pi pi-globe';
895
- return 'pi pi-question';
1028
+ // Deny rows take a distinct icon regardless of principal kind so the operator can
1029
+ // scan a long permission list and immediately spot revocations.
1030
+ if (perm.isDeny)
1031
+ return 'pi pi-ban';
1032
+ // Reverse-translate: an OuPrincipal whose ouId matches a known role-OU should render
1033
+ // as a role chip, even though the host stored it as an OU grant.
1034
+ if (perm.principal.kind === 'ou' && this.roleOusByOuId()[perm.principal.ouId]) {
1035
+ return 'pi pi-id-card';
1036
+ }
1037
+ return SharePanelComponent.principalIcon(perm.principal);
1038
+ }
1039
+ /** Maps a principal kind to its display icon. Static so hosts can reuse for menus. */
1040
+ static principalIcon(principal) {
1041
+ switch (principal.kind) {
1042
+ case 'user': return 'pi pi-user';
1043
+ case 'role': return 'pi pi-id-card';
1044
+ case 'ou': return 'pi pi-sitemap';
1045
+ case 'app-everyone': return 'pi pi-globe';
1046
+ }
896
1047
  }
897
1048
  /** Logical start padding for OU hierarchy (roots align with section padding). */
898
1049
  ouIndentStartPx(ou) {
@@ -900,31 +1051,40 @@ class SharePanelComponent {
900
1051
  return 12 + d * 18;
901
1052
  }
902
1053
  getPermLabel(perm) {
903
- if (perm.displayName?.trim())
904
- return perm.displayName.trim();
905
- if (perm.grantedToUserId) {
906
- return `${perm.grantedToUserId.substring(0, 8)}…`;
907
- }
908
- if (perm.grantedToOuId) {
909
- const id = perm.grantedToOuId;
910
- const fromDefault = this.ouLabelById()[id];
911
- if (fromDefault)
912
- return fromDefault;
913
- const ou = this.ouTree().find((o) => o.id === id);
914
- if (ou)
915
- return ou.displayName;
916
- return `${id.substring(0, 8)}…`;
1054
+ const p = perm.principal;
1055
+ if (p.displayName?.trim())
1056
+ return p.displayName.trim();
1057
+ switch (p.kind) {
1058
+ case 'user':
1059
+ return `${p.userId.substring(0, 8)}…`;
1060
+ case 'role':
1061
+ // Hosts SHOULD pre-resolve role display names — this branch is the
1062
+ // worst-case fallback for an unresolved key.
1063
+ return `${p.appId}:${p.roleKey}`;
1064
+ case 'ou': {
1065
+ // Reverse-translate: if this OU id matches a known apps-chart role-OU, render
1066
+ // as the semantic role label instead of the opaque OU name.
1067
+ const role = this.roleOusByOuId()[p.ouId];
1068
+ if (role)
1069
+ return role.displayName;
1070
+ const fromDefault = this.ouLabelById()[p.ouId];
1071
+ if (fromDefault)
1072
+ return fromDefault;
1073
+ const ou = this.ouTree().find((o) => o.id === p.ouId);
1074
+ if (ou)
1075
+ return ou.displayName;
1076
+ return `${p.ouId.substring(0, 8)}…`;
1077
+ }
1078
+ case 'app-everyone':
1079
+ return p.appId === '*' ? this.i18n.t(this.everyoneLabelKey) : p.appId;
917
1080
  }
918
- if (perm.grantedToAppId === '*')
919
- return this.i18n.t(this.everyoneLabelKey);
920
- return perm.grantedToAppId ?? '';
921
1081
  }
922
1082
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: SharePanelComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
923
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: SharePanelComponent, isStandalone: true, selector: "fly-share-panel", inputs: { targetName: "targetName", titleKey: "titleKey", loadPermissions: "loadPermissions", searchUsers: "searchUsers", loadOuTree: "loadOuTree", loadChartOptions: "loadChartOptions", loadOuLabelMap: "loadOuLabelMap", grantToUser: "grantToUser", grantToOu: "grantToOu", updatePermission: "updatePermission", revokePermission: "revokePermission", showApplyToChildren: "showApplyToChildren", permissionLevels: "permissionLevels", everyoneLabelKey: "everyoneLabelKey" }, outputs: { close: "close" }, ngImport: i0, template: "<div class=\"fac-overlay\" (click)=\"close.emit()\">\n <div class=\"fac-panel\" (click)=\"$event.stopPropagation()\">\n <div class=\"fac-header\">\n <h3>{{ titleKey | translate }}</h3>\n <span class=\"fac-target-name\">{{ targetName }}</span>\n <button type=\"button\" class=\"fac-close\" (click)=\"close.emit()\">\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\n </button>\n </div>\n\n <div class=\"fac-add-section\">\n <div class=\"fac-search-row\">\n <input\n type=\"text\"\n class=\"fac-input\"\n [(ngModel)]=\"searchQuery\"\n (input)=\"onSearchInput()\"\n [placeholder]=\"'files.share.search_placeholder' | translate\"\n />\n <select class=\"fac-select\" [(ngModel)]=\"newLevel\">\n @for (opt of levelOptions; track opt.value) {\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\n }\n </select>\n </div>\n\n @if (searchResults().length > 0) {\n <div class=\"fac-search-results\">\n @for (result of searchResults(); track result.id) {\n <div class=\"fac-search-item\" (click)=\"onGrantToUser(result)\">\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\n <span>{{ result.firstName }} {{ result.lastName }}</span>\n <span class=\"fac-email\">{{ result.email }}</span>\n </div>\n }\n </div>\n }\n\n @if (chartOptions().length > 0) {\n <div class=\"fac-chart-row\">\n <label for=\"fac-org-chart-select\" class=\"fac-section-label\">{{\n 'files.share.org_chart' | translate\n }}</label>\n <select\n id=\"fac-org-chart-select\"\n class=\"fac-select\"\n [ngModel]=\"selectedOrgChartId()\"\n (ngModelChange)=\"onOrgChartSelectChange($event)\">\n @for (opt of chartOptions(); track $index) {\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\n }\n </select>\n </div>\n }\n\n @if (showOuPicker()) {\n <div class=\"fac-ou-section\">\n <div class=\"fac-section-label\">{{ 'files.share.share_with_ou' | translate }}</div>\n @for (ou of ouTree(); track ou.id) {\n <div\n class=\"fac-ou-item\"\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\n (click)=\"onGrantToOu(ou)\"\n >\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\n <span>{{ ou.displayName }}</span>\n </div>\n }\n </div>\n }\n\n <div class=\"fac-toggle-row\">\n <button type=\"button\" class=\"fac-link\" (click)=\"showOuPicker.set(!showOuPicker())\">\n {{ showOuPicker() ? ('files.share.hide_ous' | translate) : ('files.share.show_ous' | translate) }}\n </button>\n @if (showApplyToChildren) {\n <label class=\"fac-checkbox-label\">\n <input type=\"checkbox\" [(ngModel)]=\"applyToChildren\" />\n {{ 'files.share.apply_children' | translate }}\n </label>\n }\n </div>\n </div>\n\n <div class=\"fac-perms-section\">\n <div class=\"fac-section-label\">{{ 'files.share.current_permissions' | translate }}</div>\n @if (loading()) {\n <div class=\"fac-loading\"><i class=\"pi pi-spin pi-spinner\" aria-hidden=\"true\"></i></div>\n } @else if (permissions().length === 0) {\n <div class=\"fac-empty\">{{ 'files.share.no_permissions' | translate }}</div>\n } @else {\n @for (perm of permissions(); track perm.id) {\n <div class=\"fac-perm-row\">\n <div class=\"fac-perm-info\">\n <i [class]=\"getPermIcon(perm)\" aria-hidden=\"true\"></i>\n <span class=\"fac-perm-name\">{{ getPermLabel(perm) }}</span>\n @if (perm.isInherited) {\n <span class=\"fac-badge inherited\">{{ 'files.share.inherited' | translate }}</span>\n }\n </div>\n <div class=\"fac-perm-actions\">\n <select\n class=\"fac-select sm\"\n [ngModel]=\"perm.level\"\n (ngModelChange)=\"onUpdateLevel(perm, $event)\"\n [disabled]=\"perm.isInherited === true\"\n >\n @for (opt of levelOptions; track opt.value) {\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\n }\n </select>\n <button\n type=\"button\"\n class=\"fac-icon-btn danger\"\n (click)=\"onRevokePermission(perm)\"\n [disabled]=\"perm.isInherited === true\"\n [title]=\"'files.share.revoke' | translate\"\n >\n <i class=\"pi pi-trash\" aria-hidden=\"true\"></i>\n </button>\n </div>\n </div>\n }\n }\n </div>\n </div>\n</div>\n", styles: [".fac-overlay{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:2000;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.fac-panel{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:12px;width:600px;max-width:min(600px,96vw);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 16px 48px #0006}.fac-header{display:flex;align-items:center;gap:10px;padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-header h3{margin:0;font-size:16px;font-weight:600}.fac-target-name{flex:1;font-size:13px;color:var(--text-color-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:16px;padding:4px}.fac-close:hover{color:var(--text-color)}.fac-add-section{padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-search-row{display:flex;gap:8px}.fac-chart-row{display:flex;flex-direction:column;gap:6px;margin-top:12px}.fac-chart-row .fac-select{width:100%}.fac-input{flex:1;background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fac-input:focus{border-color:var(--primary-color, #e8732a)}.fac-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 10px;color:inherit;font-size:13px;outline:none}.fac-select.sm{padding:4px 8px;font-size:12px}.fac-search-results{margin-top:8px;max-height:150px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-search-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-search-item:hover{background:var(--surface-hover)}.fac-search-item i{color:var(--text-color-secondary)}.fac-email{color:var(--text-color-secondary);font-size:12px;margin-inline-start:auto}.fac-ou-section{margin-top:10px;max-height:220px;overflow-y:auto}.fac-ou-item{display:flex;align-items:center;gap:8px;padding:6px 12px;padding-inline-start:12px;cursor:pointer;font-size:13px;border-radius:6px}.fac-ou-item:hover{background:var(--surface-hover)}.fac-ou-item i{color:var(--text-color-secondary)}.fac-section-label{font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-color-secondary);letter-spacing:.5px;margin-bottom:8px}.fac-toggle-row{display:flex;align-items:center;justify-content:space-between;margin-top:10px}.fac-link{background:none;border:none;color:var(--primary-color, #e8732a);cursor:pointer;font-size:12px;padding:0}.fac-link:hover{text-decoration:underline}.fac-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fac-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fac-perms-section{flex:1;overflow-y:auto;padding:16px 20px}.fac-loading,.fac-empty{text-align:center;padding:20px;color:var(--text-color-secondary);font-size:13px}.fac-perm-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.05);gap:8px}.fac-perm-info{display:flex;align-items:center;gap:8px;flex:1;min-width:0}.fac-perm-info i{color:var(--text-color-secondary);flex-shrink:0}.fac-perm-name{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-badge{font-size:10px;padding:2px 6px;border-radius:8px;flex-shrink:0}.fac-badge.inherited{background:#ffffff1a;color:var(--text-color-secondary)}.fac-perm-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}.fac-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:12px}.fac-icon-btn:hover{background:#ffffff24}.fac-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fac-icon-btn:disabled{opacity:.4;cursor:default}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1083
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: SharePanelComponent, isStandalone: true, selector: "fly-share-panel", inputs: { targetName: "targetName", titleKey: "titleKey", loadPermissions: "loadPermissions", searchUsers: "searchUsers", loadOuTree: "loadOuTree", loadChartOptions: "loadChartOptions", loadOuLabelMap: "loadOuLabelMap", grantToUser: "grantToUser", grantToOu: "grantToOu", updatePermission: "updatePermission", revokePermission: "revokePermission", showApplyToChildren: "showApplyToChildren", supportsDeny: "supportsDeny", permissionLevels: "permissionLevels", everyoneLabelKey: "everyoneLabelKey", loadRoleOus: "loadRoleOus" }, outputs: { close: "close" }, ngImport: i0, template: "<div class=\"fac-overlay\" (click)=\"close.emit()\">\r\n <div class=\"fac-panel\" (click)=\"$event.stopPropagation()\">\r\n <div class=\"fac-header\">\r\n <h3>{{ titleKey | translate }}</h3>\r\n <span class=\"fac-target-name\">{{ targetName }}</span>\r\n <button\r\n type=\"button\"\r\n class=\"fac-close\"\r\n (click)=\"close.emit()\"\r\n [attr.aria-label]=\"'files.share.close' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n\r\n <div class=\"fac-add-section\">\r\n <div class=\"fac-search-row\">\r\n <input\r\n type=\"text\"\r\n class=\"fac-input\"\r\n [(ngModel)]=\"searchQuery\"\r\n (input)=\"onSearchInput()\"\r\n [placeholder]=\"'files.share.search_placeholder' | translate\"\r\n [attr.aria-label]=\"'files.share.search_users' | translate\"\r\n />\r\n <select class=\"fac-select\" [(ngModel)]=\"newLevel\">\r\n @for (opt of levelOptions; track opt.value) {\r\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\r\n }\r\n </select>\r\n </div>\r\n\r\n @if (searchResults().length > 0) {\r\n <div class=\"fac-search-results\">\r\n @for (result of searchResults(); track result.id) {\r\n <div class=\"fac-search-item\" (click)=\"onGrantToUser(result)\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ result.firstName }} {{ result.lastName }}</span>\r\n <span class=\"fac-email\">{{ result.email }}</span>\r\n </div>\r\n }\r\n </div>\r\n }\r\n\r\n @if (chartOptions().length > 0) {\r\n <div class=\"fac-chart-row\">\r\n <label for=\"fac-org-chart-select\" class=\"fac-section-label\">{{\r\n 'files.share.org_chart' | translate\r\n }}</label>\r\n <select\r\n id=\"fac-org-chart-select\"\r\n class=\"fac-select\"\r\n [ngModel]=\"selectedOrgChartId()\"\r\n (ngModelChange)=\"onOrgChartSelectChange($event)\">\r\n @for (opt of chartOptions(); track $index) {\r\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\r\n }\r\n </select>\r\n </div>\r\n }\r\n\r\n @if (showOuPicker()) {\r\n <div class=\"fac-ou-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.share_with_ou' | translate }}</div>\r\n @for (ou of ouTree(); track ou.id) {\r\n <button\r\n type=\"button\"\r\n class=\"fac-ou-item\"\r\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\r\n (click)=\"onGrantToOu(ou)\"\r\n [attr.aria-label]=\"('files.share.share_with_ou_named' | translate) + ' ' + ou.displayName\"\r\n >\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span dir=\"auto\">{{ ou.displayName }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <div class=\"fac-role-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.share_with_role' | translate }}</div>\r\n <input\r\n type=\"text\"\r\n class=\"fac-input\"\r\n [(ngModel)]=\"roleSearchQuery\"\r\n [placeholder]=\"'files.share.role_search_placeholder' | translate\"\r\n [attr.aria-label]=\"'files.share.search_roles' | translate\"\r\n [hidden]=\"!showRolePicker()\" />\r\n @if (showRolePicker()) {\r\n <div class=\"fac-role-list\">\r\n @for (role of filteredRoles(); track role.ouId) {\r\n <button\r\n type=\"button\"\r\n class=\"fac-role-item\"\r\n (click)=\"onGrantToRole(role)\"\r\n [attr.aria-label]=\"('files.share.share_with_role_named' | translate) + ' ' + role.displayName\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span dir=\"auto\">{{ role.displayName }}</span>\r\n <span class=\"fac-role-app\">{{ role.appId }}</span>\r\n </button>\r\n }\r\n @if (filteredRoles().length === 0) {\r\n <div class=\"fac-empty\">{{ 'files.share.no_roles_match' | translate }}</div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n }\r\n\r\n <div class=\"fac-toggle-row\">\r\n <button type=\"button\" class=\"fac-link\" (click)=\"showOuPicker.set(!showOuPicker())\">\r\n {{ showOuPicker() ? ('files.share.hide_ous' | translate) : ('files.share.show_ous' | translate) }}\r\n </button>\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <button type=\"button\" class=\"fac-link\" (click)=\"showRolePicker.set(!showRolePicker())\">\r\n {{ showRolePicker() ? ('files.share.hide_roles' | translate) : ('files.share.show_roles' | translate) }}\r\n </button>\r\n }\r\n @if (showApplyToChildren) {\r\n <label class=\"fac-checkbox-label\">\r\n <input type=\"checkbox\" [(ngModel)]=\"applyToChildren\" />\r\n {{ 'files.share.apply_children' | translate }}\r\n </label>\r\n }\r\n @if (supportsDeny) {\r\n <label class=\"fac-checkbox-label fac-deny-toggle\"\r\n [title]=\"'files.share.deny_help' | translate\">\r\n <input type=\"checkbox\" [(ngModel)]=\"addAsDeny\" />\r\n <i class=\"pi pi-ban\" aria-hidden=\"true\"></i>\r\n {{ 'files.share.add_as_deny' | translate }}\r\n </label>\r\n }\r\n </div>\r\n </div>\r\n\r\n <div class=\"fac-perms-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.current_permissions' | translate }}</div>\r\n @if (loading()) {\r\n <div class=\"fac-loading\"><i class=\"pi pi-spin pi-spinner\" aria-hidden=\"true\"></i></div>\r\n } @else if (permissions().length === 0) {\r\n <div class=\"fac-empty\">{{ 'files.share.no_permissions' | translate }}</div>\r\n } @else {\r\n @for (perm of permissions(); track perm.id) {\r\n <div class=\"fac-perm-row\" [class.fac-perm-row-deny]=\"perm.isDeny\">\r\n <div class=\"fac-perm-info\">\r\n <i [class]=\"getPermIcon(perm)\" aria-hidden=\"true\"></i>\r\n <span class=\"fac-perm-name\" dir=\"auto\">{{ getPermLabel(perm) }}</span>\r\n @if (perm.isDeny) {\r\n <span class=\"fac-badge deny\">{{ 'files.share.denied' | translate }}</span>\r\n }\r\n @if (perm.isInherited) {\r\n <span class=\"fac-badge inherited\">{{ 'files.share.inherited' | translate }}</span>\r\n }\r\n </div>\r\n <div class=\"fac-perm-actions\">\r\n @if (!perm.isDeny) {\r\n <select\r\n class=\"fac-select sm\"\r\n [ngModel]=\"perm.level\"\r\n (ngModelChange)=\"onUpdateLevel(perm, $event)\"\r\n [disabled]=\"perm.isInherited === true\"\r\n >\r\n @for (opt of levelOptions; track opt.value) {\r\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\r\n }\r\n </select>\r\n }\r\n <button\r\n type=\"button\"\r\n class=\"fac-icon-btn danger\"\r\n (click)=\"onRevokePermission(perm)\"\r\n [disabled]=\"perm.isInherited === true\"\r\n [title]=\"'files.share.revoke' | translate\"\r\n >\r\n <i class=\"pi pi-trash\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n </div>\r\n }\r\n }\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".fac-overlay{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:2000;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.fac-panel{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:12px;width:600px;max-width:min(600px,96vw);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 16px 48px #0006}.fac-header{display:flex;align-items:center;gap:10px;padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-header h3{margin:0;font-size:16px;font-weight:600}.fac-target-name{flex:1;font-size:13px;color:var(--text-color-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:16px;padding:4px}.fac-close:hover{color:var(--text-color)}.fac-add-section{padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-search-row{display:flex;gap:8px}.fac-chart-row{display:flex;flex-direction:column;gap:6px;margin-top:12px}.fac-chart-row .fac-select{width:100%}.fac-input{flex:1;background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fac-input:focus{border-color:var(--primary-color, #e8732a)}.fac-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 10px;color:inherit;font-size:13px;outline:none}.fac-select.sm{padding:4px 8px;font-size:12px}.fac-search-results{margin-top:8px;max-height:150px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-search-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-search-item:hover{background:var(--surface-hover)}.fac-search-item i{color:var(--text-color-secondary)}.fac-email{color:var(--text-color-secondary);font-size:12px;margin-inline-start:auto}.fac-ou-section{margin-top:10px;max-height:220px;overflow-y:auto}.fac-ou-item{display:flex;align-items:center;gap:8px;padding:6px 12px;padding-inline-start:12px;cursor:pointer;font-size:13px;border-radius:6px}.fac-ou-item:hover{background:var(--surface-hover)}.fac-ou-item i{color:var(--text-color-secondary)}.fac-section-label{font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-color-secondary);letter-spacing:.5px;margin-bottom:8px}.fac-toggle-row{display:flex;align-items:center;justify-content:space-between;margin-top:10px}.fac-link{background:none;border:none;color:var(--primary-color, #e8732a);cursor:pointer;font-size:12px;padding:0}.fac-link:hover{text-decoration:underline}.fac-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fac-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fac-perms-section{flex:1;overflow-y:auto;padding:16px 20px}.fac-loading,.fac-empty{text-align:center;padding:20px;color:var(--text-color-secondary);font-size:13px}.fac-perm-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.05);gap:8px}.fac-perm-info{display:flex;align-items:center;gap:8px;flex:1;min-width:0}.fac-perm-info i{color:var(--text-color-secondary);flex-shrink:0}.fac-perm-name{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-badge{font-size:10px;padding:2px 6px;border-radius:8px;flex-shrink:0}.fac-badge.inherited{background:#ffffff1a;color:var(--text-color-secondary)}.fac-badge.deny{background:#ff3b302e;color:#ff3b30;text-transform:uppercase;font-weight:600;letter-spacing:.5px}.fac-perm-row-deny{background:#ff3b300f;border-inline-start:3px solid #ff3b30;padding-inline-start:8px;border-radius:6px}.fac-perm-row-deny .fac-perm-info i{color:#ff3b30}.fac-deny-toggle{color:#ff6b6b}.fac-deny-toggle i{color:#ff6b6b;font-size:11px;margin-inline-end:2px}.fac-deny-toggle input{accent-color:#ff3b30}.fac-role-section{margin-top:10px;display:flex;flex-direction:column;gap:6px}.fac-role-list{margin-top:6px;max-height:220px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-role-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-role-item:hover{background:var(--surface-hover)}.fac-role-item i{color:var(--text-color-secondary)}.fac-role-app{margin-inline-start:auto;font-size:11px;color:var(--text-color-secondary);text-transform:uppercase;letter-spacing:.5px}.fac-perm-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}.fac-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:12px}.fac-icon-btn:hover{background:#ffffff24}.fac-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fac-icon-btn:disabled{opacity:.4;cursor:default}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
924
1084
  }
925
1085
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: SharePanelComponent, decorators: [{
926
1086
  type: Component,
927
- args: [{ selector: 'fly-share-panel', standalone: true, imports: [CommonModule, FormsModule, TranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"fac-overlay\" (click)=\"close.emit()\">\n <div class=\"fac-panel\" (click)=\"$event.stopPropagation()\">\n <div class=\"fac-header\">\n <h3>{{ titleKey | translate }}</h3>\n <span class=\"fac-target-name\">{{ targetName }}</span>\n <button type=\"button\" class=\"fac-close\" (click)=\"close.emit()\">\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\n </button>\n </div>\n\n <div class=\"fac-add-section\">\n <div class=\"fac-search-row\">\n <input\n type=\"text\"\n class=\"fac-input\"\n [(ngModel)]=\"searchQuery\"\n (input)=\"onSearchInput()\"\n [placeholder]=\"'files.share.search_placeholder' | translate\"\n />\n <select class=\"fac-select\" [(ngModel)]=\"newLevel\">\n @for (opt of levelOptions; track opt.value) {\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\n }\n </select>\n </div>\n\n @if (searchResults().length > 0) {\n <div class=\"fac-search-results\">\n @for (result of searchResults(); track result.id) {\n <div class=\"fac-search-item\" (click)=\"onGrantToUser(result)\">\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\n <span>{{ result.firstName }} {{ result.lastName }}</span>\n <span class=\"fac-email\">{{ result.email }}</span>\n </div>\n }\n </div>\n }\n\n @if (chartOptions().length > 0) {\n <div class=\"fac-chart-row\">\n <label for=\"fac-org-chart-select\" class=\"fac-section-label\">{{\n 'files.share.org_chart' | translate\n }}</label>\n <select\n id=\"fac-org-chart-select\"\n class=\"fac-select\"\n [ngModel]=\"selectedOrgChartId()\"\n (ngModelChange)=\"onOrgChartSelectChange($event)\">\n @for (opt of chartOptions(); track $index) {\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\n }\n </select>\n </div>\n }\n\n @if (showOuPicker()) {\n <div class=\"fac-ou-section\">\n <div class=\"fac-section-label\">{{ 'files.share.share_with_ou' | translate }}</div>\n @for (ou of ouTree(); track ou.id) {\n <div\n class=\"fac-ou-item\"\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\n (click)=\"onGrantToOu(ou)\"\n >\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\n <span>{{ ou.displayName }}</span>\n </div>\n }\n </div>\n }\n\n <div class=\"fac-toggle-row\">\n <button type=\"button\" class=\"fac-link\" (click)=\"showOuPicker.set(!showOuPicker())\">\n {{ showOuPicker() ? ('files.share.hide_ous' | translate) : ('files.share.show_ous' | translate) }}\n </button>\n @if (showApplyToChildren) {\n <label class=\"fac-checkbox-label\">\n <input type=\"checkbox\" [(ngModel)]=\"applyToChildren\" />\n {{ 'files.share.apply_children' | translate }}\n </label>\n }\n </div>\n </div>\n\n <div class=\"fac-perms-section\">\n <div class=\"fac-section-label\">{{ 'files.share.current_permissions' | translate }}</div>\n @if (loading()) {\n <div class=\"fac-loading\"><i class=\"pi pi-spin pi-spinner\" aria-hidden=\"true\"></i></div>\n } @else if (permissions().length === 0) {\n <div class=\"fac-empty\">{{ 'files.share.no_permissions' | translate }}</div>\n } @else {\n @for (perm of permissions(); track perm.id) {\n <div class=\"fac-perm-row\">\n <div class=\"fac-perm-info\">\n <i [class]=\"getPermIcon(perm)\" aria-hidden=\"true\"></i>\n <span class=\"fac-perm-name\">{{ getPermLabel(perm) }}</span>\n @if (perm.isInherited) {\n <span class=\"fac-badge inherited\">{{ 'files.share.inherited' | translate }}</span>\n }\n </div>\n <div class=\"fac-perm-actions\">\n <select\n class=\"fac-select sm\"\n [ngModel]=\"perm.level\"\n (ngModelChange)=\"onUpdateLevel(perm, $event)\"\n [disabled]=\"perm.isInherited === true\"\n >\n @for (opt of levelOptions; track opt.value) {\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\n }\n </select>\n <button\n type=\"button\"\n class=\"fac-icon-btn danger\"\n (click)=\"onRevokePermission(perm)\"\n [disabled]=\"perm.isInherited === true\"\n [title]=\"'files.share.revoke' | translate\"\n >\n <i class=\"pi pi-trash\" aria-hidden=\"true\"></i>\n </button>\n </div>\n </div>\n }\n }\n </div>\n </div>\n</div>\n", styles: [".fac-overlay{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:2000;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.fac-panel{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:12px;width:600px;max-width:min(600px,96vw);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 16px 48px #0006}.fac-header{display:flex;align-items:center;gap:10px;padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-header h3{margin:0;font-size:16px;font-weight:600}.fac-target-name{flex:1;font-size:13px;color:var(--text-color-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:16px;padding:4px}.fac-close:hover{color:var(--text-color)}.fac-add-section{padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-search-row{display:flex;gap:8px}.fac-chart-row{display:flex;flex-direction:column;gap:6px;margin-top:12px}.fac-chart-row .fac-select{width:100%}.fac-input{flex:1;background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fac-input:focus{border-color:var(--primary-color, #e8732a)}.fac-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 10px;color:inherit;font-size:13px;outline:none}.fac-select.sm{padding:4px 8px;font-size:12px}.fac-search-results{margin-top:8px;max-height:150px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-search-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-search-item:hover{background:var(--surface-hover)}.fac-search-item i{color:var(--text-color-secondary)}.fac-email{color:var(--text-color-secondary);font-size:12px;margin-inline-start:auto}.fac-ou-section{margin-top:10px;max-height:220px;overflow-y:auto}.fac-ou-item{display:flex;align-items:center;gap:8px;padding:6px 12px;padding-inline-start:12px;cursor:pointer;font-size:13px;border-radius:6px}.fac-ou-item:hover{background:var(--surface-hover)}.fac-ou-item i{color:var(--text-color-secondary)}.fac-section-label{font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-color-secondary);letter-spacing:.5px;margin-bottom:8px}.fac-toggle-row{display:flex;align-items:center;justify-content:space-between;margin-top:10px}.fac-link{background:none;border:none;color:var(--primary-color, #e8732a);cursor:pointer;font-size:12px;padding:0}.fac-link:hover{text-decoration:underline}.fac-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fac-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fac-perms-section{flex:1;overflow-y:auto;padding:16px 20px}.fac-loading,.fac-empty{text-align:center;padding:20px;color:var(--text-color-secondary);font-size:13px}.fac-perm-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.05);gap:8px}.fac-perm-info{display:flex;align-items:center;gap:8px;flex:1;min-width:0}.fac-perm-info i{color:var(--text-color-secondary);flex-shrink:0}.fac-perm-name{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-badge{font-size:10px;padding:2px 6px;border-radius:8px;flex-shrink:0}.fac-badge.inherited{background:#ffffff1a;color:var(--text-color-secondary)}.fac-perm-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}.fac-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:12px}.fac-icon-btn:hover{background:#ffffff24}.fac-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fac-icon-btn:disabled{opacity:.4;cursor:default}\n"] }]
1087
+ args: [{ selector: 'fly-share-panel', standalone: true, imports: [CommonModule, FormsModule, TranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"fac-overlay\" (click)=\"close.emit()\">\r\n <div class=\"fac-panel\" (click)=\"$event.stopPropagation()\">\r\n <div class=\"fac-header\">\r\n <h3>{{ titleKey | translate }}</h3>\r\n <span class=\"fac-target-name\">{{ targetName }}</span>\r\n <button\r\n type=\"button\"\r\n class=\"fac-close\"\r\n (click)=\"close.emit()\"\r\n [attr.aria-label]=\"'files.share.close' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n\r\n <div class=\"fac-add-section\">\r\n <div class=\"fac-search-row\">\r\n <input\r\n type=\"text\"\r\n class=\"fac-input\"\r\n [(ngModel)]=\"searchQuery\"\r\n (input)=\"onSearchInput()\"\r\n [placeholder]=\"'files.share.search_placeholder' | translate\"\r\n [attr.aria-label]=\"'files.share.search_users' | translate\"\r\n />\r\n <select class=\"fac-select\" [(ngModel)]=\"newLevel\">\r\n @for (opt of levelOptions; track opt.value) {\r\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\r\n }\r\n </select>\r\n </div>\r\n\r\n @if (searchResults().length > 0) {\r\n <div class=\"fac-search-results\">\r\n @for (result of searchResults(); track result.id) {\r\n <div class=\"fac-search-item\" (click)=\"onGrantToUser(result)\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ result.firstName }} {{ result.lastName }}</span>\r\n <span class=\"fac-email\">{{ result.email }}</span>\r\n </div>\r\n }\r\n </div>\r\n }\r\n\r\n @if (chartOptions().length > 0) {\r\n <div class=\"fac-chart-row\">\r\n <label for=\"fac-org-chart-select\" class=\"fac-section-label\">{{\r\n 'files.share.org_chart' | translate\r\n }}</label>\r\n <select\r\n id=\"fac-org-chart-select\"\r\n class=\"fac-select\"\r\n [ngModel]=\"selectedOrgChartId()\"\r\n (ngModelChange)=\"onOrgChartSelectChange($event)\">\r\n @for (opt of chartOptions(); track $index) {\r\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\r\n }\r\n </select>\r\n </div>\r\n }\r\n\r\n @if (showOuPicker()) {\r\n <div class=\"fac-ou-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.share_with_ou' | translate }}</div>\r\n @for (ou of ouTree(); track ou.id) {\r\n <button\r\n type=\"button\"\r\n class=\"fac-ou-item\"\r\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\r\n (click)=\"onGrantToOu(ou)\"\r\n [attr.aria-label]=\"('files.share.share_with_ou_named' | translate) + ' ' + ou.displayName\"\r\n >\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span dir=\"auto\">{{ ou.displayName }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <div class=\"fac-role-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.share_with_role' | translate }}</div>\r\n <input\r\n type=\"text\"\r\n class=\"fac-input\"\r\n [(ngModel)]=\"roleSearchQuery\"\r\n [placeholder]=\"'files.share.role_search_placeholder' | translate\"\r\n [attr.aria-label]=\"'files.share.search_roles' | translate\"\r\n [hidden]=\"!showRolePicker()\" />\r\n @if (showRolePicker()) {\r\n <div class=\"fac-role-list\">\r\n @for (role of filteredRoles(); track role.ouId) {\r\n <button\r\n type=\"button\"\r\n class=\"fac-role-item\"\r\n (click)=\"onGrantToRole(role)\"\r\n [attr.aria-label]=\"('files.share.share_with_role_named' | translate) + ' ' + role.displayName\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span dir=\"auto\">{{ role.displayName }}</span>\r\n <span class=\"fac-role-app\">{{ role.appId }}</span>\r\n </button>\r\n }\r\n @if (filteredRoles().length === 0) {\r\n <div class=\"fac-empty\">{{ 'files.share.no_roles_match' | translate }}</div>\r\n }\r\n </div>\r\n }\r\n </div>\r\n }\r\n\r\n <div class=\"fac-toggle-row\">\r\n <button type=\"button\" class=\"fac-link\" (click)=\"showOuPicker.set(!showOuPicker())\">\r\n {{ showOuPicker() ? ('files.share.hide_ous' | translate) : ('files.share.show_ous' | translate) }}\r\n </button>\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <button type=\"button\" class=\"fac-link\" (click)=\"showRolePicker.set(!showRolePicker())\">\r\n {{ showRolePicker() ? ('files.share.hide_roles' | translate) : ('files.share.show_roles' | translate) }}\r\n </button>\r\n }\r\n @if (showApplyToChildren) {\r\n <label class=\"fac-checkbox-label\">\r\n <input type=\"checkbox\" [(ngModel)]=\"applyToChildren\" />\r\n {{ 'files.share.apply_children' | translate }}\r\n </label>\r\n }\r\n @if (supportsDeny) {\r\n <label class=\"fac-checkbox-label fac-deny-toggle\"\r\n [title]=\"'files.share.deny_help' | translate\">\r\n <input type=\"checkbox\" [(ngModel)]=\"addAsDeny\" />\r\n <i class=\"pi pi-ban\" aria-hidden=\"true\"></i>\r\n {{ 'files.share.add_as_deny' | translate }}\r\n </label>\r\n }\r\n </div>\r\n </div>\r\n\r\n <div class=\"fac-perms-section\">\r\n <div class=\"fac-section-label\">{{ 'files.share.current_permissions' | translate }}</div>\r\n @if (loading()) {\r\n <div class=\"fac-loading\"><i class=\"pi pi-spin pi-spinner\" aria-hidden=\"true\"></i></div>\r\n } @else if (permissions().length === 0) {\r\n <div class=\"fac-empty\">{{ 'files.share.no_permissions' | translate }}</div>\r\n } @else {\r\n @for (perm of permissions(); track perm.id) {\r\n <div class=\"fac-perm-row\" [class.fac-perm-row-deny]=\"perm.isDeny\">\r\n <div class=\"fac-perm-info\">\r\n <i [class]=\"getPermIcon(perm)\" aria-hidden=\"true\"></i>\r\n <span class=\"fac-perm-name\" dir=\"auto\">{{ getPermLabel(perm) }}</span>\r\n @if (perm.isDeny) {\r\n <span class=\"fac-badge deny\">{{ 'files.share.denied' | translate }}</span>\r\n }\r\n @if (perm.isInherited) {\r\n <span class=\"fac-badge inherited\">{{ 'files.share.inherited' | translate }}</span>\r\n }\r\n </div>\r\n <div class=\"fac-perm-actions\">\r\n @if (!perm.isDeny) {\r\n <select\r\n class=\"fac-select sm\"\r\n [ngModel]=\"perm.level\"\r\n (ngModelChange)=\"onUpdateLevel(perm, $event)\"\r\n [disabled]=\"perm.isInherited === true\"\r\n >\r\n @for (opt of levelOptions; track opt.value) {\r\n <option [value]=\"opt.value\">{{ opt.labelKey | translate }}</option>\r\n }\r\n </select>\r\n }\r\n <button\r\n type=\"button\"\r\n class=\"fac-icon-btn danger\"\r\n (click)=\"onRevokePermission(perm)\"\r\n [disabled]=\"perm.isInherited === true\"\r\n [title]=\"'files.share.revoke' | translate\"\r\n >\r\n <i class=\"pi pi-trash\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n </div>\r\n }\r\n }\r\n </div>\r\n </div>\r\n</div>\r\n", styles: [".fac-overlay{position:fixed;inset:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:2000;-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}.fac-panel{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:12px;width:600px;max-width:min(600px,96vw);max-height:85vh;display:flex;flex-direction:column;overflow:hidden;box-shadow:0 16px 48px #0006}.fac-header{display:flex;align-items:center;gap:10px;padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-header h3{margin:0;font-size:16px;font-weight:600}.fac-target-name{flex:1;font-size:13px;color:var(--text-color-secondary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:16px;padding:4px}.fac-close:hover{color:var(--text-color)}.fac-add-section{padding:16px 20px;border-bottom:1px solid var(--surface-border)}.fac-search-row{display:flex;gap:8px}.fac-chart-row{display:flex;flex-direction:column;gap:6px;margin-top:12px}.fac-chart-row .fac-select{width:100%}.fac-input{flex:1;background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fac-input:focus{border-color:var(--primary-color, #e8732a)}.fac-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 10px;color:inherit;font-size:13px;outline:none}.fac-select.sm{padding:4px 8px;font-size:12px}.fac-search-results{margin-top:8px;max-height:150px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-search-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-search-item:hover{background:var(--surface-hover)}.fac-search-item i{color:var(--text-color-secondary)}.fac-email{color:var(--text-color-secondary);font-size:12px;margin-inline-start:auto}.fac-ou-section{margin-top:10px;max-height:220px;overflow-y:auto}.fac-ou-item{display:flex;align-items:center;gap:8px;padding:6px 12px;padding-inline-start:12px;cursor:pointer;font-size:13px;border-radius:6px}.fac-ou-item:hover{background:var(--surface-hover)}.fac-ou-item i{color:var(--text-color-secondary)}.fac-section-label{font-size:11px;font-weight:700;text-transform:uppercase;color:var(--text-color-secondary);letter-spacing:.5px;margin-bottom:8px}.fac-toggle-row{display:flex;align-items:center;justify-content:space-between;margin-top:10px}.fac-link{background:none;border:none;color:var(--primary-color, #e8732a);cursor:pointer;font-size:12px;padding:0}.fac-link:hover{text-decoration:underline}.fac-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fac-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fac-perms-section{flex:1;overflow-y:auto;padding:16px 20px}.fac-loading,.fac-empty{text-align:center;padding:20px;color:var(--text-color-secondary);font-size:13px}.fac-perm-row{display:flex;align-items:center;justify-content:space-between;padding:8px 0;border-bottom:1px solid rgba(255,255,255,.05);gap:8px}.fac-perm-info{display:flex;align-items:center;gap:8px;flex:1;min-width:0}.fac-perm-info i{color:var(--text-color-secondary);flex-shrink:0}.fac-perm-name{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.fac-badge{font-size:10px;padding:2px 6px;border-radius:8px;flex-shrink:0}.fac-badge.inherited{background:#ffffff1a;color:var(--text-color-secondary)}.fac-badge.deny{background:#ff3b302e;color:#ff3b30;text-transform:uppercase;font-weight:600;letter-spacing:.5px}.fac-perm-row-deny{background:#ff3b300f;border-inline-start:3px solid #ff3b30;padding-inline-start:8px;border-radius:6px}.fac-perm-row-deny .fac-perm-info i{color:#ff3b30}.fac-deny-toggle{color:#ff6b6b}.fac-deny-toggle i{color:#ff6b6b;font-size:11px;margin-inline-end:2px}.fac-deny-toggle input{accent-color:#ff3b30}.fac-role-section{margin-top:10px;display:flex;flex-direction:column;gap:6px}.fac-role-list{margin-top:6px;max-height:220px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fac-role-item{display:flex;align-items:center;gap:8px;padding:8px 12px;cursor:pointer;font-size:13px}.fac-role-item:hover{background:var(--surface-hover)}.fac-role-item i{color:var(--text-color-secondary)}.fac-role-app{margin-inline-start:auto;font-size:11px;color:var(--text-color-secondary);text-transform:uppercase;letter-spacing:.5px}.fac-perm-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}.fac-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:28px;height:28px;display:flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:12px}.fac-icon-btn:hover{background:#ffffff24}.fac-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fac-icon-btn:disabled{opacity:.4;cursor:default}\n"] }]
928
1088
  }], propDecorators: { targetName: [{
929
1089
  type: Input
930
1090
  }], titleKey: [{
@@ -958,14 +1118,618 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImpor
958
1118
  args: [{ required: true }]
959
1119
  }], showApplyToChildren: [{
960
1120
  type: Input
1121
+ }], supportsDeny: [{
1122
+ type: Input
961
1123
  }], permissionLevels: [{
962
1124
  type: Input
963
1125
  }], everyoneLabelKey: [{
964
1126
  type: Input
1127
+ }], loadRoleOus: [{
1128
+ type: Input
965
1129
  }], close: [{
966
1130
  type: Output
967
1131
  }] } });
968
1132
 
1133
+ /**
1134
+ * Generic builder for {@link AudienceFilter}. Accepts an initial value, emits both the
1135
+ * full filter and a validity flag on every change. Reuses the same callback shapes as
1136
+ * {@link SharePanelComponent} so a host already wiring the share panel can drop this in
1137
+ * with no extra plumbing.
1138
+ *
1139
+ * Compaction policy: when the operator picks the same kind multiple times, the component
1140
+ * merges into a single term where it's safe to do so:
1141
+ * <list type="bullet">
1142
+ * <item><b>users</b>: dedupes user ids into one {@link UsersTerm}.</item>
1143
+ * <item><b>roles</b>: per `appId`, dedupes role keys into one {@link RolesTerm}.</item>
1144
+ * <item><b>ou</b>: per `(chartId, includeDescendants)`, dedupes OU ids into one {@link OuTerm}.</item>
1145
+ * <item><b>app-everyone / chart / preset</b>: idempotent per kind+key.</item>
1146
+ * </list>
1147
+ * This keeps the term count well below {@link AUDIENCE_LIMITS.maxTermsPerFilter} for typical
1148
+ * use, and matches how the backend resolver would have unioned them anyway.
1149
+ */
1150
+ class AudienceBuilderComponent {
1151
+ /** Initial audience filter. Two-way: changes emit via {@link audienceChange}. */
1152
+ value = { includes: [] };
1153
+ /** Hide the Excludes section entirely (e.g. for simple "send to" pickers). */
1154
+ showExcludes = true;
1155
+ /**
1156
+ * Which term kinds are selectable. Defaults to all six. Hosts that don't have a
1157
+ * concept of `chart` or `preset` should narrow this down to keep the UI focused.
1158
+ */
1159
+ supportedKinds = [
1160
+ 'users',
1161
+ 'roles',
1162
+ 'ou',
1163
+ 'app-everyone',
1164
+ 'chart',
1165
+ 'preset',
1166
+ ];
1167
+ /**
1168
+ * Anchor user id used when adding a `preset` term. Typically `auth.currentUser().id`.
1169
+ * If null and the operator picks a preset, the term is rejected with an inline error.
1170
+ */
1171
+ selfUserId = null;
1172
+ /** App ids the host wants to expose as `app-everyone` options. */
1173
+ appEveryoneOptions = [];
1174
+ // ── Picker callbacks (same shapes as SharePanelComponent) ────────────────────
1175
+ searchUsers;
1176
+ loadOuTree;
1177
+ loadChartOptions;
1178
+ /**
1179
+ * Optional apps-chart role-OU index. When supplied, the picker exposes a "Roles" tab
1180
+ * surfacing role chips; without it, that tab is hidden even if `'roles'` is in
1181
+ * {@link supportedKinds}.
1182
+ */
1183
+ loadRoleOus;
1184
+ // ── Outputs ──────────────────────────────────────────────────────────────────
1185
+ /** Fires on every successful add/remove with the current filter. */
1186
+ audienceChange = new EventEmitter();
1187
+ /**
1188
+ * True when {@link AudienceFilter.includes} is non-empty and the term count is within
1189
+ * {@link AUDIENCE_LIMITS.maxTermsPerFilter}. Hosts should disable submit when false.
1190
+ */
1191
+ validityChange = new EventEmitter();
1192
+ // ── Internal state (signals; computed where derived) ─────────────────────────
1193
+ destroyRef = inject(DestroyRef);
1194
+ i18n = inject(I18nService);
1195
+ includes = signal([], ...(ngDevMode ? [{ debugName: "includes" }] : /* istanbul ignore next */ []));
1196
+ excludes = signal([], ...(ngDevMode ? [{ debugName: "excludes" }] : /* istanbul ignore next */ []));
1197
+ editTarget = signal('includes', ...(ngDevMode ? [{ debugName: "editTarget" }] : /* istanbul ignore next */ []));
1198
+ pickerOpen = signal(false, ...(ngDevMode ? [{ debugName: "pickerOpen" }] : /* istanbul ignore next */ []));
1199
+ activeKind = signal('users', ...(ngDevMode ? [{ debugName: "activeKind" }] : /* istanbul ignore next */ []));
1200
+ // User picker — signal-backed so OnPush + computed re-evaluates correctly. Keystrokes go
1201
+ // through a debounced Subject + switchMap so a slow "fo" result can never overwrite a
1202
+ // fresh "foobar" result (the previous setTimeout-based path had this race).
1203
+ userSearchQuery = signal('', ...(ngDevMode ? [{ debugName: "userSearchQuery" }] : /* istanbul ignore next */ []));
1204
+ userSearchResults = signal([], ...(ngDevMode ? [{ debugName: "userSearchResults" }] : /* istanbul ignore next */ []));
1205
+ userSearch$ = new Subject();
1206
+ // Role picker.
1207
+ roleOus = signal([], ...(ngDevMode ? [{ debugName: "roleOus" }] : /* istanbul ignore next */ []));
1208
+ roleSearchQuery = signal('', ...(ngDevMode ? [{ debugName: "roleSearchQuery" }] : /* istanbul ignore next */ []));
1209
+ /**
1210
+ * Filtered role list as a `computed` so the template doesn't re-call a method on every
1211
+ * change-detection cycle.
1212
+ */
1213
+ filteredRoles = computed(() => {
1214
+ const q = this.roleSearchQuery().trim().toLowerCase();
1215
+ const all = this.roleOus();
1216
+ if (!q)
1217
+ return all;
1218
+ return all.filter((r) => r.displayName.toLowerCase().includes(q)
1219
+ || r.roleKey.toLowerCase().includes(q)
1220
+ || r.appId.toLowerCase().includes(q));
1221
+ }, ...(ngDevMode ? [{ debugName: "filteredRoles" }] : /* istanbul ignore next */ []));
1222
+ // OU picker.
1223
+ ouTree = signal([], ...(ngDevMode ? [{ debugName: "ouTree" }] : /* istanbul ignore next */ []));
1224
+ chartOptions = signal([], ...(ngDevMode ? [{ debugName: "chartOptions" }] : /* istanbul ignore next */ []));
1225
+ selectedChartId = signal(null, ...(ngDevMode ? [{ debugName: "selectedChartId" }] : /* istanbul ignore next */ []));
1226
+ ouIncludeDescendants = signal(false, ...(ngDevMode ? [{ debugName: "ouIncludeDescendants" }] : /* istanbul ignore next */ []));
1227
+ /**
1228
+ * Chart-term picker entries — filters out the null-id default chart since `ChartTerm`
1229
+ * requires a real Guid. Avoids the UX trap where clicking "Default Company" surfaces
1230
+ * an inline error.
1231
+ */
1232
+ chartTermOptions = computed(() => this.chartOptions().filter((c) => c.id !== null), ...(ngDevMode ? [{ debugName: "chartTermOptions" }] : /* istanbul ignore next */ []));
1233
+ selectedChartId$ = new ReplaySubject(1);
1234
+ // Preset options exposed from the model so the template stays in sync with new presets.
1235
+ presetOptions = AUDIENCE_PRESETS;
1236
+ // Validation surface (inline errors).
1237
+ errorKey = signal(null, ...(ngDevMode ? [{ debugName: "errorKey" }] : /* istanbul ignore next */ []));
1238
+ /** True when the host disabled this control (template-driven `[disabled]` or reactive). */
1239
+ disabled = signal(false, ...(ngDevMode ? [{ debugName: "disabled" }] : /* istanbul ignore next */ []));
1240
+ // ── ControlValueAccessor + Validator wiring ──────────────────────────────────
1241
+ // These let hosts bind the component via `formControl` / `formControlName` /
1242
+ // `[(ngModel)]` instead of plumbing `[value]` + `(audienceChange)` manually. The
1243
+ // (audienceChange) / (validityChange) outputs still fire — Angular forms infrastructure
1244
+ // is purely additive and template-driven hosts continue to work unchanged.
1245
+ onChange = () => undefined;
1246
+ onTouched = () => undefined;
1247
+ onValidatorChange = () => undefined;
1248
+ // ── Lifecycle ────────────────────────────────────────────────────────────────
1249
+ ngOnInit() {
1250
+ // applyValue() seeds includes/excludes — defer the initial emit so we don't trigger
1251
+ // ExpressionChangedAfterItHasBeenChecked when a parent template binding observes
1252
+ // (audienceChange) / (validityChange) in the same change-detection cycle.
1253
+ this.applyValue(this.value, /* emitImmediate */ false);
1254
+ queueMicrotask(() => this.emitState());
1255
+ if (this.loadRoleOus) {
1256
+ this.loadRoleOus()
1257
+ .pipe(takeUntilDestroyed(this.destroyRef))
1258
+ .subscribe({
1259
+ next: (rows) => this.roleOus.set(rows),
1260
+ error: () => this.roleOus.set([]),
1261
+ });
1262
+ }
1263
+ this.selectedChartId$
1264
+ .pipe(switchMap((id) => this.loadOuTree(id)), takeUntilDestroyed(this.destroyRef))
1265
+ .subscribe({
1266
+ next: (tree) => this.ouTree.set(tree),
1267
+ error: () => this.ouTree.set([]),
1268
+ });
1269
+ // Debounced user search: 300ms idle → switchMap cancels any in-flight request when a
1270
+ // newer query arrives, killing the "stale result overwrites fresh result" race the old
1271
+ // setTimeout path had. `filter(q.length >= 2)` preserves the same UX gate.
1272
+ this.userSearch$
1273
+ .pipe(debounceTime(300), distinctUntilChanged(), filter((q) => q.length >= 2), switchMap((q) => this.searchUsers(q)), takeUntilDestroyed(this.destroyRef))
1274
+ .subscribe({
1275
+ next: (items) => this.userSearchResults.set(items),
1276
+ error: () => this.userSearchResults.set([]),
1277
+ });
1278
+ this.loadChartOptions()
1279
+ .pipe(takeUntilDestroyed(this.destroyRef))
1280
+ .subscribe({
1281
+ next: (opts) => {
1282
+ this.chartOptions.set(opts);
1283
+ const initial = opts[0]?.id ?? null;
1284
+ this.selectedChartId.set(initial);
1285
+ this.selectedChartId$.next(initial);
1286
+ },
1287
+ error: () => {
1288
+ this.chartOptions.set([]);
1289
+ this.selectedChartId.set(null);
1290
+ this.selectedChartId$.next(null);
1291
+ },
1292
+ });
1293
+ if (this.supportedKinds.length > 0 && !this.supportedKinds.includes(this.activeKind())) {
1294
+ this.activeKind.set(this.supportedKinds[0]);
1295
+ }
1296
+ }
1297
+ ngOnChanges(changes) {
1298
+ // Re-hydrate when the host swaps the filter wholesale (e.g. editing a different rule).
1299
+ // First-mount initialisation is handled by ngOnInit so we skip the initial assignment
1300
+ // here. Picker UI state is also reset so a stale search query / open picker from the
1301
+ // previous draft doesn't bleed into the new one.
1302
+ //
1303
+ // We do NOT emit (audienceChange) / onChange — the host already KNOWS the value it
1304
+ // pushed. Echoing back via the change callback can create a feedback loop with two-way
1305
+ // bindings and reactive forms. Validity, however, may genuinely change with the new
1306
+ // value — emit that signal explicitly via the form-validator hook + (validityChange).
1307
+ if (changes['value'] && !changes['value'].firstChange) {
1308
+ this.applyValue(this.value, /* emitImmediate */ false);
1309
+ this.closePicker();
1310
+ this.errorKey.set(null);
1311
+ this.emitValidityOnly();
1312
+ }
1313
+ }
1314
+ ngOnDestroy() {
1315
+ this.userSearch$.complete();
1316
+ }
1317
+ /**
1318
+ * Replaces the in-component state from an external value. Null-safe — if the host
1319
+ * passes <c>null</c> or <c>undefined</c> (e.g. while a parent signal is loading),
1320
+ * we treat it as an empty filter rather than crashing.
1321
+ */
1322
+ applyValue(v, emitImmediate) {
1323
+ this.includes.set([...(v?.includes ?? [])]);
1324
+ this.excludes.set([...(v?.excludes ?? [])]);
1325
+ if (emitImmediate)
1326
+ this.emitState();
1327
+ }
1328
+ // ── Picker open/close + tabs ─────────────────────────────────────────────────
1329
+ openPicker(target) {
1330
+ if (this.disabled())
1331
+ return;
1332
+ this.editTarget.set(target);
1333
+ this.pickerOpen.set(true);
1334
+ this.errorKey.set(null);
1335
+ }
1336
+ closePicker() {
1337
+ this.pickerOpen.set(false);
1338
+ this.userSearchQuery.set('');
1339
+ this.userSearchResults.set([]);
1340
+ this.roleSearchQuery.set('');
1341
+ }
1342
+ selectKind(kind) {
1343
+ this.activeKind.set(kind);
1344
+ this.errorKey.set(null);
1345
+ }
1346
+ // ── User picker ──────────────────────────────────────────────────────────────
1347
+ onUserSearchInput() {
1348
+ const q = this.userSearchQuery().trim();
1349
+ if (q.length < 2) {
1350
+ this.userSearchResults.set([]);
1351
+ // Push the short query into the stream so distinctUntilChanged resets when the user
1352
+ // shortens and re-lengthens the query (otherwise "foo" → "f" → "foo" is a no-op).
1353
+ this.userSearch$.next(q);
1354
+ return;
1355
+ }
1356
+ this.userSearch$.next(q);
1357
+ }
1358
+ pickUser(user) {
1359
+ this.addToActiveBucket((existing) => this.upsertUsersTerm(existing, user.id));
1360
+ this.userSearchQuery.set('');
1361
+ this.userSearchResults.set([]);
1362
+ }
1363
+ // ── Role picker ──────────────────────────────────────────────────────────────
1364
+ pickRole(role) {
1365
+ this.addToActiveBucket((existing) => this.upsertRolesTerm(existing, role.appId, role.roleKey));
1366
+ this.roleSearchQuery.set('');
1367
+ }
1368
+ // ── OU picker ────────────────────────────────────────────────────────────────
1369
+ onChartChange(chartId) {
1370
+ this.selectedChartId.set(chartId);
1371
+ this.selectedChartId$.next(chartId);
1372
+ }
1373
+ pickOu(ou) {
1374
+ const chartId = this.selectedChartId();
1375
+ const includeDescendants = this.ouIncludeDescendants();
1376
+ this.addToActiveBucket((existing) => this.upsertOuTerm(existing, ou.id, chartId, includeDescendants));
1377
+ }
1378
+ ouIndentStartPx(ou) {
1379
+ const d = ou.depth ?? 0;
1380
+ return 12 + d * 18;
1381
+ }
1382
+ // ── App-everyone picker ──────────────────────────────────────────────────────
1383
+ pickAppEveryone(appId) {
1384
+ this.addToActiveBucket((existing) => this.upsertAppEveryoneTerm(existing, appId));
1385
+ }
1386
+ // ── Chart picker ─────────────────────────────────────────────────────────────
1387
+ pickChart(chartId) {
1388
+ if (!chartId) {
1389
+ this.errorKey.set('audience.error.chart_id_required');
1390
+ return;
1391
+ }
1392
+ this.addToActiveBucket((existing) => this.upsertChartTerm(existing, chartId));
1393
+ }
1394
+ // ── Preset picker ────────────────────────────────────────────────────────────
1395
+ pickPreset(preset) {
1396
+ if (!this.selfUserId) {
1397
+ this.errorKey.set('audience.error.self_user_required');
1398
+ return;
1399
+ }
1400
+ const anchor = this.selfUserId;
1401
+ this.addToActiveBucket((existing) => this.upsertPresetTerm(existing, preset, anchor));
1402
+ }
1403
+ // ── Term removal ─────────────────────────────────────────────────────────────
1404
+ removeTerm(target, index) {
1405
+ if (this.disabled())
1406
+ return;
1407
+ const list = target === 'includes' ? this.includes() : this.excludes();
1408
+ const next = list.filter((_, i) => i !== index);
1409
+ if (target === 'includes')
1410
+ this.includes.set(next);
1411
+ else
1412
+ this.excludes.set(next);
1413
+ this.emitState();
1414
+ }
1415
+ removeUserId(target, termIndex, userId) {
1416
+ this.editTermAt(target, termIndex, (term) => {
1417
+ if (term.kind !== 'users')
1418
+ return term;
1419
+ const next = term.userIds.filter((id) => id !== userId);
1420
+ return next.length === 0 ? null : { ...term, userIds: next };
1421
+ });
1422
+ }
1423
+ removeRoleKey(target, termIndex, roleKey) {
1424
+ this.editTermAt(target, termIndex, (term) => {
1425
+ if (term.kind !== 'roles')
1426
+ return term;
1427
+ const next = term.roleKeys.filter((k) => k !== roleKey);
1428
+ return next.length === 0 ? null : { ...term, roleKeys: next };
1429
+ });
1430
+ }
1431
+ removeOuId(target, termIndex, ouId) {
1432
+ this.editTermAt(target, termIndex, (term) => {
1433
+ if (term.kind !== 'ou')
1434
+ return term;
1435
+ const next = term.ouIds.filter((id) => id !== ouId);
1436
+ return next.length === 0 ? null : { ...term, ouIds: next };
1437
+ });
1438
+ }
1439
+ // ── Public read API ──────────────────────────────────────────────────────────
1440
+ /** Returns the current filter snapshot — useful for hosts that want to peek without subscribing. */
1441
+ snapshot() {
1442
+ return this.buildFilter();
1443
+ }
1444
+ // ── ControlValueAccessor implementation ──────────────────────────────────────
1445
+ /**
1446
+ * Called by the forms infrastructure to push a new value into the control. Mirrors the
1447
+ * `[value]` setter path — re-uses `applyValue` so template-driven and reactive callers
1448
+ * converge on the same hydration code.
1449
+ */
1450
+ writeValue(value) {
1451
+ this.applyValue(value, /* emitImmediate */ false);
1452
+ }
1453
+ registerOnChange(fn) {
1454
+ this.onChange = fn;
1455
+ }
1456
+ registerOnTouched(fn) {
1457
+ this.onTouched = fn;
1458
+ }
1459
+ setDisabledState(isDisabled) {
1460
+ this.disabled.set(isDisabled);
1461
+ if (isDisabled)
1462
+ this.closePicker();
1463
+ }
1464
+ // ── Validator implementation ─────────────────────────────────────────────────
1465
+ /**
1466
+ * Aligns Angular's reactive-forms validity with the component's own `validityChange`
1467
+ * output. Returns `audienceRequired` when includes is empty (the canonical "zero
1468
+ * recipients" guard) and `audienceTooManyTerms` when over the per-filter cap.
1469
+ * <br>
1470
+ * The output-event path (<see cref="emitState"/>) reuses this method so there is ONE
1471
+ * definition of "valid" — the classic "two almost-identical validators drifted" bug
1472
+ * cannot recur here.
1473
+ */
1474
+ validate(_) {
1475
+ return this.validateFilter(this.buildFilter());
1476
+ }
1477
+ validateFilter(candidate) {
1478
+ if (candidate.includes.length === 0)
1479
+ return { audienceRequired: true };
1480
+ const total = candidate.includes.length + (candidate.excludes?.length ?? 0);
1481
+ if (total > AUDIENCE_LIMITS.maxTermsPerFilter) {
1482
+ return { audienceTooManyTerms: { actual: total, max: AUDIENCE_LIMITS.maxTermsPerFilter } };
1483
+ }
1484
+ return null;
1485
+ }
1486
+ registerOnValidatorChange(fn) {
1487
+ this.onValidatorChange = fn;
1488
+ }
1489
+ // ── Term display helpers ─────────────────────────────────────────────────────
1490
+ ouLabel(ouId) {
1491
+ const match = this.ouTree().find((o) => o.id === ouId);
1492
+ return match?.displayName ?? `${ouId.substring(0, 8)}…`;
1493
+ }
1494
+ roleLabel(appId, roleKey) {
1495
+ const match = this.roleOus().find((r) => r.appId === appId && r.roleKey === roleKey);
1496
+ return match?.displayName ?? `${appId}:${roleKey}`;
1497
+ }
1498
+ appLabel(appId) {
1499
+ if (appId === '*')
1500
+ return this.i18n.t('audience.app_everyone_wildcard');
1501
+ const match = this.appEveryoneOptions.find((a) => a.appId === appId);
1502
+ return match?.displayName ?? appId;
1503
+ }
1504
+ chartLabel(chartId) {
1505
+ const match = this.chartOptions().find((c) => c.id === chartId);
1506
+ return match?.name ?? `${chartId.substring(0, 8)}…`;
1507
+ }
1508
+ /**
1509
+ * Locale key for a term kind. Replaces hyphens with underscores so the JSON file uses
1510
+ * one consistent naming convention (`audience.kind_app_everyone`, not the hyphen variant).
1511
+ */
1512
+ kindLocaleKey(kind) {
1513
+ return `audience.kind_${kind.replace(/-/g, '_')}`;
1514
+ }
1515
+ /** Same convention as {@link kindLocaleKey} but for preset names. */
1516
+ presetLocaleKey(preset) {
1517
+ return `audience.preset_${preset.replace(/-/g, '_')}`;
1518
+ }
1519
+ /** Picon icon CSS class for a term kind tab — kept here so the template stays declarative. */
1520
+ kindIconClass(kind) {
1521
+ switch (kind) {
1522
+ case 'users': return 'pi pi-user';
1523
+ case 'roles': return 'pi pi-id-card';
1524
+ case 'ou': return 'pi pi-sitemap';
1525
+ case 'app-everyone': return 'pi pi-globe';
1526
+ case 'chart': return 'pi pi-share-alt';
1527
+ case 'preset': return 'pi pi-star';
1528
+ }
1529
+ }
1530
+ // ── Internal helpers — upsert + state emission ───────────────────────────────
1531
+ addToActiveBucket(upsert) {
1532
+ if (this.disabled())
1533
+ return;
1534
+ const target = this.editTarget();
1535
+ const current = target === 'includes' ? this.includes() : this.excludes();
1536
+ const next = upsert(current);
1537
+ if (this.totalTermCount(next, target) > AUDIENCE_LIMITS.maxTermsPerFilter) {
1538
+ this.errorKey.set('audience.error.too_many_terms');
1539
+ return;
1540
+ }
1541
+ if (target === 'includes')
1542
+ this.includes.set(next);
1543
+ else
1544
+ this.excludes.set(next);
1545
+ this.errorKey.set(null);
1546
+ this.emitState();
1547
+ }
1548
+ editTermAt(target, index, fn) {
1549
+ if (this.disabled())
1550
+ return;
1551
+ const list = target === 'includes' ? this.includes() : this.excludes();
1552
+ const term = list[index];
1553
+ if (!term)
1554
+ return;
1555
+ const replaced = fn(term);
1556
+ const next = replaced === null
1557
+ ? list.filter((_, i) => i !== index)
1558
+ : list.map((t, i) => (i === index ? replaced : t));
1559
+ if (target === 'includes')
1560
+ this.includes.set(next);
1561
+ else
1562
+ this.excludes.set(next);
1563
+ this.emitState();
1564
+ }
1565
+ totalTermCount(replacement, target) {
1566
+ const inc = target === 'includes' ? replacement : this.includes();
1567
+ const exc = target === 'excludes' ? replacement : this.excludes();
1568
+ return inc.length + exc.length;
1569
+ }
1570
+ upsertUsersTerm(existing, userId) {
1571
+ const idx = existing.findIndex((t) => t.kind === 'users');
1572
+ if (idx < 0) {
1573
+ const term = { kind: 'users', userIds: [userId] };
1574
+ return [...existing, term];
1575
+ }
1576
+ const term = existing[idx];
1577
+ if (term.userIds.includes(userId))
1578
+ return existing; // idempotent
1579
+ if (term.userIds.length >= AUDIENCE_LIMITS.maxUserIdsPerTerm) {
1580
+ this.errorKey.set('audience.error.too_many_users_per_term');
1581
+ return existing;
1582
+ }
1583
+ return existing.map((t, i) => i === idx ? { ...term, userIds: [...term.userIds, userId] } : t);
1584
+ }
1585
+ upsertRolesTerm(existing, appId, roleKey) {
1586
+ const idx = existing.findIndex((t) => t.kind === 'roles' && t.appId === appId);
1587
+ if (idx < 0) {
1588
+ const term = { kind: 'roles', appId, roleKeys: [roleKey] };
1589
+ return [...existing, term];
1590
+ }
1591
+ const term = existing[idx];
1592
+ if (term.roleKeys.includes(roleKey))
1593
+ return existing;
1594
+ if (term.roleKeys.length >= AUDIENCE_LIMITS.maxRoleKeysPerTerm) {
1595
+ this.errorKey.set('audience.error.too_many_roles_per_term');
1596
+ return existing;
1597
+ }
1598
+ return existing.map((t, i) => i === idx ? { ...term, roleKeys: [...term.roleKeys, roleKey] } : t);
1599
+ }
1600
+ upsertOuTerm(existing, ouId, chartId, includeDescendants) {
1601
+ const idx = existing.findIndex((t) => t.kind === 'ou'
1602
+ && t.chartId === chartId
1603
+ && t.includeDescendants === includeDescendants);
1604
+ if (idx < 0) {
1605
+ const term = {
1606
+ kind: 'ou',
1607
+ ouIds: [ouId],
1608
+ chartId,
1609
+ includeDescendants,
1610
+ };
1611
+ return [...existing, term];
1612
+ }
1613
+ const term = existing[idx];
1614
+ if (term.ouIds.includes(ouId))
1615
+ return existing;
1616
+ if (term.ouIds.length >= AUDIENCE_LIMITS.maxOuIdsPerTerm) {
1617
+ this.errorKey.set('audience.error.too_many_ous_per_term');
1618
+ return existing;
1619
+ }
1620
+ return existing.map((t, i) => i === idx ? { ...term, ouIds: [...term.ouIds, ouId] } : t);
1621
+ }
1622
+ upsertAppEveryoneTerm(existing, appId) {
1623
+ const exists = existing.some((t) => t.kind === 'app-everyone' && t.appId === appId);
1624
+ if (exists)
1625
+ return existing;
1626
+ const term = { kind: 'app-everyone', appId };
1627
+ return [...existing, term];
1628
+ }
1629
+ upsertChartTerm(existing, chartId) {
1630
+ const exists = existing.some((t) => t.kind === 'chart' && t.chartId === chartId);
1631
+ if (exists)
1632
+ return existing;
1633
+ const term = { kind: 'chart', chartId };
1634
+ return [...existing, term];
1635
+ }
1636
+ upsertPresetTerm(existing, preset, anchorUserId) {
1637
+ const exists = existing.some((t) => t.kind === 'preset' && t.preset === preset && t.anchorUserId === anchorUserId);
1638
+ if (exists)
1639
+ return existing;
1640
+ const term = { kind: 'preset', preset, anchorUserId };
1641
+ return [...existing, term];
1642
+ }
1643
+ buildFilter() {
1644
+ const inc = this.includes();
1645
+ const exc = this.excludes();
1646
+ return {
1647
+ includes: inc,
1648
+ ...(exc.length > 0 ? { excludes: exc } : {}),
1649
+ ...(this.value?.options ? { options: this.value.options } : {}),
1650
+ };
1651
+ }
1652
+ emitState() {
1653
+ const filter = this.buildFilter();
1654
+ this.audienceChange.emit(filter);
1655
+ this.validityChange.emit(this.computeValidity(filter));
1656
+ // Forward to the forms infrastructure so reactive forms see the change.
1657
+ this.onChange(filter);
1658
+ this.onTouched();
1659
+ this.onValidatorChange();
1660
+ }
1661
+ /**
1662
+ * Emits ONLY the validity signal (component output + forms-validator hook). Used by
1663
+ * <see cref="ngOnChanges"/> when the host pushes a new value: the host already knows the
1664
+ * value it sent, so re-firing onChange / audienceChange is redundant and risks feedback
1665
+ * loops with two-way bindings; but the validity flag may genuinely have flipped and the
1666
+ * host needs that to enable / disable submit.
1667
+ */
1668
+ emitValidityOnly() {
1669
+ const filter = this.buildFilter();
1670
+ this.validityChange.emit(this.computeValidity(filter));
1671
+ this.onValidatorChange();
1672
+ }
1673
+ computeValidity(candidate) {
1674
+ // Single-source: delegate to validateFilter so the output flag and the form-validator
1675
+ // result agree by construction.
1676
+ return this.validateFilter(candidate) === null;
1677
+ }
1678
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: AudienceBuilderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1679
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: AudienceBuilderComponent, isStandalone: true, selector: "fly-audience-builder", inputs: { value: "value", showExcludes: "showExcludes", supportedKinds: "supportedKinds", selfUserId: "selfUserId", appEveryoneOptions: "appEveryoneOptions", searchUsers: "searchUsers", loadOuTree: "loadOuTree", loadChartOptions: "loadChartOptions", loadRoleOus: "loadRoleOus" }, outputs: { audienceChange: "audienceChange", validityChange: "validityChange" }, providers: [
1680
+ {
1681
+ provide: NG_VALUE_ACCESSOR,
1682
+ useExisting: forwardRef(() => AudienceBuilderComponent),
1683
+ multi: true,
1684
+ },
1685
+ {
1686
+ provide: NG_VALIDATORS,
1687
+ useExisting: forwardRef(() => AudienceBuilderComponent),
1688
+ multi: true,
1689
+ },
1690
+ ], usesOnChanges: true, ngImport: i0, template: "<div class=\"fab-root\">\r\n\r\n <!-- Includes section -->\r\n <div class=\"fab-bucket\">\r\n <div class=\"fab-bucket-header\">\r\n <div class=\"fab-section-label\" id=\"fab-includes-label\">{{ 'audience.includes' | translate }}</div>\r\n <button\r\n type=\"button\"\r\n class=\"fab-add-btn\"\r\n (click)=\"openPicker('includes')\"\r\n [attr.aria-expanded]=\"pickerOpen() && editTarget() === 'includes'\"\r\n [attr.aria-controls]=\"pickerOpen() && editTarget() === 'includes' ? 'fab-picker-region' : null\"\r\n >\r\n <i class=\"pi pi-plus\" aria-hidden=\"true\"></i>\r\n {{ 'audience.add_term' | translate }}\r\n </button>\r\n </div>\r\n\r\n @if (includes().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.includes_empty' | translate }}</div>\r\n } @else {\r\n <ul class=\"fab-term-list\" aria-labelledby=\"fab-includes-label\">\r\n @for (term of includes(); track term) {\r\n <li class=\"fab-term\">\r\n <ng-container\r\n *ngTemplateOutlet=\"termCard; context: { $implicit: term, target: 'includes', index: $index }\">\r\n </ng-container>\r\n </li>\r\n }\r\n </ul>\r\n }\r\n </div>\r\n\r\n <!-- Excludes section -->\r\n @if (showExcludes) {\r\n <div class=\"fab-bucket fab-bucket-exclude\">\r\n <div class=\"fab-bucket-header\">\r\n <div class=\"fab-section-label\" id=\"fab-excludes-label\">{{ 'audience.excludes' | translate }}</div>\r\n <button\r\n type=\"button\"\r\n class=\"fab-add-btn\"\r\n (click)=\"openPicker('excludes')\"\r\n [attr.aria-expanded]=\"pickerOpen() && editTarget() === 'excludes'\"\r\n [attr.aria-controls]=\"pickerOpen() && editTarget() === 'excludes' ? 'fab-picker-region' : null\"\r\n >\r\n <i class=\"pi pi-plus\" aria-hidden=\"true\"></i>\r\n {{ 'audience.add_term' | translate }}\r\n </button>\r\n </div>\r\n\r\n @if (excludes().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.excludes_empty' | translate }}</div>\r\n } @else {\r\n <ul class=\"fab-term-list\" aria-labelledby=\"fab-excludes-label\">\r\n @for (term of excludes(); track term) {\r\n <li class=\"fab-term\">\r\n <ng-container\r\n *ngTemplateOutlet=\"termCard; context: { $implicit: term, target: 'excludes', index: $index }\">\r\n </ng-container>\r\n </li>\r\n }\r\n </ul>\r\n }\r\n </div>\r\n }\r\n\r\n <!-- Inline picker (shared across includes/excludes; gated by pickerOpen) -->\r\n @if (pickerOpen()) {\r\n <div\r\n id=\"fab-picker-region\"\r\n class=\"fab-picker\"\r\n role=\"region\"\r\n [attr.aria-label]=\"(editTarget() === 'includes'\r\n ? ('audience.adding_to_includes' | translate)\r\n : ('audience.adding_to_excludes' | translate))\"\r\n >\r\n <div class=\"fab-picker-header\">\r\n <span class=\"fab-picker-target\">\r\n {{\r\n editTarget() === 'includes'\r\n ? ('audience.adding_to_includes' | translate)\r\n : ('audience.adding_to_excludes' | translate)\r\n }}\r\n </span>\r\n <button type=\"button\" class=\"fab-close\" (click)=\"closePicker()\" [attr.aria-label]=\"'audience.close' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n\r\n <!-- Kind tabs -->\r\n <div class=\"fab-kind-tabs\" role=\"tablist\">\r\n @for (kind of supportedKinds; track kind) {\r\n <button\r\n type=\"button\"\r\n role=\"tab\"\r\n class=\"fab-kind-tab\"\r\n [id]=\"'fab-tab-' + kind\"\r\n [class.active]=\"activeKind() === kind\"\r\n [attr.aria-selected]=\"activeKind() === kind\"\r\n [attr.aria-controls]=\"'fab-tabpanel-' + kind\"\r\n (click)=\"selectKind(kind)\"\r\n >\r\n <i [class]=\"kindIconClass(kind)\" aria-hidden=\"true\"></i>\r\n <span>{{ kindLocaleKey(kind) | translate }}</span>\r\n </button>\r\n }\r\n </div>\r\n\r\n @if (errorKey()) {\r\n <div class=\"fab-inline-error\" role=\"alert\">\r\n <i class=\"pi pi-exclamation-triangle\" aria-hidden=\"true\"></i>\r\n {{ errorKey()! | translate }}\r\n </div>\r\n }\r\n\r\n <!-- Per-kind picker bodies -->\r\n <div\r\n class=\"fab-picker-body\"\r\n role=\"tabpanel\"\r\n [id]=\"'fab-tabpanel-' + activeKind()\"\r\n [attr.aria-labelledby]=\"'fab-tab-' + activeKind()\"\r\n >\r\n @switch (activeKind()) {\r\n\r\n @case ('users') {\r\n <input\r\n type=\"text\"\r\n class=\"fab-input\"\r\n [ngModel]=\"userSearchQuery()\"\r\n (ngModelChange)=\"userSearchQuery.set($event); onUserSearchInput()\"\r\n [placeholder]=\"'audience.search_users_placeholder' | translate\"\r\n [attr.aria-label]=\"'audience.search_users_placeholder' | translate\"\r\n />\r\n @if (userSearchResults().length > 0) {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (u of userSearchResults(); track u.id) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickUser(u)\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ u.firstName }} {{ u.lastName }}</span>\r\n <span class=\"fab-result-secondary\">{{ u.email }}</span>\r\n </button>\r\n }\r\n </div>\r\n } @else if (userSearchQuery().length >= 2) {\r\n <div class=\"fab-empty\">{{ 'audience.no_users_match' | translate }}</div>\r\n }\r\n }\r\n\r\n @case ('roles') {\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <input\r\n type=\"text\"\r\n class=\"fab-input\"\r\n [ngModel]=\"roleSearchQuery()\"\r\n (ngModelChange)=\"roleSearchQuery.set($event)\"\r\n [placeholder]=\"'audience.search_roles_placeholder' | translate\"\r\n [attr.aria-label]=\"'audience.search_roles_placeholder' | translate\"\r\n />\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (role of filteredRoles(); track role.ouId) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickRole(role)\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span>{{ role.displayName }}</span>\r\n <span class=\"fab-result-secondary\">{{ role.appId }}</span>\r\n </button>\r\n }\r\n @if (filteredRoles().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.no_roles_match' | translate }}</div>\r\n }\r\n </div>\r\n } @else {\r\n <div class=\"fab-empty\">{{ 'audience.roles_unavailable' | translate }}</div>\r\n }\r\n }\r\n\r\n @case ('ou') {\r\n @if (chartOptions().length > 0) {\r\n <label for=\"fab-chart-select\" class=\"fab-mini-label\">\r\n {{ 'audience.org_chart' | translate }}\r\n </label>\r\n <select\r\n id=\"fab-chart-select\"\r\n class=\"fab-select\"\r\n [ngModel]=\"selectedChartId()\"\r\n (ngModelChange)=\"onChartChange($event)\"\r\n >\r\n @for (opt of chartOptions(); track $index) {\r\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\r\n }\r\n </select>\r\n }\r\n <label class=\"fab-checkbox-label\">\r\n <input\r\n type=\"checkbox\"\r\n [ngModel]=\"ouIncludeDescendants()\"\r\n (ngModelChange)=\"ouIncludeDescendants.set($event)\"\r\n />\r\n {{ 'audience.include_descendants' | translate }}\r\n </label>\r\n @if (ouTree().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.ou_tree_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list fab-ou-tree\" role=\"list\">\r\n @for (ou of ouTree(); track ou.id) {\r\n <button\r\n type=\"button\"\r\n role=\"listitem\"\r\n class=\"fab-result-item\"\r\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\r\n (click)=\"pickOu(ou)\"\r\n >\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span>{{ ou.displayName }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('app-everyone') {\r\n @if (appEveryoneOptions.length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.app_everyone_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (app of appEveryoneOptions; track app.appId) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickAppEveryone(app.appId)\">\r\n <i class=\"pi pi-globe\" aria-hidden=\"true\"></i>\r\n <span>{{ app.displayName }}</span>\r\n <span class=\"fab-result-secondary\">{{ app.appId }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('chart') {\r\n @if (chartTermOptions().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.chart_options_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (opt of chartTermOptions(); track opt.id) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickChart(opt.id)\">\r\n <i class=\"pi pi-share-alt\" aria-hidden=\"true\"></i>\r\n <span>{{ opt.name }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('preset') {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (p of presetOptions; track p) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickPreset(p)\">\r\n <i class=\"pi pi-star\" aria-hidden=\"true\"></i>\r\n <span>{{ presetLocaleKey(p) | translate }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n </div>\r\n </div>\r\n }\r\n\r\n</div>\r\n\r\n<!-- Reusable term card template, parameterised by target bucket + index for removal callbacks -->\r\n<ng-template #termCard let-term let-target=\"target\" let-index=\"index\">\r\n <div class=\"fab-term-card\" [class.fab-term-exclude]=\"target === 'excludes'\">\r\n\r\n @switch (term.kind) {\r\n @case ('users') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_users' | translate: { count: term.userIds.length } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (id of term.userIds; track id) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ id.substring(0, 8) }}\u2026</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeUserId(target, index, id)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('roles') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_roles' | translate: { app: appLabel(term.appId), count: term.roleKeys.length } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (k of term.roleKeys; track k) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span>{{ roleLabel(term.appId, k) }}</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeRoleKey(target, index, k)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('ou') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_ous' | translate: { count: term.ouIds.length } }}\r\n @if (term.includeDescendants) {\r\n <span class=\"fab-badge\">{{ 'audience.descendants_badge' | translate }}</span>\r\n }\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (id of term.ouIds; track id) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span>{{ ouLabel(id) }}</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeOuId(target, index, id)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('app-everyone') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-globe\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_app_everyone' | translate: { app: appLabel(term.appId) } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n\r\n @case ('chart') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-share-alt\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_chart' | translate: { chart: chartLabel(term.chartId) } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n\r\n @case ('preset') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-star\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ presetLocaleKey(term.preset) | translate }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n }\r\n </div>\r\n</ng-template>\r\n", styles: [".fab-root{display:flex;flex-direction:column;gap:14px;width:100%}.fab-bucket{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:10px;padding:12px 14px}.fab-bucket.fab-bucket-exclude{border-inline-start:3px solid rgba(255,59,48,.55)}.fab-bucket-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.fab-section-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text-color-secondary)}.fab-add-btn{display:inline-flex;align-items:center;gap:6px;background:var(--primary-color, #e8732a);color:#fff;border:none;border-radius:6px;padding:5px 10px;font-size:12px;cursor:pointer}.fab-add-btn:hover{filter:brightness(1.05)}.fab-add-btn i{font-size:11px}.fab-empty{text-align:center;padding:12px;color:var(--text-color-secondary);font-size:12px;font-style:italic}.fab-term-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}.fab-term-card{background:#ffffff0a;border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:8px 10px}.fab-term-card.fab-term-exclude{background:#ff3b300d;border-color:#ff3b302e}.fab-term-head{display:flex;align-items:center;gap:8px}.fab-term-head i:first-child{color:var(--text-color-secondary)}.fab-term-title{flex:1;font-size:13px;display:inline-flex;align-items:center;gap:6px}.fab-badge{font-size:10px;background:#ffffff1a;color:var(--text-color-secondary);padding:2px 6px;border-radius:8px;text-transform:uppercase;letter-spacing:.5px}.fab-term-chips{margin-top:6px;display:flex;flex-wrap:wrap;gap:4px}.fab-chip{display:inline-flex;align-items:center;gap:4px;background:#ffffff0f;border-radius:12px;padding:2px 4px 2px 8px;font-size:11px}.fab-chip i{font-size:10px;color:var(--text-color-secondary)}.fab-chip-x{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;padding:2px 4px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center}.fab-chip-x i{font-size:9px}.fab-chip-x:hover{background:#ff3b302e;color:#ff3b30}.fab-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:11px}.fab-icon-btn:hover{background:#ffffff24}.fab-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fab-picker{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:10px;box-shadow:0 6px 18px #0000002e}.fab-picker-header{display:flex;align-items:center;justify-content:space-between}.fab-picker-target{font-size:12px;color:var(--text-color-secondary)}.fab-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:14px;padding:4px}.fab-close:hover{color:var(--text-color)}.fab-kind-tabs{display:flex;flex-wrap:wrap;gap:4px;border-bottom:1px solid var(--surface-border);padding-bottom:6px}.fab-kind-tab{background:transparent;border:1px solid transparent;border-radius:6px;padding:6px 10px;font-size:12px;color:var(--text-color-secondary);cursor:pointer;display:inline-flex;align-items:center;gap:6px}.fab-kind-tab i{font-size:11px}.fab-kind-tab:hover{background:var(--surface-hover);color:var(--text-color)}.fab-kind-tab.active{background:#e8732a1f;border-color:var(--primary-color, #e8732a);color:var(--text-color)}.fab-picker-body{display:flex;flex-direction:column;gap:8px}.fab-input,.fab-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fab-input:focus,.fab-select:focus{border-color:var(--primary-color, #e8732a)}.fab-mini-label{font-size:11px;color:var(--text-color-secondary)}.fab-result-list{max-height:200px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fab-result-list.fab-ou-tree{max-height:240px}button.fab-result-item{width:100%;background:transparent;border:none;color:inherit;font:inherit;text-align:start;appearance:none}.fab-result-item{display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:13px}.fab-result-item:hover{background:var(--surface-hover)}.fab-result-item:focus-visible{outline:2px solid var(--primary-color, #e8732a);outline-offset:-2px}.fab-result-item i{color:var(--text-color-secondary)}.fab-result-secondary{color:var(--text-color-secondary);font-size:11px;margin-inline-start:auto;text-transform:uppercase;letter-spacing:.4px}.fab-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fab-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fab-inline-error{display:flex;align-items:center;gap:6px;background:#ff3b301a;color:#ff8a80;border-radius:6px;padding:6px 10px;font-size:12px}.fab-inline-error i{font-size:12px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "pipe", type: TranslatePipe, name: "translate" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1691
+ }
1692
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: AudienceBuilderComponent, decorators: [{
1693
+ type: Component,
1694
+ args: [{ selector: 'fly-audience-builder', standalone: true, imports: [CommonModule, FormsModule, TranslatePipe], changeDetection: ChangeDetectionStrategy.OnPush, providers: [
1695
+ {
1696
+ provide: NG_VALUE_ACCESSOR,
1697
+ useExisting: forwardRef(() => AudienceBuilderComponent),
1698
+ multi: true,
1699
+ },
1700
+ {
1701
+ provide: NG_VALIDATORS,
1702
+ useExisting: forwardRef(() => AudienceBuilderComponent),
1703
+ multi: true,
1704
+ },
1705
+ ], template: "<div class=\"fab-root\">\r\n\r\n <!-- Includes section -->\r\n <div class=\"fab-bucket\">\r\n <div class=\"fab-bucket-header\">\r\n <div class=\"fab-section-label\" id=\"fab-includes-label\">{{ 'audience.includes' | translate }}</div>\r\n <button\r\n type=\"button\"\r\n class=\"fab-add-btn\"\r\n (click)=\"openPicker('includes')\"\r\n [attr.aria-expanded]=\"pickerOpen() && editTarget() === 'includes'\"\r\n [attr.aria-controls]=\"pickerOpen() && editTarget() === 'includes' ? 'fab-picker-region' : null\"\r\n >\r\n <i class=\"pi pi-plus\" aria-hidden=\"true\"></i>\r\n {{ 'audience.add_term' | translate }}\r\n </button>\r\n </div>\r\n\r\n @if (includes().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.includes_empty' | translate }}</div>\r\n } @else {\r\n <ul class=\"fab-term-list\" aria-labelledby=\"fab-includes-label\">\r\n @for (term of includes(); track term) {\r\n <li class=\"fab-term\">\r\n <ng-container\r\n *ngTemplateOutlet=\"termCard; context: { $implicit: term, target: 'includes', index: $index }\">\r\n </ng-container>\r\n </li>\r\n }\r\n </ul>\r\n }\r\n </div>\r\n\r\n <!-- Excludes section -->\r\n @if (showExcludes) {\r\n <div class=\"fab-bucket fab-bucket-exclude\">\r\n <div class=\"fab-bucket-header\">\r\n <div class=\"fab-section-label\" id=\"fab-excludes-label\">{{ 'audience.excludes' | translate }}</div>\r\n <button\r\n type=\"button\"\r\n class=\"fab-add-btn\"\r\n (click)=\"openPicker('excludes')\"\r\n [attr.aria-expanded]=\"pickerOpen() && editTarget() === 'excludes'\"\r\n [attr.aria-controls]=\"pickerOpen() && editTarget() === 'excludes' ? 'fab-picker-region' : null\"\r\n >\r\n <i class=\"pi pi-plus\" aria-hidden=\"true\"></i>\r\n {{ 'audience.add_term' | translate }}\r\n </button>\r\n </div>\r\n\r\n @if (excludes().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.excludes_empty' | translate }}</div>\r\n } @else {\r\n <ul class=\"fab-term-list\" aria-labelledby=\"fab-excludes-label\">\r\n @for (term of excludes(); track term) {\r\n <li class=\"fab-term\">\r\n <ng-container\r\n *ngTemplateOutlet=\"termCard; context: { $implicit: term, target: 'excludes', index: $index }\">\r\n </ng-container>\r\n </li>\r\n }\r\n </ul>\r\n }\r\n </div>\r\n }\r\n\r\n <!-- Inline picker (shared across includes/excludes; gated by pickerOpen) -->\r\n @if (pickerOpen()) {\r\n <div\r\n id=\"fab-picker-region\"\r\n class=\"fab-picker\"\r\n role=\"region\"\r\n [attr.aria-label]=\"(editTarget() === 'includes'\r\n ? ('audience.adding_to_includes' | translate)\r\n : ('audience.adding_to_excludes' | translate))\"\r\n >\r\n <div class=\"fab-picker-header\">\r\n <span class=\"fab-picker-target\">\r\n {{\r\n editTarget() === 'includes'\r\n ? ('audience.adding_to_includes' | translate)\r\n : ('audience.adding_to_excludes' | translate)\r\n }}\r\n </span>\r\n <button type=\"button\" class=\"fab-close\" (click)=\"closePicker()\" [attr.aria-label]=\"'audience.close' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n\r\n <!-- Kind tabs -->\r\n <div class=\"fab-kind-tabs\" role=\"tablist\">\r\n @for (kind of supportedKinds; track kind) {\r\n <button\r\n type=\"button\"\r\n role=\"tab\"\r\n class=\"fab-kind-tab\"\r\n [id]=\"'fab-tab-' + kind\"\r\n [class.active]=\"activeKind() === kind\"\r\n [attr.aria-selected]=\"activeKind() === kind\"\r\n [attr.aria-controls]=\"'fab-tabpanel-' + kind\"\r\n (click)=\"selectKind(kind)\"\r\n >\r\n <i [class]=\"kindIconClass(kind)\" aria-hidden=\"true\"></i>\r\n <span>{{ kindLocaleKey(kind) | translate }}</span>\r\n </button>\r\n }\r\n </div>\r\n\r\n @if (errorKey()) {\r\n <div class=\"fab-inline-error\" role=\"alert\">\r\n <i class=\"pi pi-exclamation-triangle\" aria-hidden=\"true\"></i>\r\n {{ errorKey()! | translate }}\r\n </div>\r\n }\r\n\r\n <!-- Per-kind picker bodies -->\r\n <div\r\n class=\"fab-picker-body\"\r\n role=\"tabpanel\"\r\n [id]=\"'fab-tabpanel-' + activeKind()\"\r\n [attr.aria-labelledby]=\"'fab-tab-' + activeKind()\"\r\n >\r\n @switch (activeKind()) {\r\n\r\n @case ('users') {\r\n <input\r\n type=\"text\"\r\n class=\"fab-input\"\r\n [ngModel]=\"userSearchQuery()\"\r\n (ngModelChange)=\"userSearchQuery.set($event); onUserSearchInput()\"\r\n [placeholder]=\"'audience.search_users_placeholder' | translate\"\r\n [attr.aria-label]=\"'audience.search_users_placeholder' | translate\"\r\n />\r\n @if (userSearchResults().length > 0) {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (u of userSearchResults(); track u.id) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickUser(u)\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ u.firstName }} {{ u.lastName }}</span>\r\n <span class=\"fab-result-secondary\">{{ u.email }}</span>\r\n </button>\r\n }\r\n </div>\r\n } @else if (userSearchQuery().length >= 2) {\r\n <div class=\"fab-empty\">{{ 'audience.no_users_match' | translate }}</div>\r\n }\r\n }\r\n\r\n @case ('roles') {\r\n @if (loadRoleOus && roleOus().length > 0) {\r\n <input\r\n type=\"text\"\r\n class=\"fab-input\"\r\n [ngModel]=\"roleSearchQuery()\"\r\n (ngModelChange)=\"roleSearchQuery.set($event)\"\r\n [placeholder]=\"'audience.search_roles_placeholder' | translate\"\r\n [attr.aria-label]=\"'audience.search_roles_placeholder' | translate\"\r\n />\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (role of filteredRoles(); track role.ouId) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickRole(role)\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span>{{ role.displayName }}</span>\r\n <span class=\"fab-result-secondary\">{{ role.appId }}</span>\r\n </button>\r\n }\r\n @if (filteredRoles().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.no_roles_match' | translate }}</div>\r\n }\r\n </div>\r\n } @else {\r\n <div class=\"fab-empty\">{{ 'audience.roles_unavailable' | translate }}</div>\r\n }\r\n }\r\n\r\n @case ('ou') {\r\n @if (chartOptions().length > 0) {\r\n <label for=\"fab-chart-select\" class=\"fab-mini-label\">\r\n {{ 'audience.org_chart' | translate }}\r\n </label>\r\n <select\r\n id=\"fab-chart-select\"\r\n class=\"fab-select\"\r\n [ngModel]=\"selectedChartId()\"\r\n (ngModelChange)=\"onChartChange($event)\"\r\n >\r\n @for (opt of chartOptions(); track $index) {\r\n <option [ngValue]=\"opt.id\">{{ opt.name }}</option>\r\n }\r\n </select>\r\n }\r\n <label class=\"fab-checkbox-label\">\r\n <input\r\n type=\"checkbox\"\r\n [ngModel]=\"ouIncludeDescendants()\"\r\n (ngModelChange)=\"ouIncludeDescendants.set($event)\"\r\n />\r\n {{ 'audience.include_descendants' | translate }}\r\n </label>\r\n @if (ouTree().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.ou_tree_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list fab-ou-tree\" role=\"list\">\r\n @for (ou of ouTree(); track ou.id) {\r\n <button\r\n type=\"button\"\r\n role=\"listitem\"\r\n class=\"fab-result-item\"\r\n [style.padding-inline-start.px]=\"ouIndentStartPx(ou)\"\r\n (click)=\"pickOu(ou)\"\r\n >\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span>{{ ou.displayName }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('app-everyone') {\r\n @if (appEveryoneOptions.length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.app_everyone_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (app of appEveryoneOptions; track app.appId) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickAppEveryone(app.appId)\">\r\n <i class=\"pi pi-globe\" aria-hidden=\"true\"></i>\r\n <span>{{ app.displayName }}</span>\r\n <span class=\"fab-result-secondary\">{{ app.appId }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('chart') {\r\n @if (chartTermOptions().length === 0) {\r\n <div class=\"fab-empty\">{{ 'audience.chart_options_empty' | translate }}</div>\r\n } @else {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (opt of chartTermOptions(); track opt.id) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickChart(opt.id)\">\r\n <i class=\"pi pi-share-alt\" aria-hidden=\"true\"></i>\r\n <span>{{ opt.name }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n\r\n @case ('preset') {\r\n <div class=\"fab-result-list\" role=\"list\">\r\n @for (p of presetOptions; track p) {\r\n <button type=\"button\" role=\"listitem\" class=\"fab-result-item\" (click)=\"pickPreset(p)\">\r\n <i class=\"pi pi-star\" aria-hidden=\"true\"></i>\r\n <span>{{ presetLocaleKey(p) | translate }}</span>\r\n </button>\r\n }\r\n </div>\r\n }\r\n }\r\n </div>\r\n </div>\r\n }\r\n\r\n</div>\r\n\r\n<!-- Reusable term card template, parameterised by target bucket + index for removal callbacks -->\r\n<ng-template #termCard let-term let-target=\"target\" let-index=\"index\">\r\n <div class=\"fab-term-card\" [class.fab-term-exclude]=\"target === 'excludes'\">\r\n\r\n @switch (term.kind) {\r\n @case ('users') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_users' | translate: { count: term.userIds.length } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (id of term.userIds; track id) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-user\" aria-hidden=\"true\"></i>\r\n <span>{{ id.substring(0, 8) }}\u2026</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeUserId(target, index, id)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('roles') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_roles' | translate: { app: appLabel(term.appId), count: term.roleKeys.length } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (k of term.roleKeys; track k) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-id-card\" aria-hidden=\"true\"></i>\r\n <span>{{ roleLabel(term.appId, k) }}</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeRoleKey(target, index, k)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('ou') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_ous' | translate: { count: term.ouIds.length } }}\r\n @if (term.includeDescendants) {\r\n <span class=\"fab-badge\">{{ 'audience.descendants_badge' | translate }}</span>\r\n }\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n <div class=\"fab-term-chips\">\r\n @for (id of term.ouIds; track id) {\r\n <span class=\"fab-chip\">\r\n <i class=\"pi pi-sitemap\" aria-hidden=\"true\"></i>\r\n <span>{{ ouLabel(id) }}</span>\r\n <button type=\"button\" class=\"fab-chip-x\" (click)=\"removeOuId(target, index, id)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </span>\r\n }\r\n </div>\r\n }\r\n\r\n @case ('app-everyone') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-globe\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_app_everyone' | translate: { app: appLabel(term.appId) } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n\r\n @case ('chart') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-share-alt\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ 'audience.term_chart' | translate: { chart: chartLabel(term.chartId) } }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n\r\n @case ('preset') {\r\n <div class=\"fab-term-head\">\r\n <i class=\"pi pi-star\" aria-hidden=\"true\"></i>\r\n <span class=\"fab-term-title\">\r\n {{ presetLocaleKey(term.preset) | translate }}\r\n </span>\r\n <button type=\"button\" class=\"fab-icon-btn danger\" (click)=\"removeTerm(target, index)\"\r\n [attr.aria-label]=\"'audience.remove_term' | translate\">\r\n <i class=\"pi pi-times\" aria-hidden=\"true\"></i>\r\n </button>\r\n </div>\r\n }\r\n }\r\n </div>\r\n</ng-template>\r\n", styles: [".fab-root{display:flex;flex-direction:column;gap:14px;width:100%}.fab-bucket{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:10px;padding:12px 14px}.fab-bucket.fab-bucket-exclude{border-inline-start:3px solid rgba(255,59,48,.55)}.fab-bucket-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}.fab-section-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;color:var(--text-color-secondary)}.fab-add-btn{display:inline-flex;align-items:center;gap:6px;background:var(--primary-color, #e8732a);color:#fff;border:none;border-radius:6px;padding:5px 10px;font-size:12px;cursor:pointer}.fab-add-btn:hover{filter:brightness(1.05)}.fab-add-btn i{font-size:11px}.fab-empty{text-align:center;padding:12px;color:var(--text-color-secondary);font-size:12px;font-style:italic}.fab-term-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}.fab-term-card{background:#ffffff0a;border:1px solid rgba(255,255,255,.08);border-radius:8px;padding:8px 10px}.fab-term-card.fab-term-exclude{background:#ff3b300d;border-color:#ff3b302e}.fab-term-head{display:flex;align-items:center;gap:8px}.fab-term-head i:first-child{color:var(--text-color-secondary)}.fab-term-title{flex:1;font-size:13px;display:inline-flex;align-items:center;gap:6px}.fab-badge{font-size:10px;background:#ffffff1a;color:var(--text-color-secondary);padding:2px 6px;border-radius:8px;text-transform:uppercase;letter-spacing:.5px}.fab-term-chips{margin-top:6px;display:flex;flex-wrap:wrap;gap:4px}.fab-chip{display:inline-flex;align-items:center;gap:4px;background:#ffffff0f;border-radius:12px;padding:2px 4px 2px 8px;font-size:11px}.fab-chip i{font-size:10px;color:var(--text-color-secondary)}.fab-chip-x{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;padding:2px 4px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center}.fab-chip-x i{font-size:9px}.fab-chip-x:hover{background:#ff3b302e;color:#ff3b30}.fab-icon-btn{background:#ffffff0f;border:none;border-radius:6px;width:24px;height:24px;display:inline-flex;align-items:center;justify-content:center;color:inherit;cursor:pointer;font-size:11px}.fab-icon-btn:hover{background:#ffffff24}.fab-icon-btn.danger:hover{background:#ff3b3033;color:#ff3b30}.fab-picker{background:var(--surface-card, #1e1e1e);border:1px solid var(--surface-border);border-radius:10px;padding:12px 14px;display:flex;flex-direction:column;gap:10px;box-shadow:0 6px 18px #0000002e}.fab-picker-header{display:flex;align-items:center;justify-content:space-between}.fab-picker-target{font-size:12px;color:var(--text-color-secondary)}.fab-close{background:none;border:none;color:var(--text-color-secondary);cursor:pointer;font-size:14px;padding:4px}.fab-close:hover{color:var(--text-color)}.fab-kind-tabs{display:flex;flex-wrap:wrap;gap:4px;border-bottom:1px solid var(--surface-border);padding-bottom:6px}.fab-kind-tab{background:transparent;border:1px solid transparent;border-radius:6px;padding:6px 10px;font-size:12px;color:var(--text-color-secondary);cursor:pointer;display:inline-flex;align-items:center;gap:6px}.fab-kind-tab i{font-size:11px}.fab-kind-tab:hover{background:var(--surface-hover);color:var(--text-color)}.fab-kind-tab.active{background:#e8732a1f;border-color:var(--primary-color, #e8732a);color:var(--text-color)}.fab-picker-body{display:flex;flex-direction:column;gap:8px}.fab-input,.fab-select{background:#ffffff14;border:1px solid rgba(255,255,255,.15);border-radius:8px;padding:8px 12px;color:inherit;font-size:13px;outline:none}.fab-input:focus,.fab-select:focus{border-color:var(--primary-color, #e8732a)}.fab-mini-label{font-size:11px;color:var(--text-color-secondary)}.fab-result-list{max-height:200px;overflow-y:auto;border:1px solid var(--surface-border);border-radius:8px}.fab-result-list.fab-ou-tree{max-height:240px}button.fab-result-item{width:100%;background:transparent;border:none;color:inherit;font:inherit;text-align:start;appearance:none}.fab-result-item{display:flex;align-items:center;gap:8px;padding:6px 12px;cursor:pointer;font-size:13px}.fab-result-item:hover{background:var(--surface-hover)}.fab-result-item:focus-visible{outline:2px solid var(--primary-color, #e8732a);outline-offset:-2px}.fab-result-item i{color:var(--text-color-secondary)}.fab-result-secondary{color:var(--text-color-secondary);font-size:11px;margin-inline-start:auto;text-transform:uppercase;letter-spacing:.4px}.fab-checkbox-label{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text-color-secondary);cursor:pointer}.fab-checkbox-label input{accent-color:var(--primary-color, #e8732a)}.fab-inline-error{display:flex;align-items:center;gap:6px;background:#ff3b301a;color:#ff8a80;border-radius:6px;padding:6px 10px;font-size:12px}.fab-inline-error i{font-size:12px}\n"] }]
1706
+ }], propDecorators: { value: [{
1707
+ type: Input
1708
+ }], showExcludes: [{
1709
+ type: Input
1710
+ }], supportedKinds: [{
1711
+ type: Input
1712
+ }], selfUserId: [{
1713
+ type: Input
1714
+ }], appEveryoneOptions: [{
1715
+ type: Input
1716
+ }], searchUsers: [{
1717
+ type: Input,
1718
+ args: [{ required: true }]
1719
+ }], loadOuTree: [{
1720
+ type: Input,
1721
+ args: [{ required: true }]
1722
+ }], loadChartOptions: [{
1723
+ type: Input,
1724
+ args: [{ required: true }]
1725
+ }], loadRoleOus: [{
1726
+ type: Input
1727
+ }], audienceChange: [{
1728
+ type: Output
1729
+ }], validityChange: [{
1730
+ type: Output
1731
+ }] } });
1732
+
969
1733
  /** Full-bleed loading overlay for window content (shell) or embedded hosts. */
970
1734
  class FlyBlockUiComponent {
971
1735
  /** When false, the overlay is not rendered (host may use @if instead). */
@@ -1312,6 +2076,8 @@ class FlyFileUploadComponent {
1312
2076
  sourceApp = input('unknown', ...(ngDevMode ? [{ debugName: "sourceApp" }] : /* istanbul ignore next */ []));
1313
2077
  sourceEntityType = input(null, ...(ngDevMode ? [{ debugName: "sourceEntityType" }] : /* istanbul ignore next */ []));
1314
2078
  sourceEntityId = input(null, ...(ngDevMode ? [{ debugName: "sourceEntityId" }] : /* istanbul ignore next */ []));
2079
+ /** Optional: provide file IDs to auto-fetch metadata for pre-existing files (edit mode). */
2080
+ existingFileIds = input(null, ...(ngDevMode ? [{ debugName: "existingFileIds" }] : /* istanbul ignore next */ []));
1315
2081
  /** Two-way model for completed file metadata. */
1316
2082
  files = model([], ...(ngDevMode ? [{ debugName: "files" }] : /* istanbul ignore next */ []));
1317
2083
  // ── Outputs ──
@@ -1323,6 +2089,24 @@ class FlyFileUploadComponent {
1323
2089
  /** Pre-existing files loaded from the entity (before any new uploads this session). */
1324
2090
  existingFiles = signal([], ...(ngDevMode ? [{ debugName: "existingFiles" }] : /* istanbul ignore next */ []));
1325
2091
  fileInput = viewChild('fileInput', ...(ngDevMode ? [{ debugName: "fileInput" }] : /* istanbul ignore next */ []));
2092
+ constructor() {
2093
+ // Auto-fetch metadata for existing file IDs (edit mode)
2094
+ effect((onCleanup) => {
2095
+ const ids = this.existingFileIds();
2096
+ if (!ids?.length)
2097
+ return;
2098
+ const subs = ids.map(id => this.http.get(`/api/files/${id}`).subscribe({
2099
+ next: (res) => {
2100
+ const info = res.data ?? res;
2101
+ if (info?.id) {
2102
+ this.existingFiles.update(list => list.some(f => f.id === info.id) ? list : [...list, info]);
2103
+ this.files.update(list => list.some(f => f.id === info.id) ? list : [...list, info]);
2104
+ }
2105
+ },
2106
+ }));
2107
+ onCleanup(() => subs.forEach(s => s.unsubscribe()));
2108
+ });
2109
+ }
1326
2110
  allSlots = computed(() => this.slots(), ...(ngDevMode ? [{ debugName: "allSlots" }] : /* istanbul ignore next */ []));
1327
2111
  canAddMore = computed(() => {
1328
2112
  const current = this.files().length + this.slots().filter(s => s.status === 'uploading').length;
@@ -1431,7 +2215,7 @@ class FlyFileUploadComponent {
1431
2215
  });
1432
2216
  }
1433
2217
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.5", ngImport: i0, type: FlyFileUploadComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1434
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: FlyFileUploadComponent, isStandalone: true, selector: "fly-file-upload", inputs: { maxFiles: { classPropertyName: "maxFiles", publicName: "maxFiles", isSignal: true, isRequired: false, transformFunction: null }, maxFileSizeBytes: { classPropertyName: "maxFileSizeBytes", publicName: "maxFileSizeBytes", isSignal: true, isRequired: false, transformFunction: null }, accept: { classPropertyName: "accept", publicName: "accept", isSignal: true, isRequired: false, transformFunction: null }, sourceApp: { classPropertyName: "sourceApp", publicName: "sourceApp", isSignal: true, isRequired: false, transformFunction: null }, sourceEntityType: { classPropertyName: "sourceEntityType", publicName: "sourceEntityType", isSignal: true, isRequired: false, transformFunction: null }, sourceEntityId: { classPropertyName: "sourceEntityId", publicName: "sourceEntityId", isSignal: true, isRequired: false, transformFunction: null }, files: { classPropertyName: "files", publicName: "files", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { files: "filesChange", filesChanged: "filesChanged" }, viewQueries: [{ propertyName: "fileInput", first: true, predicate: ["fileInput"], descendants: true, isSignal: true }], ngImport: i0, template: `
2218
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.5", type: FlyFileUploadComponent, isStandalone: true, selector: "fly-file-upload", inputs: { maxFiles: { classPropertyName: "maxFiles", publicName: "maxFiles", isSignal: true, isRequired: false, transformFunction: null }, maxFileSizeBytes: { classPropertyName: "maxFileSizeBytes", publicName: "maxFileSizeBytes", isSignal: true, isRequired: false, transformFunction: null }, accept: { classPropertyName: "accept", publicName: "accept", isSignal: true, isRequired: false, transformFunction: null }, sourceApp: { classPropertyName: "sourceApp", publicName: "sourceApp", isSignal: true, isRequired: false, transformFunction: null }, sourceEntityType: { classPropertyName: "sourceEntityType", publicName: "sourceEntityType", isSignal: true, isRequired: false, transformFunction: null }, sourceEntityId: { classPropertyName: "sourceEntityId", publicName: "sourceEntityId", isSignal: true, isRequired: false, transformFunction: null }, existingFileIds: { classPropertyName: "existingFileIds", publicName: "existingFileIds", isSignal: true, isRequired: false, transformFunction: null }, files: { classPropertyName: "files", publicName: "files", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { files: "filesChange", filesChanged: "filesChanged" }, viewQueries: [{ propertyName: "fileInput", first: true, predicate: ["fileInput"], descendants: true, isSignal: true }], ngImport: i0, template: `
1435
2219
  <div class="fly-file-upload">
1436
2220
  @if (canAddMore()) {
1437
2221
  <div class="fly-file-upload__dropzone"
@@ -1574,7 +2358,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImpor
1574
2358
  <input #fileInput type="file" [accept]="accept()" multiple class="fly-file-upload__hidden" (change)="onFilesSelected($event)" />
1575
2359
  </div>
1576
2360
  `, styles: [".fly-file-upload{display:flex;flex-direction:column;gap:8px}.fly-file-upload__hidden{display:none}.fly-file-upload__dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:20px;border:2px dashed var(--separator-primary, #e5e7eb);border-radius:10px;background:var(--fill-quaternary, #f9fafb);cursor:pointer;transition:border-color .15s,background .15s}.fly-file-upload__dropzone:hover,.fly-file-upload__dropzone--drag{border-color:var(--accent-primary, #0071e3);background:var(--fill-tertiary, #f3f4f6)}.fly-file-upload__icon{font-size:24px;color:var(--label-tertiary, #9ca3af)}.fly-file-upload__label{font-size:13px;color:var(--label-secondary, #6b7280)}.fly-file-upload__hint{font-size:11px;color:var(--label-tertiary, #9ca3af)}.fly-file-upload__error{font-size:12px;color:var(--system-red, #ef4444);padding:4px 0}.fly-file-upload__list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}.fly-file-upload__item{display:flex;align-items:center;gap:10px;padding:8px 12px;border:1px solid var(--separator-primary, #e5e7eb);border-radius:8px;background:var(--fill-quaternary, #f9fafb);font-size:13px}.fly-file-upload__file-icon{font-size:18px;color:var(--label-tertiary, #9ca3af);flex-shrink:0}.fly-file-upload__file-info{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.fly-file-upload__file-name{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--label-primary, #1f2937)}.fly-file-upload__file-size{font-size:11px;color:var(--label-tertiary, #9ca3af)}.fly-file-upload__progress-track{width:60px;height:4px;border-radius:2px;background:var(--fill-tertiary, #e5e7eb);overflow:hidden}.fly-file-upload__progress-fill{height:100%;background:var(--accent-primary, #0071e3);border-radius:2px;transition:width .2s}.fly-file-upload__file-ok{color:var(--system-green, #22c55e);font-size:14px}.fly-file-upload__file-error{color:var(--system-red, #ef4444);font-size:14px}.fly-file-upload__remove-btn{background:none;border:none;cursor:pointer;color:var(--label-tertiary, #9ca3af);width:24px;height:24px;display:flex;align-items:center;justify-content:center;border-radius:4px;transition:color .15s,background .15s;flex-shrink:0}.fly-file-upload__remove-btn:hover{color:var(--system-red, #ef4444);background:var(--fill-tertiary, #f3f4f6)}\n"] }]
1577
- }], propDecorators: { maxFiles: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxFiles", required: false }] }], maxFileSizeBytes: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxFileSizeBytes", required: false }] }], accept: [{ type: i0.Input, args: [{ isSignal: true, alias: "accept", required: false }] }], sourceApp: [{ type: i0.Input, args: [{ isSignal: true, alias: "sourceApp", required: false }] }], sourceEntityType: [{ type: i0.Input, args: [{ isSignal: true, alias: "sourceEntityType", required: false }] }], sourceEntityId: [{ type: i0.Input, args: [{ isSignal: true, alias: "sourceEntityId", required: false }] }], files: [{ type: i0.Input, args: [{ isSignal: true, alias: "files", required: false }] }, { type: i0.Output, args: ["filesChange"] }], filesChanged: [{ type: i0.Output, args: ["filesChanged"] }], fileInput: [{ type: i0.ViewChild, args: ['fileInput', { isSignal: true }] }] } });
2361
+ }], ctorParameters: () => [], propDecorators: { maxFiles: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxFiles", required: false }] }], maxFileSizeBytes: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxFileSizeBytes", required: false }] }], accept: [{ type: i0.Input, args: [{ isSignal: true, alias: "accept", required: false }] }], sourceApp: [{ type: i0.Input, args: [{ isSignal: true, alias: "sourceApp", required: false }] }], sourceEntityType: [{ type: i0.Input, args: [{ isSignal: true, alias: "sourceEntityType", required: false }] }], sourceEntityId: [{ type: i0.Input, args: [{ isSignal: true, alias: "sourceEntityId", required: false }] }], existingFileIds: [{ type: i0.Input, args: [{ isSignal: true, alias: "existingFileIds", required: false }] }], files: [{ type: i0.Input, args: [{ isSignal: true, alias: "files", required: false }] }, { type: i0.Output, args: ["filesChange"] }], filesChanged: [{ type: i0.Output, args: ["filesChanged"] }], fileInput: [{ type: i0.ViewChild, args: ['fileInput', { isSignal: true }] }] } });
1578
2362
 
1579
2363
  /*
1580
2364
  * @mohamedatia/fly-design-system — Public API
@@ -1607,12 +2391,56 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.5", ngImpor
1607
2391
  * options resolve (no spurious null fetch). `loadOuLabelMap` supplies default-tree labels for OU rows.
1608
2392
  * v1.8.0: `WindowInstance` optional `contentUiBlocked` / `contentUiBlockMessageKey`; `WindowManagerService`
1609
2393
  * `beginContentUiBlock` / `endContentUiBlock` default no-ops; `FlyBlockUiComponent` for shell window overlay.
2394
+ * v1.10.0: SharePanelComponent — `supportsDeny` input + `isDeny?` on `SharePermissionEntry`. Surfaces an
2395
+ * "Add as deny" toggle in the add-permission row and renders existing deny grants with red
2396
+ * accent + ban icon. `grantToUser` / `grantToOu` callbacks gain a fourth `isDeny: boolean`
2397
+ * parameter (additive — existing 3-arg callers won't compile until updated). New locale keys
2398
+ * `files.share.add_as_deny`, `files.share.deny_help`, `files.share.denied` (en/ar/fr/ur).
2399
+ * Backed by Files Manager `FilePermission.IsDeny` and Notes `NoteShareLevel.Deny`.
2400
+ * v2.0.0: BREAKING — `SharePermissionEntry` adopts the `SharePrincipal` discriminated union
2401
+ * (`{ kind: 'user' | 'role' | 'ou' | 'app-everyone', ... }`) instead of the v1
2402
+ * `grantedToUserId` / `grantedToOuId` / `grantedToAppId` set-which-is-set fields.
2403
+ * Hosts must translate domain DTOs to/from the new shape (see
2404
+ * `file-access-control.component.ts` and `notes.component.ts` for migration patterns).
2405
+ * The `role` kind is reserved for the planned apps-chart role-as-principal flow;
2406
+ * hosts should not emit it until the backend's `PermissionResolver` enumerates a
2407
+ * user's full OU set (direct + computed-from-role memberships).
2408
+ * v2.1.0: AudienceBuilderComponent — generic <fly-audience-builder> form-control for composing
2409
+ * a `Fly.Shared.Core.Audience.AudienceFilter`. TypeScript model (`AudienceFilter`,
2410
+ * `AudienceTerm` discriminated union, `AUDIENCE_LIMITS`) mirrors the C# polymorphic
2411
+ * shape byte-for-byte (`kind` discriminator: users/roles/ou/app-everyone/chart/preset).
2412
+ * Reuses share-panel callback shapes (searchUsers, loadOuTree, loadChartOptions,
2413
+ * loadRoleOus). Distinct from share-panel: emits `(audienceChange)` + `(validityChange)`
2414
+ * instead of auto-persisting; hosts save on submit. New `audience.*` locale namespace.
2415
+ * v2.3.0: Deep-link launch contract — `LAUNCH_CONTEXT` injection token + `LaunchContext`
2416
+ * type for federated remotes to receive a route + params payload when the host
2417
+ * opens (or re-opens) their window via a `?app=&route=&params=` URL on the shell.
2418
+ * Token resolves to `Signal<LaunchContext | null>`; `null` when no host provider.
2419
+ * Backed by `Fly.Shared.Core.Apps.IDeepLinkBuilder` for outbound URL generation.
2420
+ * v2.2.0: AudienceBuilderComponent now implements `ControlValueAccessor` + `Validator` so
2421
+ * hosts can bind via `formControl` / `formControlName` / `[(ngModel)]`. Existing
2422
+ * `[value]` + `(audienceChange)` + `(validityChange)` API still works (forms wiring
2423
+ * is purely additive). `AUDIENCE_PRESETS` const exported for hosts that build their
2424
+ * own preset pickers. `OuTerm.chartId` and `OuTerm.includeDescendants` are now
2425
+ * REQUIRED (no longer optional) so wire round-trips are deterministic — emit explicit
2426
+ * `null` / `boolean` rather than `undefined`. New `AudienceErrorCodes` mirror added.
1610
2427
  * See docs/ExternalAppsGuide/03-frontend-app.md.
1611
2428
  */
2429
+ /**
2430
+ * Stable error codes returned by audience-aware backend endpoints. Mirror of
2431
+ * `Fly.Shared.Core.Audience.AudienceErrorCodes`. Compare on these strings, not on HTTP
2432
+ * status, so localized messages can change without breaking integrations.
2433
+ */
2434
+ const AUDIENCE_ERROR_CODES = {
2435
+ invalidFilter: 'INVALID_AUDIENCE_FILTER',
2436
+ audienceTooLarge: 'AUDIENCE_TOO_LARGE',
2437
+ resolverUnavailable: 'AUDIENCE_RESOLVER_UNAVAILABLE',
2438
+ invalidAudienceKind: 'INVALID_AUDIENCE_KIND',
2439
+ };
1612
2440
 
1613
2441
  /**
1614
2442
  * Generated bundle index. Do not edit.
1615
2443
  */
1616
2444
 
1617
- export { AuthService, ContextMenuComponent, DEFAULT_FLY_THEME_MODE, DialogResult, FLY_THEME_MODE_IDS, FlyBlockUiComponent, FlyFileUploadComponent, FlyImageUploadComponent, FlyThemeService, I18nService, MessageBoxButtons, MessageBoxComponent, MessageBoxIcon, MessageBoxService, MockAuthService, RTL_LOCALE_SET, SHARE_PANEL_DEFAULT_FILE_LEVELS, SharePanelComponent, StandaloneWindowManagerService, TranslatePipe, WINDOW_DATA, WindowManagerService, isRtlLocale, normalizeFlyTheme };
2445
+ export { AUDIENCE_ERROR_CODES, AUDIENCE_LIMITS, AUDIENCE_PRESETS, AUDIENCE_TERM_KINDS, AudienceBuilderComponent, AuthService, ContextMenuComponent, DEFAULT_FLY_THEME_MODE, DialogResult, FLY_THEME_MODE_IDS, FlyBlockUiComponent, FlyFileUploadComponent, FlyImageUploadComponent, FlyThemeService, I18nService, LAUNCH_CONTEXT, MessageBoxButtons, MessageBoxComponent, MessageBoxIcon, MessageBoxService, MockAuthService, RTL_LOCALE_SET, SHARE_PANEL_DEFAULT_FILE_LEVELS, SharePanelComponent, StandaloneWindowManagerService, TranslatePipe, WINDOW_DATA, WindowManagerService, isRtlLocale, normalizeFlyTheme };
1618
2446
  //# sourceMappingURL=mohamedatia-fly-design-system.mjs.map