valtech-components 4.0.27 → 4.0.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. package/esm2022/lib/components/organisms/account-view/account-view.component.mjs +34 -30
  2. package/esm2022/lib/components/organisms/change-password-modal/change-password-modal.component.mjs +3 -3
  3. package/esm2022/lib/components/organisms/create-org-modal/create-org-modal.component.mjs +91 -65
  4. package/esm2022/lib/components/organisms/delete-account-modal/delete-account-modal.component.mjs +106 -84
  5. package/esm2022/lib/components/organisms/organization-view/organization-view.component.mjs +16 -3
  6. package/esm2022/lib/components/organisms/permissions-view/permissions-modal.component.mjs +70 -0
  7. package/esm2022/lib/components/organisms/permissions-view/permissions-view.component.mjs +16 -5
  8. package/esm2022/lib/version.mjs +2 -2
  9. package/esm2022/public-api.mjs +2 -1
  10. package/fesm2022/valtech-components.mjs +1954 -1814
  11. package/fesm2022/valtech-components.mjs.map +1 -1
  12. package/lib/components/organisms/account-view/account-view.component.d.ts +3 -0
  13. package/lib/components/organisms/create-org-modal/create-org-modal.component.d.ts +8 -8
  14. package/lib/components/organisms/delete-account-modal/delete-account-modal.component.d.ts +7 -8
  15. package/lib/components/organisms/organization-view/organization-view.component.d.ts +1 -0
  16. package/lib/components/organisms/permissions-view/permissions-modal.component.d.ts +13 -0
  17. package/lib/components/organisms/permissions-view/permissions-view.component.d.ts +9 -2
  18. package/lib/version.d.ts +1 -1
  19. package/package.json +1 -1
  20. package/public-api.d.ts +1 -0
@@ -56,7 +56,7 @@ import { BrowserMultiFormatReader } from '@zxing/browser';
56
56
  * Current version of valtech-components.
57
57
  * This is automatically updated during the publish process.
58
58
  */
59
- const VERSION = '4.0.27';
59
+ const VERSION = '4.0.28';
60
60
 
61
61
  // Control de estado de refresco (singleton a nivel de módulo)
62
62
  let isRefreshing = false;
@@ -33564,11 +33564,11 @@ class ChangePasswordModalComponent {
33564
33564
  this.toast.show({ message, duration: 3500 });
33565
33565
  }
33566
33566
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ChangePasswordModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
33567
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ChangePasswordModalComponent, isStandalone: true, selector: "val-change-password-modal", inputs: { isOpen: "isOpen" }, outputs: { changed: "changed", dismissed: "dismissed" }, ngImport: i0, template: "<ion-modal [isOpen]=\"isOpen\" (didDismiss)=\"close()\">\n <ng-template>\n <ion-header>\n <ion-toolbar>\n <ion-buttons slot=\"end\">\n <ion-button fill=\"clear\" color=\"dark\" shape=\"round\" (click)=\"close()\">\n <strong>{{ t('close') }}</strong>\n </ion-button>\n </ion-buttons>\n </ion-toolbar>\n </ion-header>\n <ion-content class=\"ion-padding\">\n <section class=\"modal-form-section\">\n @if (mode() === 'loading') {\n <div class=\"modal-loading\">\n <ion-spinner name=\"crescent\"></ion-spinner>\n </div>\n } @else {\n <val-form [props]=\"formProps()\" (onSubmit)=\"submitHandler($event)\" />\n }\n </section>\n </ion-content>\n </ng-template>\n</ion-modal>\n", styles: [".modal-form-section{display:flex;flex-direction:column;gap:16px;max-width:420px;margin:0 auto;padding-top:8px}.modal-loading{display:flex;justify-content:center;padding:40px 0}\n"], dependencies: [{ kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonModal, selector: "ion-modal" }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: FormComponent, selector: "val-form", inputs: ["props"], outputs: ["onSubmit", "onInvalid", "onSelectChange"] }] }); }
33567
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: ChangePasswordModalComponent, isStandalone: true, selector: "val-change-password-modal", inputs: { isOpen: "isOpen" }, outputs: { changed: "changed", dismissed: "dismissed" }, ngImport: i0, template: "<ion-modal [isOpen]=\"isOpen\" (didDismiss)=\"close()\" cssClass=\"val-modal-adaptive\">\n <ng-template>\n <ion-header>\n <ion-toolbar>\n <ion-buttons slot=\"end\">\n <ion-button fill=\"clear\" color=\"dark\" shape=\"round\" (click)=\"close()\">\n <strong>{{ t('close') }}</strong>\n </ion-button>\n </ion-buttons>\n </ion-toolbar>\n </ion-header>\n <ion-content class=\"ion-padding\">\n <section class=\"modal-form-section\">\n @if (mode() === 'loading') {\n <div class=\"modal-loading\">\n <ion-spinner name=\"crescent\"></ion-spinner>\n </div>\n } @else {\n <val-form [props]=\"formProps()\" (onSubmit)=\"submitHandler($event)\" />\n }\n </section>\n </ion-content>\n </ng-template>\n</ion-modal>\n", styles: [".modal-form-section{display:flex;flex-direction:column;gap:16px;max-width:420px;margin:0 auto;padding-top:8px}.modal-loading{display:flex;justify-content:center;padding:40px 0}\n"], dependencies: [{ kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonModal, selector: "ion-modal" }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: FormComponent, selector: "val-form", inputs: ["props"], outputs: ["onSubmit", "onInvalid", "onSelectChange"] }] }); }
33568
33568
  }
33569
33569
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: ChangePasswordModalComponent, decorators: [{
33570
33570
  type: Component,
33571
- args: [{ selector: 'val-change-password-modal', standalone: true, imports: [IonButton, IonButtons, IonContent, IonHeader, IonModal, IonSpinner, IonToolbar, FormComponent], template: "<ion-modal [isOpen]=\"isOpen\" (didDismiss)=\"close()\">\n <ng-template>\n <ion-header>\n <ion-toolbar>\n <ion-buttons slot=\"end\">\n <ion-button fill=\"clear\" color=\"dark\" shape=\"round\" (click)=\"close()\">\n <strong>{{ t('close') }}</strong>\n </ion-button>\n </ion-buttons>\n </ion-toolbar>\n </ion-header>\n <ion-content class=\"ion-padding\">\n <section class=\"modal-form-section\">\n @if (mode() === 'loading') {\n <div class=\"modal-loading\">\n <ion-spinner name=\"crescent\"></ion-spinner>\n </div>\n } @else {\n <val-form [props]=\"formProps()\" (onSubmit)=\"submitHandler($event)\" />\n }\n </section>\n </ion-content>\n </ng-template>\n</ion-modal>\n", styles: [".modal-form-section{display:flex;flex-direction:column;gap:16px;max-width:420px;margin:0 auto;padding-top:8px}.modal-loading{display:flex;justify-content:center;padding:40px 0}\n"] }]
33571
+ args: [{ selector: 'val-change-password-modal', standalone: true, imports: [IonButton, IonButtons, IonContent, IonHeader, IonModal, IonSpinner, IonToolbar, FormComponent], template: "<ion-modal [isOpen]=\"isOpen\" (didDismiss)=\"close()\" cssClass=\"val-modal-adaptive\">\n <ng-template>\n <ion-header>\n <ion-toolbar>\n <ion-buttons slot=\"end\">\n <ion-button fill=\"clear\" color=\"dark\" shape=\"round\" (click)=\"close()\">\n <strong>{{ t('close') }}</strong>\n </ion-button>\n </ion-buttons>\n </ion-toolbar>\n </ion-header>\n <ion-content class=\"ion-padding\">\n <section class=\"modal-form-section\">\n @if (mode() === 'loading') {\n <div class=\"modal-loading\">\n <ion-spinner name=\"crescent\"></ion-spinner>\n </div>\n } @else {\n <val-form [props]=\"formProps()\" (onSubmit)=\"submitHandler($event)\" />\n }\n </section>\n </ion-content>\n </ng-template>\n</ion-modal>\n", styles: [".modal-form-section{display:flex;flex-direction:column;gap:16px;max-width:420px;margin:0 auto;padding-top:8px}.modal-loading{display:flex;justify-content:center;padding:40px 0}\n"] }]
33572
33572
  }], propDecorators: { isOpen: [{
33573
33573
  type: Input
33574
33574
  }], changed: [{
@@ -46678,6 +46678,16 @@ const DEFAULT_NAMESPACE$h = 'CreateOrgModal';
46678
46678
  * `CreateOrgModal`.
46679
46679
  */
46680
46680
  class CreateOrgModalComponent {
46681
+ set isOpen(value) {
46682
+ const opening = value && !this._isOpen;
46683
+ this._isOpen = value;
46684
+ if (opening) {
46685
+ this._busy.set(false);
46686
+ }
46687
+ }
46688
+ get isOpen() {
46689
+ return this._isOpen;
46690
+ }
46681
46691
  constructor() {
46682
46692
  this.i18n = inject(I18nService);
46683
46693
  this.orgService = inject(OrgService);
@@ -46686,6 +46696,9 @@ class CreateOrgModalComponent {
46686
46696
  this.errors = inject(ValtechErrorService);
46687
46697
  /** Namespace i18n con que la vista resuelve sus textos. */
46688
46698
  this.i18nNamespace = DEFAULT_NAMESPACE$h;
46699
+ this._isOpen = false;
46700
+ this.dismissed = new EventEmitter();
46701
+ this.created = new EventEmitter();
46689
46702
  this._busy = signal(false);
46690
46703
  if (!this.i18n.hasNamespace(DEFAULT_NAMESPACE$h)) {
46691
46704
  this.i18n.registerContent(DEFAULT_NAMESPACE$h, CREATE_ORG_MODAL_I18N);
@@ -46731,8 +46744,8 @@ class CreateOrgModalComponent {
46731
46744
  duration: 3500,
46732
46745
  });
46733
46746
  await this.orgSwitch.switchTo(newOrg.id);
46734
- this.onSuccess?.(newOrg);
46735
- this._modalRef?.dismiss(newOrg, 'confirm');
46747
+ this.created.emit(newOrg);
46748
+ this.dismissed.emit();
46736
46749
  }
46737
46750
  catch (err) {
46738
46751
  this.errors.handle(err, { i18nNamespace: this.i18nNamespace });
@@ -46742,7 +46755,10 @@ class CreateOrgModalComponent {
46742
46755
  }
46743
46756
  }
46744
46757
  dismiss() {
46745
- this._modalRef?.dismiss(null, 'cancel');
46758
+ this.dismissed.emit();
46759
+ }
46760
+ close() {
46761
+ this.dismissed.emit();
46746
46762
  }
46747
46763
  buildFormMeta() {
46748
46764
  const nameField = {
@@ -46822,71 +46838,81 @@ class CreateOrgModalComponent {
46822
46838
  return this.i18n.t(key, this.i18nNamespace);
46823
46839
  }
46824
46840
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: CreateOrgModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
46825
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: CreateOrgModalComponent, isStandalone: true, selector: "val-create-org-modal", inputs: { _modalRef: "_modalRef", onSuccess: "onSuccess", i18nNamespace: "i18nNamespace" }, ngImport: i0, template: `
46826
- <ion-header>
46827
- <ion-toolbar>
46828
- <ion-buttons slot="end">
46829
- <ion-button fill="clear" color="dark" shape="round" (click)="dismiss()">
46830
- <strong>{{ t('close') }}</strong>
46831
- </ion-button>
46832
- </ion-buttons>
46833
- </ion-toolbar>
46834
- </ion-header>
46835
- <ion-content class="ion-padding">
46836
- <val-display [props]="{ content: t('pageTitle'), size: 'small', color: 'dark' }" />
46837
- <val-title
46838
- [props]="{
46839
- content: t('pageDescription'),
46840
- size: 'large',
46841
- color: '',
46842
- bold: false,
46843
- }"
46844
- />
46845
- <val-form [props]="formMeta()" (onSubmit)="onSubmit($event)" />
46846
- <div class="modal-close-bottom">
46847
- <ion-button expand="block" fill="outline" color="dark" shape="round" (click)="dismiss()">
46848
- {{ t('close') }}
46849
- </ion-button>
46850
- </div>
46851
- </ion-content>
46852
- `, isInline: true, styles: [".modal-close-bottom{margin-top:16px}\n"], dependencies: [{ kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: FormComponent, selector: "val-form", inputs: ["props"], outputs: ["onSubmit", "onInvalid", "onSelectChange"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }] }); }
46841
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: CreateOrgModalComponent, isStandalone: true, selector: "val-create-org-modal", inputs: { i18nNamespace: "i18nNamespace", isOpen: "isOpen" }, outputs: { dismissed: "dismissed", created: "created" }, ngImport: i0, template: `
46842
+ <ion-modal [isOpen]="isOpen" (didDismiss)="close()" cssClass="val-modal-adaptive">
46843
+ <ng-template>
46844
+ <ion-header>
46845
+ <ion-toolbar>
46846
+ <ion-buttons slot="end">
46847
+ <ion-button fill="clear" color="dark" shape="round" (click)="dismiss()">
46848
+ <strong>{{ t('close') }}</strong>
46849
+ </ion-button>
46850
+ </ion-buttons>
46851
+ </ion-toolbar>
46852
+ </ion-header>
46853
+ <ion-content class="ion-padding">
46854
+ <val-display [props]="{ content: t('pageTitle'), size: 'small', color: 'dark' }" />
46855
+ <val-title
46856
+ [props]="{
46857
+ content: t('pageDescription'),
46858
+ size: 'large',
46859
+ color: '',
46860
+ bold: false,
46861
+ }"
46862
+ />
46863
+ <val-form [props]="formMeta()" (onSubmit)="onSubmit($event)" />
46864
+ <div class="modal-close-bottom">
46865
+ <ion-button expand="block" fill="outline" color="dark" shape="round" (click)="dismiss()">
46866
+ {{ t('close') }}
46867
+ </ion-button>
46868
+ </div>
46869
+ </ion-content>
46870
+ </ng-template>
46871
+ </ion-modal>
46872
+ `, isInline: true, styles: [".modal-close-bottom{margin-top:16px}\n"], dependencies: [{ kind: "component", type: IonModal, selector: "ion-modal" }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: FormComponent, selector: "val-form", inputs: ["props"], outputs: ["onSubmit", "onInvalid", "onSelectChange"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }] }); }
46853
46873
  }
46854
46874
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: CreateOrgModalComponent, decorators: [{
46855
46875
  type: Component,
46856
- args: [{ selector: 'val-create-org-modal', standalone: true, imports: [IonHeader, IonToolbar, IonButtons, IonButton, IonContent, FormComponent, DisplayComponent, TitleComponent], template: `
46857
- <ion-header>
46858
- <ion-toolbar>
46859
- <ion-buttons slot="end">
46860
- <ion-button fill="clear" color="dark" shape="round" (click)="dismiss()">
46861
- <strong>{{ t('close') }}</strong>
46862
- </ion-button>
46863
- </ion-buttons>
46864
- </ion-toolbar>
46865
- </ion-header>
46866
- <ion-content class="ion-padding">
46867
- <val-display [props]="{ content: t('pageTitle'), size: 'small', color: 'dark' }" />
46868
- <val-title
46869
- [props]="{
46870
- content: t('pageDescription'),
46871
- size: 'large',
46872
- color: '',
46873
- bold: false,
46874
- }"
46875
- />
46876
- <val-form [props]="formMeta()" (onSubmit)="onSubmit($event)" />
46877
- <div class="modal-close-bottom">
46878
- <ion-button expand="block" fill="outline" color="dark" shape="round" (click)="dismiss()">
46879
- {{ t('close') }}
46880
- </ion-button>
46881
- </div>
46882
- </ion-content>
46876
+ args: [{ selector: 'val-create-org-modal', standalone: true, imports: [IonModal, IonHeader, IonToolbar, IonButtons, IonButton, IonContent, FormComponent, DisplayComponent, TitleComponent], template: `
46877
+ <ion-modal [isOpen]="isOpen" (didDismiss)="close()" cssClass="val-modal-adaptive">
46878
+ <ng-template>
46879
+ <ion-header>
46880
+ <ion-toolbar>
46881
+ <ion-buttons slot="end">
46882
+ <ion-button fill="clear" color="dark" shape="round" (click)="dismiss()">
46883
+ <strong>{{ t('close') }}</strong>
46884
+ </ion-button>
46885
+ </ion-buttons>
46886
+ </ion-toolbar>
46887
+ </ion-header>
46888
+ <ion-content class="ion-padding">
46889
+ <val-display [props]="{ content: t('pageTitle'), size: 'small', color: 'dark' }" />
46890
+ <val-title
46891
+ [props]="{
46892
+ content: t('pageDescription'),
46893
+ size: 'large',
46894
+ color: '',
46895
+ bold: false,
46896
+ }"
46897
+ />
46898
+ <val-form [props]="formMeta()" (onSubmit)="onSubmit($event)" />
46899
+ <div class="modal-close-bottom">
46900
+ <ion-button expand="block" fill="outline" color="dark" shape="round" (click)="dismiss()">
46901
+ {{ t('close') }}
46902
+ </ion-button>
46903
+ </div>
46904
+ </ion-content>
46905
+ </ng-template>
46906
+ </ion-modal>
46883
46907
  `, styles: [".modal-close-bottom{margin-top:16px}\n"] }]
46884
- }], ctorParameters: () => [], propDecorators: { _modalRef: [{
46885
- type: Input
46886
- }], onSuccess: [{
46908
+ }], ctorParameters: () => [], propDecorators: { i18nNamespace: [{
46887
46909
  type: Input
46888
- }], i18nNamespace: [{
46910
+ }], isOpen: [{
46889
46911
  type: Input
46912
+ }], dismissed: [{
46913
+ type: Output
46914
+ }], created: [{
46915
+ type: Output
46890
46916
  }] } });
46891
46917
 
46892
46918
  /**
@@ -46949,6 +46975,21 @@ const DEFAULT_NAMESPACE$g = 'Settings.DeleteAccount';
46949
46975
  * proveyó el namespace `Settings.DeleteAccount`.
46950
46976
  */
46951
46977
  class DeleteAccountModalComponent {
46978
+ set isOpen(value) {
46979
+ const opening = value && !this._isOpen;
46980
+ this._isOpen = value;
46981
+ if (opening) {
46982
+ this.hasPassword.set(null);
46983
+ this.codeSent.set(false);
46984
+ this.formState.set(ComponentStates.ENABLED);
46985
+ firstValueFrom(this.auth.getProfile())
46986
+ .then(profile => this.hasPassword.set(profile.hasPassword ?? true))
46987
+ .catch(() => this.hasPassword.set(true));
46988
+ }
46989
+ }
46990
+ get isOpen() {
46991
+ return this._isOpen;
46992
+ }
46952
46993
  constructor() {
46953
46994
  this.auth = inject(AuthService);
46954
46995
  this.errors = inject(ValtechErrorService);
@@ -46956,6 +46997,8 @@ class DeleteAccountModalComponent {
46956
46997
  this.toast = inject(ToastService);
46957
46998
  /** Namespace i18n con que la vista resuelve sus textos. */
46958
46999
  this.i18nNamespace = DEFAULT_NAMESPACE$g;
47000
+ this._isOpen = false;
47001
+ this.dismissed = new EventEmitter();
46959
47002
  this.hasPassword = signal(null);
46960
47003
  this.codeSent = signal(false);
46961
47004
  this.formState = signal(ComponentStates.ENABLED);
@@ -47026,11 +47069,6 @@ class DeleteAccountModalComponent {
47026
47069
  this.i18n.registerContent(DEFAULT_NAMESPACE$g, DELETE_ACCOUNT_MODAL_I18N);
47027
47070
  }
47028
47071
  }
47029
- ngOnInit() {
47030
- firstValueFrom(this.auth.getProfile())
47031
- .then(profile => this.hasPassword.set(profile.hasPassword ?? true))
47032
- .catch(() => this.hasPassword.set(true));
47033
- }
47034
47072
  submitBtn(token, textKey) {
47035
47073
  return {
47036
47074
  text: this.t(textKey),
@@ -47045,7 +47083,7 @@ class DeleteAccountModalComponent {
47045
47083
  };
47046
47084
  }
47047
47085
  close() {
47048
- this._modalRef?.dismiss(undefined, 'cancel');
47086
+ this.dismissed.emit();
47049
47087
  }
47050
47088
  async onPasswordSubmit(event) {
47051
47089
  const password = event.fields['password'];
@@ -47105,89 +47143,99 @@ class DeleteAccountModalComponent {
47105
47143
  }
47106
47144
  }
47107
47145
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DeleteAccountModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
47108
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: DeleteAccountModalComponent, isStandalone: true, selector: "val-delete-account-modal", inputs: { _modalRef: "_modalRef", i18nNamespace: "i18nNamespace" }, ngImport: i0, template: `
47109
- <ion-header>
47110
- <ion-toolbar>
47111
- <ion-buttons slot="end">
47112
- <ion-button fill="clear" color="dark" shape="round" (click)="close()">
47113
- <strong>{{ t('close') }}</strong>
47114
- </ion-button>
47115
- </ion-buttons>
47116
- </ion-toolbar>
47117
- </ion-header>
47146
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: DeleteAccountModalComponent, isStandalone: true, selector: "val-delete-account-modal", inputs: { i18nNamespace: "i18nNamespace", isOpen: "isOpen" }, outputs: { dismissed: "dismissed" }, ngImport: i0, template: `
47147
+ <ion-modal [isOpen]="isOpen" (didDismiss)="close()" cssClass="val-modal-adaptive">
47148
+ <ng-template>
47149
+ <ion-header>
47150
+ <ion-toolbar>
47151
+ <ion-buttons slot="end">
47152
+ <ion-button fill="clear" color="dark" shape="round" (click)="close()">
47153
+ <strong>{{ t('close') }}</strong>
47154
+ </ion-button>
47155
+ </ion-buttons>
47156
+ </ion-toolbar>
47157
+ </ion-header>
47118
47158
 
47119
- <ion-content class="ion-padding">
47120
- <section class="modal-form-section">
47121
- @if (hasPassword() === null) {
47122
- <div class="spinner-center">
47123
- <ion-spinner name="crescent" />
47124
- </div>
47125
- } @else if (hasPassword()) {
47126
- <val-form [props]="passwordFormProps()" (onSubmit)="onPasswordSubmit($event)" />
47127
- } @else if (!codeSent()) {
47128
- <val-form [props]="sendCodeFormProps()" (onSubmit)="onSendCode()" />
47129
- } @else {
47130
- <val-form [props]="codeFormProps()" (onSubmit)="onCodeSubmit($event)" />
47131
- <div class="resend-row">
47132
- <ion-button
47133
- fill="clear"
47134
- color="medium"
47135
- size="small"
47136
- [disabled]="formState() === 'WORKING'"
47137
- (click)="onResendCode()"
47138
- >
47139
- {{ t('resend') }}
47140
- </ion-button>
47141
- </div>
47142
- }
47143
- </section>
47144
- </ion-content>
47145
- `, isInline: true, styles: [".modal-form-section{display:flex;flex-direction:column;gap:16px;max-width:420px;margin:0 auto;padding-top:8px}.resend-row{display:flex;justify-content:center;margin-top:-8px}.spinner-center{display:flex;justify-content:center;padding-top:32px}\n"], dependencies: [{ kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: FormComponent, selector: "val-form", inputs: ["props"], outputs: ["onSubmit", "onInvalid", "onSelectChange"] }] }); }
47159
+ <ion-content class="ion-padding">
47160
+ <section class="modal-form-section">
47161
+ @if (hasPassword() === null) {
47162
+ <div class="spinner-center">
47163
+ <ion-spinner name="crescent" />
47164
+ </div>
47165
+ } @else if (hasPassword()) {
47166
+ <val-form [props]="passwordFormProps()" (onSubmit)="onPasswordSubmit($event)" />
47167
+ } @else if (!codeSent()) {
47168
+ <val-form [props]="sendCodeFormProps()" (onSubmit)="onSendCode()" />
47169
+ } @else {
47170
+ <val-form [props]="codeFormProps()" (onSubmit)="onCodeSubmit($event)" />
47171
+ <div class="resend-row">
47172
+ <ion-button
47173
+ fill="clear"
47174
+ color="medium"
47175
+ size="small"
47176
+ [disabled]="formState() === 'WORKING'"
47177
+ (click)="onResendCode()"
47178
+ >
47179
+ {{ t('resend') }}
47180
+ </ion-button>
47181
+ </div>
47182
+ }
47183
+ </section>
47184
+ </ion-content>
47185
+ </ng-template>
47186
+ </ion-modal>
47187
+ `, isInline: true, styles: [".modal-form-section{display:flex;flex-direction:column;gap:16px;max-width:420px;margin:0 auto;padding-top:8px}.resend-row{display:flex;justify-content:center;margin-top:-8px}.spinner-center{display:flex;justify-content:center;padding-top:32px}\n"], dependencies: [{ kind: "component", type: IonModal, selector: "ion-modal" }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: FormComponent, selector: "val-form", inputs: ["props"], outputs: ["onSubmit", "onInvalid", "onSelectChange"] }] }); }
47146
47188
  }
47147
47189
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: DeleteAccountModalComponent, decorators: [{
47148
47190
  type: Component,
47149
- args: [{ selector: 'val-delete-account-modal', standalone: true, imports: [IonHeader, IonToolbar, IonButtons, IonButton, IonContent, IonSpinner, FormComponent], template: `
47150
- <ion-header>
47151
- <ion-toolbar>
47152
- <ion-buttons slot="end">
47153
- <ion-button fill="clear" color="dark" shape="round" (click)="close()">
47154
- <strong>{{ t('close') }}</strong>
47155
- </ion-button>
47156
- </ion-buttons>
47157
- </ion-toolbar>
47158
- </ion-header>
47191
+ args: [{ selector: 'val-delete-account-modal', standalone: true, imports: [IonModal, IonHeader, IonToolbar, IonButtons, IonButton, IonContent, IonSpinner, FormComponent], template: `
47192
+ <ion-modal [isOpen]="isOpen" (didDismiss)="close()" cssClass="val-modal-adaptive">
47193
+ <ng-template>
47194
+ <ion-header>
47195
+ <ion-toolbar>
47196
+ <ion-buttons slot="end">
47197
+ <ion-button fill="clear" color="dark" shape="round" (click)="close()">
47198
+ <strong>{{ t('close') }}</strong>
47199
+ </ion-button>
47200
+ </ion-buttons>
47201
+ </ion-toolbar>
47202
+ </ion-header>
47159
47203
 
47160
- <ion-content class="ion-padding">
47161
- <section class="modal-form-section">
47162
- @if (hasPassword() === null) {
47163
- <div class="spinner-center">
47164
- <ion-spinner name="crescent" />
47165
- </div>
47166
- } @else if (hasPassword()) {
47167
- <val-form [props]="passwordFormProps()" (onSubmit)="onPasswordSubmit($event)" />
47168
- } @else if (!codeSent()) {
47169
- <val-form [props]="sendCodeFormProps()" (onSubmit)="onSendCode()" />
47170
- } @else {
47171
- <val-form [props]="codeFormProps()" (onSubmit)="onCodeSubmit($event)" />
47172
- <div class="resend-row">
47173
- <ion-button
47174
- fill="clear"
47175
- color="medium"
47176
- size="small"
47177
- [disabled]="formState() === 'WORKING'"
47178
- (click)="onResendCode()"
47179
- >
47180
- {{ t('resend') }}
47181
- </ion-button>
47182
- </div>
47183
- }
47184
- </section>
47185
- </ion-content>
47204
+ <ion-content class="ion-padding">
47205
+ <section class="modal-form-section">
47206
+ @if (hasPassword() === null) {
47207
+ <div class="spinner-center">
47208
+ <ion-spinner name="crescent" />
47209
+ </div>
47210
+ } @else if (hasPassword()) {
47211
+ <val-form [props]="passwordFormProps()" (onSubmit)="onPasswordSubmit($event)" />
47212
+ } @else if (!codeSent()) {
47213
+ <val-form [props]="sendCodeFormProps()" (onSubmit)="onSendCode()" />
47214
+ } @else {
47215
+ <val-form [props]="codeFormProps()" (onSubmit)="onCodeSubmit($event)" />
47216
+ <div class="resend-row">
47217
+ <ion-button
47218
+ fill="clear"
47219
+ color="medium"
47220
+ size="small"
47221
+ [disabled]="formState() === 'WORKING'"
47222
+ (click)="onResendCode()"
47223
+ >
47224
+ {{ t('resend') }}
47225
+ </ion-button>
47226
+ </div>
47227
+ }
47228
+ </section>
47229
+ </ion-content>
47230
+ </ng-template>
47231
+ </ion-modal>
47186
47232
  `, styles: [".modal-form-section{display:flex;flex-direction:column;gap:16px;max-width:420px;margin:0 auto;padding-top:8px}.resend-row{display:flex;justify-content:center;margin-top:-8px}.spinner-center{display:flex;justify-content:center;padding-top:32px}\n"] }]
47187
- }], ctorParameters: () => [], propDecorators: { _modalRef: [{
47233
+ }], ctorParameters: () => [], propDecorators: { i18nNamespace: [{
47188
47234
  type: Input
47189
- }], i18nNamespace: [{
47235
+ }], isOpen: [{
47190
47236
  type: Input
47237
+ }], dismissed: [{
47238
+ type: Output
47191
47239
  }] } });
47192
47240
 
47193
47241
  /**
@@ -47878,6 +47926,8 @@ class AccountViewComponent {
47878
47926
  retrying: this.orgsLoading(),
47879
47927
  });
47880
47928
  });
47929
+ this.createOrgOpen = signal(false);
47930
+ this.deleteAccountOpen = signal(false);
47881
47931
  this.pendingInvites = signal([]);
47882
47932
  this.pendingInvitesLoading = signal(false);
47883
47933
  this.inviteAccepting = signal(null);
@@ -47991,40 +48041,18 @@ class AccountViewComponent {
47991
48041
  });
47992
48042
  }
47993
48043
  onDeleteAccount() {
47994
- void this.modalService.open({
47995
- component: DeleteAccountModalComponent,
47996
- componentProps: {
47997
- i18nNamespace: 'Settings.DeleteAccount',
47998
- },
47999
- breakpoints: {
48000
- breakpoints: [0, 0.85],
48001
- initialBreakpoint: 0.85,
48002
- showHandle: true,
48003
- },
48004
- backdropDismiss: false,
48005
- });
48044
+ this.deleteAccountOpen.set(true);
48006
48045
  this.resolvedConfig().onAccountDeleted?.();
48007
48046
  }
48008
48047
  onManageOrg() {
48009
48048
  this.nav.navigateByUrl(this.resolvedConfig().manageOrgRoute);
48010
48049
  }
48011
48050
  onNewOrg() {
48012
- void this.modalService.open({
48013
- component: CreateOrgModalComponent,
48014
- componentProps: {
48015
- i18nNamespace: 'CreateOrgModal',
48016
- onSuccess: (newOrg) => {
48017
- this.loadOrgs();
48018
- this.resolvedConfig().onOrgCreated?.(newOrg);
48019
- },
48020
- },
48021
- breakpoints: {
48022
- breakpoints: [0, 0.92],
48023
- initialBreakpoint: 0.92,
48024
- showHandle: true,
48025
- },
48026
- backdropDismiss: true,
48027
- });
48051
+ this.createOrgOpen.set(true);
48052
+ }
48053
+ onOrgCreated(newOrg) {
48054
+ this.loadOrgs();
48055
+ this.resolvedConfig().onOrgCreated?.(newOrg);
48028
48056
  }
48029
48057
  onMoreInfo() {
48030
48058
  void this.modalService.open({
@@ -48314,8 +48342,19 @@ class AccountViewComponent {
48314
48342
  </div>
48315
48343
  </section>
48316
48344
  }
48345
+ <val-create-org-modal
48346
+ [isOpen]="createOrgOpen()"
48347
+ [i18nNamespace]="'CreateOrgModal'"
48348
+ (dismissed)="createOrgOpen.set(false)"
48349
+ (created)="onOrgCreated($event)"
48350
+ />
48351
+ <val-delete-account-modal
48352
+ [isOpen]="deleteAccountOpen()"
48353
+ [i18nNamespace]="'Settings.DeleteAccount'"
48354
+ (dismissed)="deleteAccountOpen.set(false)"
48355
+ />
48317
48356
  </div>
48318
- `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.settings-section val-title{display:block;margin-bottom:12px}.section-body{display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.row-actions--gap{display:flex;flex-wrap:wrap;gap:8px}.section-header-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}.org-new-cta-card{display:block;margin-top:12px}.orgs-empty-card{background:var(--ion-color-secondary-tint, rgba(var(--ion-color-secondary-rgb, 130, 101, 208), .2));border-radius:18px;padding:18px 16px 12px;display:flex;flex-direction:column;gap:8px}.orgs-empty-card__main{display:flex;align-items:center;gap:10px}.orgs-empty-card__icon{font-size:20px;flex-shrink:0;color:var(--ion-color-dark)}.orgs-empty-card__text{font-size:.875rem;color:var(--ion-color-dark)}.orgs-empty-card__link{background:none;border:none;padding:0;cursor:pointer;font-size:.8125rem;color:var(--ion-color-primary);text-align:left;text-decoration:underline;text-underline-offset:2px;font-family:inherit}.orgs-list{display:flex;flex-direction:column;gap:8px}.org-card{display:flex;align-items:center;gap:14px;padding:14px 16px;border-radius:12px;background:var(--ion-color-light, #f4f5f8);border:1.5px solid transparent;cursor:pointer;transition:background .15s,border-color .15s}.org-card:active{opacity:.75}.org-card--active{border-color:var(--ion-color-primary);background:color-mix(in srgb,var(--ion-color-primary) 8%,transparent)}:host-context(body.dark) .org-card,:host-context(html.ion-palette-dark) .org-card,:host-context([data-theme=\"dark\"]) .org-card{background:#ffffff0d}:host-context(body.dark) .org-card--active,:host-context(html.ion-palette-dark) .org-card--active,:host-context([data-theme=\"dark\"]) .org-card--active{background:color-mix(in srgb,var(--ion-color-primary) 15%,transparent)}.org-card__icon{width:40px;height:40px;border-radius:50%;background:color-mix(in srgb,var(--ion-color-primary) 15%,transparent);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:20px;color:var(--ion-color-primary)}.org-card__body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.org-card__name{font-weight:600;font-size:.95rem;color:var(--ion-color-dark);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.org-card__meta{display:flex;flex-direction:column;gap:1px;margin-top:3px}.org-card__meta-item{font-size:.78rem;color:var(--ion-color-dark)}.org-card__meta-label{font-weight:600;color:var(--ion-color-dark);margin-right:4px}.org-card__end{display:flex;align-items:center;gap:8px;flex-shrink:0}\n"], dependencies: [{ kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: EmptyStateComponent, selector: "val-empty-state", inputs: ["props"] }, { kind: "component", type: SkeletonLayoutComponent, selector: "val-skeleton-layout", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: CtaCardComponent, selector: "val-cta-card", inputs: ["props"], outputs: ["onAction"] }, { kind: "component", type: InvitationCardComponent, selector: "val-invitation-card", inputs: ["props"], outputs: ["onAccept", "onDecline"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "pipe", type: TitleCasePipe, name: "titlecase" }] }); }
48357
+ `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.settings-section val-title{display:block;margin-bottom:12px}.section-body{display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.row-actions--gap{display:flex;flex-wrap:wrap;gap:8px}.section-header-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}.org-new-cta-card{display:block;margin-top:12px}.orgs-empty-card{background:var(--ion-color-secondary-tint, rgba(var(--ion-color-secondary-rgb, 130, 101, 208), .2));border-radius:18px;padding:18px 16px 12px;display:flex;flex-direction:column;gap:8px}.orgs-empty-card__main{display:flex;align-items:center;gap:10px}.orgs-empty-card__icon{font-size:20px;flex-shrink:0;color:var(--ion-color-dark)}.orgs-empty-card__text{font-size:.875rem;color:var(--ion-color-dark)}.orgs-empty-card__link{background:none;border:none;padding:0;cursor:pointer;font-size:.8125rem;color:var(--ion-color-primary);text-align:left;text-decoration:underline;text-underline-offset:2px;font-family:inherit}.orgs-list{display:flex;flex-direction:column;gap:8px}.org-card{display:flex;align-items:center;gap:14px;padding:14px 16px;border-radius:12px;background:var(--ion-color-light, #f4f5f8);border:1.5px solid transparent;cursor:pointer;transition:background .15s,border-color .15s}.org-card:active{opacity:.75}.org-card--active{border-color:var(--ion-color-primary);background:color-mix(in srgb,var(--ion-color-primary) 8%,transparent)}:host-context(body.dark) .org-card,:host-context(html.ion-palette-dark) .org-card,:host-context([data-theme=\"dark\"]) .org-card{background:#ffffff0d}:host-context(body.dark) .org-card--active,:host-context(html.ion-palette-dark) .org-card--active,:host-context([data-theme=\"dark\"]) .org-card--active{background:color-mix(in srgb,var(--ion-color-primary) 15%,transparent)}.org-card__icon{width:40px;height:40px;border-radius:50%;background:color-mix(in srgb,var(--ion-color-primary) 15%,transparent);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:20px;color:var(--ion-color-primary)}.org-card__body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.org-card__name{font-weight:600;font-size:.95rem;color:var(--ion-color-dark);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.org-card__meta{display:flex;flex-direction:column;gap:1px;margin-top:3px}.org-card__meta-item{font-size:.78rem;color:var(--ion-color-dark)}.org-card__meta-label{font-weight:600;color:var(--ion-color-dark);margin-right:4px}.org-card__end{display:flex;align-items:center;gap:8px;flex-shrink:0}\n"], dependencies: [{ kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: EmptyStateComponent, selector: "val-empty-state", inputs: ["props"] }, { kind: "component", type: SkeletonLayoutComponent, selector: "val-skeleton-layout", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: CtaCardComponent, selector: "val-cta-card", inputs: ["props"], outputs: ["onAction"] }, { kind: "component", type: InvitationCardComponent, selector: "val-invitation-card", inputs: ["props"], outputs: ["onAccept", "onDecline"] }, { kind: "component", type: CreateOrgModalComponent, selector: "val-create-org-modal", inputs: ["i18nNamespace", "isOpen"], outputs: ["dismissed", "created"] }, { kind: "component", type: DeleteAccountModalComponent, selector: "val-delete-account-modal", inputs: ["i18nNamespace", "isOpen"], outputs: ["dismissed"] }, { kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "pipe", type: TitleCasePipe, name: "titlecase" }] }); }
48319
48358
  }
48320
48359
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: AccountViewComponent, decorators: [{
48321
48360
  type: Component,
@@ -48328,6 +48367,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
48328
48367
  ButtonComponent,
48329
48368
  CtaCardComponent,
48330
48369
  InvitationCardComponent,
48370
+ CreateOrgModalComponent,
48371
+ DeleteAccountModalComponent,
48331
48372
  IonIcon,
48332
48373
  IonButton,
48333
48374
  TitleCasePipe,
@@ -48501,6 +48542,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
48501
48542
  </div>
48502
48543
  </section>
48503
48544
  }
48545
+ <val-create-org-modal
48546
+ [isOpen]="createOrgOpen()"
48547
+ [i18nNamespace]="'CreateOrgModal'"
48548
+ (dismissed)="createOrgOpen.set(false)"
48549
+ (created)="onOrgCreated($event)"
48550
+ />
48551
+ <val-delete-account-modal
48552
+ [isOpen]="deleteAccountOpen()"
48553
+ [i18nNamespace]="'Settings.DeleteAccount'"
48554
+ (dismissed)="deleteAccountOpen.set(false)"
48555
+ />
48504
48556
  </div>
48505
48557
  `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:16px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.settings-section val-title{display:block;margin-bottom:12px}.section-body{display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.row-actions--gap{display:flex;flex-wrap:wrap;gap:8px}.section-header-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}.org-new-cta-card{display:block;margin-top:12px}.orgs-empty-card{background:var(--ion-color-secondary-tint, rgba(var(--ion-color-secondary-rgb, 130, 101, 208), .2));border-radius:18px;padding:18px 16px 12px;display:flex;flex-direction:column;gap:8px}.orgs-empty-card__main{display:flex;align-items:center;gap:10px}.orgs-empty-card__icon{font-size:20px;flex-shrink:0;color:var(--ion-color-dark)}.orgs-empty-card__text{font-size:.875rem;color:var(--ion-color-dark)}.orgs-empty-card__link{background:none;border:none;padding:0;cursor:pointer;font-size:.8125rem;color:var(--ion-color-primary);text-align:left;text-decoration:underline;text-underline-offset:2px;font-family:inherit}.orgs-list{display:flex;flex-direction:column;gap:8px}.org-card{display:flex;align-items:center;gap:14px;padding:14px 16px;border-radius:12px;background:var(--ion-color-light, #f4f5f8);border:1.5px solid transparent;cursor:pointer;transition:background .15s,border-color .15s}.org-card:active{opacity:.75}.org-card--active{border-color:var(--ion-color-primary);background:color-mix(in srgb,var(--ion-color-primary) 8%,transparent)}:host-context(body.dark) .org-card,:host-context(html.ion-palette-dark) .org-card,:host-context([data-theme=\"dark\"]) .org-card{background:#ffffff0d}:host-context(body.dark) .org-card--active,:host-context(html.ion-palette-dark) .org-card--active,:host-context([data-theme=\"dark\"]) .org-card--active{background:color-mix(in srgb,var(--ion-color-primary) 15%,transparent)}.org-card__icon{width:40px;height:40px;border-radius:50%;background:color-mix(in srgb,var(--ion-color-primary) 15%,transparent);display:flex;align-items:center;justify-content:center;flex-shrink:0;font-size:20px;color:var(--ion-color-primary)}.org-card__body{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px}.org-card__name{font-weight:600;font-size:.95rem;color:var(--ion-color-dark);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.org-card__meta{display:flex;flex-direction:column;gap:1px;margin-top:3px}.org-card__meta-item{font-size:.78rem;color:var(--ion-color-dark)}.org-card__meta-label{font-weight:600;color:var(--ion-color-dark);margin-right:4px}.org-card__end{display:flex;align-items:center;gap:8px;flex-shrink:0}\n"] }]
48506
48558
  }], ctorParameters: () => [], propDecorators: { config: [{
@@ -50004,246 +50056,938 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
50004
50056
  }] } });
50005
50057
 
50006
50058
  /**
50007
- * Defaults i18n (es/en) embebidos en `val-transfer-ownership-modal`.
50008
- * Auto-registrados en el constructor del componente si el consumer no proveyó el
50009
- * namespace (`Settings.TransferModal` por default). SOLO incluye las keys que el
50010
- * modal usa.
50059
+ * Defaults i18n (es/en) embebidos en `val-permissions-view`. Auto-registrados en
50060
+ * el constructor del componente si el consumer no proveyó el namespace
50061
+ * (`Settings.Permissions` por default). SOLO incluye las keys que la vista usa.
50062
+ *
50063
+ * Portado EXACTO de `PERMISSIONS_I18N` (showcase) bajo el proceso de ADR-021.
50011
50064
  */
50012
- const TRANSFER_OWNERSHIP_MODAL_I18N = {
50065
+ const PERMISSIONS_VIEW_I18N = {
50013
50066
  es: {
50014
- title: 'Transferir propiedad',
50015
- hint: 'Selecciona el nuevo propietario de la organización.',
50016
- searchPlaceholder: 'Buscar miembro...',
50017
- empty: 'No hay otros miembros disponibles.',
50018
- noResults: 'Sin resultados para',
50019
- confirmCta: 'Confirmar transferencia',
50020
- close: 'Cancelar',
50067
+ pageTitle: 'Permisos',
50068
+ pageDescription: 'Roles y permisos de esta app en la organización activa',
50069
+ systemRolesTitle: 'Roles del sistema',
50070
+ systemRolesHint: 'Predefinidos por la plataforma. No editables.',
50071
+ customRolesTitle: 'Roles personalizados',
50072
+ customRolesHint: 'Definidos por tu organización.',
50073
+ systemBadge: 'Sistema',
50074
+ noCustomRoles: 'Esta organización no tiene roles personalizados.',
50075
+ noPermissions: 'Sin permisos asignados.',
50076
+ // Grupos de alcance
50077
+ groupApp: 'Permisos de la app',
50078
+ groupOrg: 'Permisos de la organización',
50079
+ groupPlatform: 'Permisos de plataforma',
50080
+ groupOther: 'Otras apps',
50081
+ // Roles del sistema
50082
+ roleViewer: 'Visor',
50083
+ roleEditor: 'Editor',
50084
+ roleAdmin: 'Administrador',
50085
+ roleSuperAdmin: 'Super Administrador',
50086
+ roleBingoAdmin: 'Bingo · Administrador',
50087
+ roleBingoTesorero: 'Bingo · Tesorero',
50088
+ roleBingoTaquilla: 'Bingo · Taquilla',
50089
+ roleBingoOperator: 'Bingo · Operador',
50090
+ // Apps
50091
+ app_showcase: 'Showcase',
50092
+ app_bingo: 'Bingo',
50093
+ // Recursos
50094
+ permUsers: 'Usuarios',
50095
+ permRoles: 'Roles',
50096
+ permRbac: 'Control de acceso',
50097
+ permApps: 'Apps',
50098
+ 'permApi-keys': 'API keys',
50099
+ 'permEmail-logs': 'Logs de correo',
50100
+ 'permSms-logs': 'Logs de SMS',
50101
+ permDocuments: 'Documentos',
50102
+ permMedia: 'Media',
50103
+ permTemplates: 'Plantillas',
50104
+ permRequests: 'Solicitudes',
50105
+ permBingo: 'Bingo',
50106
+ // Acciones
50107
+ permRead: 'Leer',
50108
+ permWrite: 'Escribir',
50109
+ permCreate: 'Crear',
50110
+ permDelete: 'Eliminar',
50111
+ permManage: 'Gestionar',
50112
+ permEdit: 'Editar',
50113
+ permSell: 'Vender',
50114
+ permCheckin: 'Canjear',
50115
+ permSettle: 'Rendir',
50116
+ permExport: 'Exportar',
50117
+ permOperate: 'Operar',
50118
+ permAll: 'Todo',
50119
+ // Loading / error
50120
+ errorTitle: 'Error al cargar permisos',
50121
+ retry: 'Reintentar',
50021
50122
  },
50022
50123
  en: {
50023
- title: 'Transfer ownership',
50024
- hint: 'Select the new owner of the organization.',
50025
- searchPlaceholder: 'Search member...',
50026
- empty: 'No other members available.',
50027
- noResults: 'No results for',
50028
- confirmCta: 'Confirm transfer',
50029
- close: 'Cancel',
50124
+ pageTitle: 'Permissions',
50125
+ pageDescription: 'Roles and permissions of this app in the active organization',
50126
+ systemRolesTitle: 'System roles',
50127
+ systemRolesHint: 'Defined by the platform. Not editable.',
50128
+ customRolesTitle: 'Custom roles',
50129
+ customRolesHint: 'Defined by your organization.',
50130
+ systemBadge: 'System',
50131
+ noCustomRoles: 'This organization has no custom roles.',
50132
+ noPermissions: 'No permissions assigned.',
50133
+ groupApp: 'App permissions',
50134
+ groupOrg: 'Organization permissions',
50135
+ groupPlatform: 'Platform permissions',
50136
+ groupOther: 'Other apps',
50137
+ roleViewer: 'Viewer',
50138
+ roleEditor: 'Editor',
50139
+ roleAdmin: 'Administrator',
50140
+ roleSuperAdmin: 'Super Administrator',
50141
+ roleBingoAdmin: 'Bingo · Administrator',
50142
+ roleBingoTesorero: 'Bingo · Treasurer',
50143
+ roleBingoTaquilla: 'Bingo · Box office',
50144
+ roleBingoOperator: 'Bingo · Operator',
50145
+ app_showcase: 'Showcase',
50146
+ app_bingo: 'Bingo',
50147
+ permUsers: 'Users',
50148
+ permRoles: 'Roles',
50149
+ permRbac: 'Access control',
50150
+ permApps: 'Apps',
50151
+ 'permApi-keys': 'API keys',
50152
+ 'permEmail-logs': 'Email logs',
50153
+ 'permSms-logs': 'SMS logs',
50154
+ permDocuments: 'Documents',
50155
+ permMedia: 'Media',
50156
+ permTemplates: 'Templates',
50157
+ permRequests: 'Requests',
50158
+ permBingo: 'Bingo',
50159
+ permRead: 'Read',
50160
+ permWrite: 'Write',
50161
+ permCreate: 'Create',
50162
+ permDelete: 'Delete',
50163
+ permManage: 'Manage',
50164
+ permEdit: 'Edit',
50165
+ permSell: 'Sell',
50166
+ permCheckin: 'Check-in',
50167
+ permSettle: 'Settle',
50168
+ permExport: 'Export',
50169
+ permOperate: 'Operate',
50170
+ permAll: 'All',
50171
+ errorTitle: 'Error loading permissions',
50172
+ retry: 'Retry',
50030
50173
  },
50031
50174
  };
50032
50175
 
50033
- const DEFAULT_NAMESPACE$9 = 'Settings.TransferModal';
50176
+ const DEFAULT_NAMESPACE$9 = 'Settings.Permissions';
50034
50177
  /**
50035
- * `val-transfer-ownership-modal` — selector de miembro para transferir la
50036
- * propiedad de la organización. Promovido desde `showcase` bajo el proceso de
50037
- * ADR-021.
50178
+ * `val-permissions-view` — vista Permisos full-feature autocontenida (organism).
50179
+ * Visor RBAC de solo lectura: muestra los roles del sistema (Visor/Editor/Admin +
50180
+ * roles de plataforma) y los roles personalizados de la org activa, con el
50181
+ * catálogo de permisos agrupado por alcance (app actual / plataforma / otras apps).
50182
+ * Promovida desde `showcase` bajo el proceso de ADR-021.
50038
50183
  *
50039
- * Recibe la lista de candidatos por `members` (todos excepto el owner actual),
50040
- * permite filtrarlos con `val-searchbar` y, al confirmar, ejecuta el callback
50041
- * `onConfirm(newOwnerId)`. La ejecución real de la transferencia (vía
50042
- * `OrgService.transferOwnership`) queda en el consumer.
50184
+ * **RBAC interno** (como `organization-view`): lee la org activa vía
50185
+ * `AuthService.user().activeOrgId`. La vista NO muta nada el control de quién
50186
+ * puede verla es responsabilidad del guard de ruta del consumer; no hay gating
50187
+ * fino interno ni se expone como config.
50043
50188
  *
50044
- * Abierto vía `ModalService` recibe `_modalRef` por `componentProps`. Header
50045
- * canónico (Regla #5): sin `ion-title`, botón Cerrar texto en `slot=end`,
50046
- * título/subtítulo en el body con `val-display`/`val-title`.
50189
+ * Datos: `forkJoin` de `OrgService.listOrgRoles(orgId)` (roles de la org) +
50190
+ * `PermissionCatalogService.getCatalog(orgId)` (catálogo + appId actual). El
50191
+ * catálogo define el alcance (`scope`/`appId`) de cada recurso para agrupar.
50047
50192
  *
50048
- * Auto-registra sus defaults i18n (es/en) si el consumer no proveyó el namespace
50049
- * `Settings.TransferModal`.
50193
+ * NO renderiza ion-content vive dentro de val-page-wrapper. `ActivatedRoute` se
50194
+ * inyecta `{ optional: true }` solo para leer el route data de config. Auto-registra
50195
+ * sus defaults i18n (es/en) si el consumer no proveyó el namespace (default
50196
+ * `Settings.Permissions`).
50050
50197
  */
50051
- class TransferOwnershipModalComponent {
50198
+ class PermissionsViewComponent {
50199
+ /** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
50200
+ get ns() {
50201
+ return this.resolvedConfig().i18nNamespace;
50202
+ }
50052
50203
  constructor() {
50053
50204
  this.i18n = inject(I18nService);
50054
- /** Lista de miembros transferibles (todos excepto el owner actual). */
50055
- this.members = [];
50056
- /** Namespace i18n con que la vista resuelve sus textos. */
50057
- this.i18nNamespace = DEFAULT_NAMESPACE$9;
50058
- this.query = signal('');
50059
- this.selectedId = signal('');
50060
- this.filtered = computed(() => {
50061
- const q = this.query().trim().toLowerCase();
50062
- if (!q)
50063
- return this.members;
50064
- return this.members.filter(m => m.name?.toLowerCase().includes(q) || m.email?.toLowerCase().includes(q) || m.userId.toLowerCase().includes(q));
50205
+ this.orgService = inject(OrgService);
50206
+ this.catalog = inject(PermissionCatalogService);
50207
+ this.auth = inject(AuthService);
50208
+ this.nav = inject(NavigationService);
50209
+ this.route = inject(ActivatedRoute, { optional: true });
50210
+ /**
50211
+ * Cuando `true`, la vista está embebida en un modal — suprime el back-header
50212
+ * (lo gestiona el modal padre). Default `false` (uso como ruta).
50213
+ */
50214
+ this.isModal = false;
50215
+ this.resolvedConfig = computed(() => {
50216
+ const fromRoute = (this.route?.snapshot.data['permissionsConfig'] ?? {});
50217
+ const merged = { ...fromRoute, ...(this.config ?? {}) };
50218
+ return {
50219
+ showSystemRoles: merged.showSystemRoles ?? true,
50220
+ showCustomRoles: merged.showCustomRoles ?? true,
50221
+ i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$9,
50222
+ };
50065
50223
  });
50066
- this.noResultsLabel = computed(() => `${this.t('noResults')} "${this.query()}"`);
50067
- if (!this.i18n.hasNamespace(DEFAULT_NAMESPACE$9)) {
50068
- this.i18n.registerContent(DEFAULT_NAMESPACE$9, TRANSFER_OWNERSHIP_MODAL_I18N);
50224
+ this.loading = signal(false);
50225
+ this.loadError = signal(null);
50226
+ this.roles = signal([]);
50227
+ this.systemRoles = computed(() => this.roles().filter(r => r.isSystem));
50228
+ this.customRoles = computed(() => this.roles().filter(r => !r.isSystem));
50229
+ this.activeOrgId = computed(() => {
50230
+ const u = this.auth.user();
50231
+ return u?.activeOrgId ?? u?.activeOrg ?? '';
50232
+ });
50233
+ this.retryButtonProps = computed(() => ({
50234
+ text: this.tt('retry'),
50235
+ color: 'dark',
50236
+ fill: 'outline',
50237
+ size: 'default',
50238
+ type: 'button',
50239
+ state: this.loading() ? ComponentStates.DISABLED : ComponentStates.ENABLED,
50240
+ }));
50241
+ const ns = this.ns;
50242
+ if (!this.i18n.hasNamespace(ns)) {
50243
+ this.i18n.registerContent(ns, PERMISSIONS_VIEW_I18N);
50069
50244
  }
50245
+ this.load();
50246
+ connectPageRefresh(() => this.load());
50070
50247
  }
50071
50248
  ngOnInit() {
50072
- if (!this.i18n.hasNamespace(this.i18nNamespace)) {
50073
- this.i18n.registerContent(this.i18nNamespace, TRANSFER_OWNERSHIP_MODAL_I18N);
50249
+ if (!this.isModal) {
50250
+ this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
50074
50251
  }
50075
50252
  }
50076
- confirm() {
50077
- const id = this.selectedId();
50078
- if (!id)
50253
+ load() {
50254
+ const orgId = this.activeOrgId();
50255
+ if (!orgId)
50079
50256
  return;
50080
- this.onConfirm?.(id);
50081
- this.dismiss();
50082
- }
50083
- dismiss() {
50084
- this._modalRef?.dismiss(null, 'cancel');
50085
- }
50086
- t(key) {
50087
- return this.i18n.t(key, this.i18nNamespace);
50257
+ this.loading.set(true);
50258
+ this.loadError.set(null);
50259
+ forkJoin({
50260
+ roles: this.orgService.listOrgRoles(orgId),
50261
+ catalog: this.catalog.getCatalog(orgId),
50262
+ }).subscribe({
50263
+ next: ({ roles: rawRoles, catalog }) => {
50264
+ const byResource = new Map();
50265
+ for (const r of catalog.permissions)
50266
+ byResource.set(r.resource, r);
50267
+ const currentAppId = catalog.appId;
50268
+ // Labeler con la SSOT del backend (labels del catálogo) — reemplaza el
50269
+ // mapa hardcodeado `perm*`. Locale del momento de carga (igual que antes:
50270
+ // los labels se hornean en el RoleView; cambio de idioma requiere reload).
50271
+ const labeler = createPermissionLabeler(catalog, this.i18n.lang(), {
50272
+ allLabel: this.tt('permAll'),
50273
+ });
50274
+ this.roles.set((rawRoles ?? []).map((r) => this.buildRoleView({
50275
+ id: r.id,
50276
+ name: r.name,
50277
+ description: r.description,
50278
+ permissions: r.permissions ?? [],
50279
+ isSystem: r.isSystem ?? false,
50280
+ }, byResource, currentAppId, labeler)));
50281
+ this.loading.set(false);
50282
+ },
50283
+ error: err => {
50284
+ this.loadError.set(err);
50285
+ this.loading.set(false);
50286
+ },
50287
+ });
50088
50288
  }
50089
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TransferOwnershipModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
50090
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: TransferOwnershipModalComponent, isStandalone: true, selector: "val-transfer-ownership-modal", inputs: { members: "members", onConfirm: "onConfirm", _modalRef: "_modalRef", i18nNamespace: "i18nNamespace" }, ngImport: i0, template: `
50091
- <ion-header>
50092
- <ion-toolbar>
50093
- <ion-buttons slot="end">
50094
- <ion-button fill="clear" color="dark" shape="round" (click)="dismiss()">
50095
- <strong>{{ t('close') }}</strong>
50096
- </ion-button>
50097
- </ion-buttons>
50098
- </ion-toolbar>
50099
- </ion-header>
50100
-
50101
- <ion-content class="ion-padding">
50102
- <val-display [props]="{ content: t('title'), size: 'small', color: 'dark' }" />
50103
- <val-title
50104
- [props]="{
50105
- content: t('hint'),
50106
- size: 'large',
50107
- color: '',
50108
- bold: false,
50109
- }"
50110
- />
50111
-
50112
- <val-searchbar
50113
- [props]="{ placeholder: t('searchPlaceholder'), debounce: 150 }"
50114
- (filterEvent)="query.set($event)"
50115
- />
50116
-
50117
- @if (members.length === 0) {
50118
- <val-text
50119
- [props]="{
50120
- content: t('empty'),
50121
- size: 'medium',
50122
- color: 'medium',
50123
- bold: false,
50124
- }"
50125
- />
50126
- } @else if (filtered().length === 0) {
50127
- <val-text
50289
+ buildRoleView(role, byResource, currentAppId, labeler) {
50290
+ const appPerms = [];
50291
+ const orgPerms = [];
50292
+ const otherByApp = new Map();
50293
+ for (const perm of role.permissions) {
50294
+ const [resource] = perm.split(':');
50295
+ const entry = byResource.get(resource);
50296
+ // Sin entrada en el catálogo (el backend omite los recursos internal para
50297
+ // no-platform-admins) o entrada internal → NUNCA se muestra a nivel de
50298
+ // producto. Defensa en profundidad junto al filtro del backend (ADR-024).
50299
+ // Nota: el rol puede traer el permiso aunque el recurso esté filtrado del
50300
+ // catálogo (ej. admin tiene templates:*); por eso se descarta lo no resuelto.
50301
+ if (!entry || entry.scope === 'internal') {
50302
+ continue;
50303
+ }
50304
+ const label = labeler(perm);
50305
+ if (entry.scope === 'app') {
50306
+ if (entry.appId === currentAppId) {
50307
+ appPerms.push(label);
50308
+ }
50309
+ else {
50310
+ const appId = entry.appId ?? '?';
50311
+ const arr = otherByApp.get(appId) ?? [];
50312
+ arr.push(label);
50313
+ otherByApp.set(appId, arr);
50314
+ }
50315
+ }
50316
+ else {
50317
+ // scope === 'org' → gestión de la organización del cliente
50318
+ orgPerms.push(label);
50319
+ }
50320
+ }
50321
+ const groups = [];
50322
+ if (appPerms.length) {
50323
+ groups.push({ key: 'app', label: this.tt('groupApp'), perms: appPerms });
50324
+ }
50325
+ if (orgPerms.length) {
50326
+ groups.push({
50327
+ key: 'org',
50328
+ label: this.tt('groupOrg'),
50329
+ perms: orgPerms,
50330
+ });
50331
+ }
50332
+ for (const [appId, perms] of otherByApp) {
50333
+ groups.push({
50334
+ key: `other:${appId}`,
50335
+ label: `${this.tt('groupOther')}: ${this.appLabel(appId)}`,
50336
+ perms,
50337
+ });
50338
+ }
50339
+ return {
50340
+ id: role.id,
50341
+ name: role.name,
50342
+ description: role.description,
50343
+ isSystem: role.isSystem,
50344
+ groups,
50345
+ hasPerms: groups.length > 0,
50346
+ };
50347
+ }
50348
+ tt(key) {
50349
+ return this.i18n.t(key, this.ns);
50350
+ }
50351
+ appLabel(appId) {
50352
+ const key = `app_${appId}`;
50353
+ const label = this.tt(key);
50354
+ return label === key ? appId : label;
50355
+ }
50356
+ roleLabel(name) {
50357
+ const map = {
50358
+ viewer: 'roleViewer',
50359
+ editor: 'roleEditor',
50360
+ admin: 'roleAdmin',
50361
+ super_admin: 'roleSuperAdmin',
50362
+ bingo_admin: 'roleBingoAdmin',
50363
+ bingo_tesorero: 'roleBingoTesorero',
50364
+ bingo_taquilla: 'roleBingoTaquilla',
50365
+ bingo_operator: 'roleBingoOperator',
50366
+ };
50367
+ return this.tt(map[name] ?? name);
50368
+ }
50369
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
50370
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: PermissionsViewComponent, isStandalone: true, selector: "val-permissions-view", inputs: { config: "config", isModal: "isModal" }, ngImport: i0, template: `
50371
+ <div class="page">
50372
+ <header class="page-header">
50373
+ <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
50374
+ <val-title
50128
50375
  [props]="{
50129
- content: noResultsLabel(),
50130
- size: 'medium',
50131
- color: 'medium',
50376
+ size: 'large',
50377
+ color: 'dark',
50132
50378
  bold: false,
50379
+ content: tt('pageDescription'),
50133
50380
  }"
50134
50381
  />
50382
+ </header>
50383
+
50384
+ @if (loading()) {
50385
+ <div class="spinner-row">
50386
+ <ion-spinner name="crescent" />
50387
+ </div>
50388
+ } @else if (loadError()) {
50389
+ <div class="error-row">
50390
+ <val-text
50391
+ [props]="{
50392
+ content: tt('errorTitle'),
50393
+ size: 'medium',
50394
+ color: 'medium',
50395
+ bold: false,
50396
+ }"
50397
+ />
50398
+ <val-button [props]="retryButtonProps()" (click)="load()" />
50399
+ </div>
50135
50400
  } @else {
50136
- <div class="transfer-member-list">
50137
- @for (m of filtered(); track m.userId) {
50138
- <button
50139
- class="transfer-member-row"
50140
- [class.transfer-member-row--selected]="selectedId() === m.userId"
50141
- (click)="selectedId.set(m.userId)"
50142
- type="button"
50143
- >
50144
- <span class="transfer-member-name">{{ m.name || m.email || m.userId }}</span>
50145
- @if (selectedId() === m.userId) {
50146
- <span class="transfer-member-check">&#10003;</span>
50401
+ @if (resolvedConfig().showSystemRoles) {
50402
+ <section class="perm-section">
50403
+ <div class="perm-section__header">
50404
+ <val-title
50405
+ [props]="{
50406
+ size: 'medium',
50407
+ color: 'dark',
50408
+ bold: true,
50409
+ content: tt('systemRolesTitle'),
50410
+ }"
50411
+ />
50412
+ <val-text
50413
+ [props]="{
50414
+ content: tt('systemRolesHint'),
50415
+ size: 'medium',
50416
+ color: 'medium',
50417
+ bold: false,
50418
+ }"
50419
+ />
50420
+ </div>
50421
+ <div class="role-list">
50422
+ @for (role of systemRoles(); track role.id) {
50423
+ <div class="role-card">
50424
+ <div class="role-card__header">
50425
+ <span class="role-card__name">{{ roleLabel(role.name) }}</span>
50426
+ <val-pill
50427
+ [props]="{
50428
+ label: tt('systemBadge'),
50429
+ color: 'primary',
50430
+ weight: 'bold',
50431
+ }"
50432
+ />
50433
+ </div>
50434
+ @if (role.description) {
50435
+ <span class="role-card__desc">{{ role.description }}</span>
50436
+ }
50437
+ @if (!role.hasPerms) {
50438
+ <div class="perm-chips">
50439
+ <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
50440
+ </div>
50441
+ } @else {
50442
+ @for (g of role.groups; track g.key) {
50443
+ <div class="perm-group">
50444
+ <span class="perm-group__label">{{ g.label }}</span>
50445
+ <div class="perm-chips">
50446
+ @for (p of g.perms; track p) {
50447
+ <span class="perm-chip">{{ p }}</span>
50448
+ }
50449
+ </div>
50450
+ </div>
50451
+ }
50452
+ }
50453
+ </div>
50147
50454
  }
50148
- </button>
50149
- }
50150
- </div>
50151
- }
50152
-
50153
- <div class="transfer-confirm-row">
50154
- <ion-button expand="block" color="dark" shape="round" [disabled]="!selectedId()" (click)="confirm()">
50155
- {{ t('confirmCta') }}
50156
- </ion-button>
50157
- </div>
50455
+ </div>
50456
+ </section>
50457
+ }
50158
50458
 
50159
- <div class="modal-close-bottom">
50160
- <ion-button expand="block" fill="outline" color="dark" shape="round" (click)="dismiss()">
50161
- {{ t('close') }}
50162
- </ion-button>
50163
- </div>
50164
- </ion-content>
50165
- `, isInline: true, styles: ["val-display{display:block;margin-bottom:4px}val-title{display:block;margin-bottom:16px}val-searchbar{display:block;margin-bottom:12px}.transfer-member-list{display:flex;flex-direction:column;gap:4px}.transfer-member-row{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-radius:10px;border:1.5px solid var(--ion-color-light);background:transparent;cursor:pointer;width:100%;text-align:left;font-size:15px;color:var(--ion-color-dark);transition:border-color .15s}.transfer-member-row--selected{border-color:var(--ion-color-dark);background:var(--ion-color-light)}.transfer-member-check{font-size:17px;color:var(--ion-color-dark)}.transfer-confirm-row{margin-top:24px}.modal-close-bottom{margin-top:12px}\n"], dependencies: [{ kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: SearchbarComponent, selector: "val-searchbar", inputs: ["preset", "props"], outputs: ["filterEvent", "focusEvent", "blurEvent"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }] }); }
50459
+ @if (resolvedConfig().showCustomRoles) {
50460
+ <section class="perm-section">
50461
+ <div class="perm-section__header">
50462
+ <val-title
50463
+ [props]="{
50464
+ size: 'medium',
50465
+ color: 'dark',
50466
+ bold: true,
50467
+ content: tt('customRolesTitle'),
50468
+ }"
50469
+ />
50470
+ <val-text
50471
+ [props]="{
50472
+ content: tt('customRolesHint'),
50473
+ size: 'medium',
50474
+ color: 'medium',
50475
+ bold: false,
50476
+ }"
50477
+ />
50478
+ </div>
50479
+ @if (customRoles().length === 0) {
50480
+ <val-text
50481
+ [props]="{
50482
+ content: tt('noCustomRoles'),
50483
+ size: 'medium',
50484
+ color: 'medium',
50485
+ bold: false,
50486
+ }"
50487
+ />
50488
+ } @else {
50489
+ <div class="role-list">
50490
+ @for (role of customRoles(); track role.id) {
50491
+ <div class="role-card">
50492
+ <div class="role-card__header">
50493
+ <span class="role-card__name">{{ role.name }}</span>
50494
+ </div>
50495
+ @if (role.description) {
50496
+ <span class="role-card__desc">{{ role.description }}</span>
50497
+ }
50498
+ @if (!role.hasPerms) {
50499
+ <div class="perm-chips">
50500
+ <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
50501
+ </div>
50502
+ } @else {
50503
+ @for (g of role.groups; track g.key) {
50504
+ <div class="perm-group">
50505
+ <span class="perm-group__label">{{ g.label }}</span>
50506
+ <div class="perm-chips">
50507
+ @for (p of g.perms; track p) {
50508
+ <span class="perm-chip">{{ p }}</span>
50509
+ }
50510
+ </div>
50511
+ </div>
50512
+ }
50513
+ }
50514
+ </div>
50515
+ }
50516
+ </div>
50517
+ }
50518
+ </section>
50519
+ }
50520
+ }
50521
+ </div>
50522
+ `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:24px}.spinner-row,.error-row{display:flex;flex-direction:column;align-items:center;gap:16px;padding:32px 0}.perm-section{margin-bottom:32px}.perm-section__header{margin-bottom:12px}.role-list{display:flex;flex-direction:column;gap:10px}.role-card{border:1.5px solid var(--ion-color-light);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}.role-card__header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.role-card__name{font-size:.9375rem;font-weight:600;color:var(--ion-color-dark)}.role-card__desc{font-size:.8125rem;color:var(--ion-color-medium);line-height:1.4}.perm-group{display:flex;flex-direction:column;gap:6px}.perm-group__label{font-size:.6875rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--ion-color-medium)}.perm-chips{display:flex;flex-wrap:wrap;gap:6px}.perm-chip{font-size:.75rem;padding:3px 10px;border-radius:20px;background:var(--ion-color-light);color:var(--ion-color-dark)}.perm-chip--empty{color:var(--ion-color-medium);background:transparent;padding-left:0}\n"], dependencies: [{ kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: PillComponent, selector: "val-pill", inputs: ["preset", "props"], outputs: ["pillClick", "pillAction"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }] }); }
50166
50523
  }
50167
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TransferOwnershipModalComponent, decorators: [{
50524
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, decorators: [{
50168
50525
  type: Component,
50169
- args: [{ selector: 'val-transfer-ownership-modal', standalone: true, imports: [
50170
- IonHeader,
50171
- IonToolbar,
50172
- IonButtons,
50173
- IonButton,
50174
- IonContent,
50175
- SearchbarComponent,
50176
- DisplayComponent,
50177
- TextComponent,
50178
- TitleComponent,
50179
- ], template: `
50180
- <ion-header>
50181
- <ion-toolbar>
50182
- <ion-buttons slot="end">
50183
- <ion-button fill="clear" color="dark" shape="round" (click)="dismiss()">
50184
- <strong>{{ t('close') }}</strong>
50185
- </ion-button>
50186
- </ion-buttons>
50187
- </ion-toolbar>
50188
- </ion-header>
50189
-
50190
- <ion-content class="ion-padding">
50191
- <val-display [props]="{ content: t('title'), size: 'small', color: 'dark' }" />
50192
- <val-title
50193
- [props]="{
50194
- content: t('hint'),
50195
- size: 'large',
50196
- color: '',
50197
- bold: false,
50198
- }"
50199
- />
50200
-
50201
- <val-searchbar
50202
- [props]="{ placeholder: t('searchPlaceholder'), debounce: 150 }"
50203
- (filterEvent)="query.set($event)"
50204
- />
50205
-
50206
- @if (members.length === 0) {
50207
- <val-text
50208
- [props]="{
50209
- content: t('empty'),
50210
- size: 'medium',
50211
- color: 'medium',
50212
- bold: false,
50213
- }"
50214
- />
50215
- } @else if (filtered().length === 0) {
50216
- <val-text
50526
+ args: [{ selector: 'val-permissions-view', standalone: true, imports: [IonSpinner, DisplayComponent, PillComponent, TitleComponent, TextComponent, ButtonComponent], template: `
50527
+ <div class="page">
50528
+ <header class="page-header">
50529
+ <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
50530
+ <val-title
50217
50531
  [props]="{
50218
- content: noResultsLabel(),
50219
- size: 'medium',
50220
- color: 'medium',
50532
+ size: 'large',
50533
+ color: 'dark',
50221
50534
  bold: false,
50535
+ content: tt('pageDescription'),
50222
50536
  }"
50223
50537
  />
50538
+ </header>
50539
+
50540
+ @if (loading()) {
50541
+ <div class="spinner-row">
50542
+ <ion-spinner name="crescent" />
50543
+ </div>
50544
+ } @else if (loadError()) {
50545
+ <div class="error-row">
50546
+ <val-text
50547
+ [props]="{
50548
+ content: tt('errorTitle'),
50549
+ size: 'medium',
50550
+ color: 'medium',
50551
+ bold: false,
50552
+ }"
50553
+ />
50554
+ <val-button [props]="retryButtonProps()" (click)="load()" />
50555
+ </div>
50224
50556
  } @else {
50225
- <div class="transfer-member-list">
50226
- @for (m of filtered(); track m.userId) {
50227
- <button
50228
- class="transfer-member-row"
50229
- [class.transfer-member-row--selected]="selectedId() === m.userId"
50230
- (click)="selectedId.set(m.userId)"
50231
- type="button"
50232
- >
50233
- <span class="transfer-member-name">{{ m.name || m.email || m.userId }}</span>
50234
- @if (selectedId() === m.userId) {
50235
- <span class="transfer-member-check">&#10003;</span>
50557
+ @if (resolvedConfig().showSystemRoles) {
50558
+ <section class="perm-section">
50559
+ <div class="perm-section__header">
50560
+ <val-title
50561
+ [props]="{
50562
+ size: 'medium',
50563
+ color: 'dark',
50564
+ bold: true,
50565
+ content: tt('systemRolesTitle'),
50566
+ }"
50567
+ />
50568
+ <val-text
50569
+ [props]="{
50570
+ content: tt('systemRolesHint'),
50571
+ size: 'medium',
50572
+ color: 'medium',
50573
+ bold: false,
50574
+ }"
50575
+ />
50576
+ </div>
50577
+ <div class="role-list">
50578
+ @for (role of systemRoles(); track role.id) {
50579
+ <div class="role-card">
50580
+ <div class="role-card__header">
50581
+ <span class="role-card__name">{{ roleLabel(role.name) }}</span>
50582
+ <val-pill
50583
+ [props]="{
50584
+ label: tt('systemBadge'),
50585
+ color: 'primary',
50586
+ weight: 'bold',
50587
+ }"
50588
+ />
50589
+ </div>
50590
+ @if (role.description) {
50591
+ <span class="role-card__desc">{{ role.description }}</span>
50592
+ }
50593
+ @if (!role.hasPerms) {
50594
+ <div class="perm-chips">
50595
+ <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
50596
+ </div>
50597
+ } @else {
50598
+ @for (g of role.groups; track g.key) {
50599
+ <div class="perm-group">
50600
+ <span class="perm-group__label">{{ g.label }}</span>
50601
+ <div class="perm-chips">
50602
+ @for (p of g.perms; track p) {
50603
+ <span class="perm-chip">{{ p }}</span>
50604
+ }
50605
+ </div>
50606
+ </div>
50607
+ }
50608
+ }
50609
+ </div>
50236
50610
  }
50237
- </button>
50238
- }
50239
- </div>
50611
+ </div>
50612
+ </section>
50613
+ }
50614
+
50615
+ @if (resolvedConfig().showCustomRoles) {
50616
+ <section class="perm-section">
50617
+ <div class="perm-section__header">
50618
+ <val-title
50619
+ [props]="{
50620
+ size: 'medium',
50621
+ color: 'dark',
50622
+ bold: true,
50623
+ content: tt('customRolesTitle'),
50624
+ }"
50625
+ />
50626
+ <val-text
50627
+ [props]="{
50628
+ content: tt('customRolesHint'),
50629
+ size: 'medium',
50630
+ color: 'medium',
50631
+ bold: false,
50632
+ }"
50633
+ />
50634
+ </div>
50635
+ @if (customRoles().length === 0) {
50636
+ <val-text
50637
+ [props]="{
50638
+ content: tt('noCustomRoles'),
50639
+ size: 'medium',
50640
+ color: 'medium',
50641
+ bold: false,
50642
+ }"
50643
+ />
50644
+ } @else {
50645
+ <div class="role-list">
50646
+ @for (role of customRoles(); track role.id) {
50647
+ <div class="role-card">
50648
+ <div class="role-card__header">
50649
+ <span class="role-card__name">{{ role.name }}</span>
50650
+ </div>
50651
+ @if (role.description) {
50652
+ <span class="role-card__desc">{{ role.description }}</span>
50653
+ }
50654
+ @if (!role.hasPerms) {
50655
+ <div class="perm-chips">
50656
+ <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
50657
+ </div>
50658
+ } @else {
50659
+ @for (g of role.groups; track g.key) {
50660
+ <div class="perm-group">
50661
+ <span class="perm-group__label">{{ g.label }}</span>
50662
+ <div class="perm-chips">
50663
+ @for (p of g.perms; track p) {
50664
+ <span class="perm-chip">{{ p }}</span>
50665
+ }
50666
+ </div>
50667
+ </div>
50668
+ }
50669
+ }
50670
+ </div>
50671
+ }
50672
+ </div>
50673
+ }
50674
+ </section>
50675
+ }
50240
50676
  }
50677
+ </div>
50678
+ `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:24px}.spinner-row,.error-row{display:flex;flex-direction:column;align-items:center;gap:16px;padding:32px 0}.perm-section{margin-bottom:32px}.perm-section__header{margin-bottom:12px}.role-list{display:flex;flex-direction:column;gap:10px}.role-card{border:1.5px solid var(--ion-color-light);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}.role-card__header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.role-card__name{font-size:.9375rem;font-weight:600;color:var(--ion-color-dark)}.role-card__desc{font-size:.8125rem;color:var(--ion-color-medium);line-height:1.4}.perm-group{display:flex;flex-direction:column;gap:6px}.perm-group__label{font-size:.6875rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--ion-color-medium)}.perm-chips{display:flex;flex-wrap:wrap;gap:6px}.perm-chip{font-size:.75rem;padding:3px 10px;border-radius:20px;background:var(--ion-color-light);color:var(--ion-color-dark)}.perm-chip--empty{color:var(--ion-color-medium);background:transparent;padding-left:0}\n"] }]
50679
+ }], ctorParameters: () => [], propDecorators: { config: [{
50680
+ type: Input
50681
+ }], isModal: [{
50682
+ type: Input
50683
+ }] } });
50241
50684
 
50242
- <div class="transfer-confirm-row">
50243
- <ion-button expand="block" color="dark" shape="round" [disabled]="!selectedId()" (click)="confirm()">
50244
- {{ t('confirmCta') }}
50245
- </ion-button>
50246
- </div>
50685
+ class PermissionsModalComponent {
50686
+ constructor() {
50687
+ this.i18n = inject(I18nService);
50688
+ this.isOpen = false;
50689
+ this.dismissed = new EventEmitter();
50690
+ }
50691
+ close() {
50692
+ this.dismissed.emit();
50693
+ }
50694
+ t(key) {
50695
+ return this.i18n.t(key, '_global');
50696
+ }
50697
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
50698
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.2.14", type: PermissionsModalComponent, isStandalone: true, selector: "val-permissions-modal", inputs: { isOpen: "isOpen", config: "config" }, outputs: { dismissed: "dismissed" }, ngImport: i0, template: `
50699
+ <ion-modal [isOpen]="isOpen" (didDismiss)="close()" cssClass="val-modal-adaptive">
50700
+ <ng-template>
50701
+ <ion-header>
50702
+ <ion-toolbar>
50703
+ <ion-buttons slot="end">
50704
+ <ion-button fill="clear" color="dark" shape="round" (click)="close()">
50705
+ <strong>{{ t('close') }}</strong>
50706
+ </ion-button>
50707
+ </ion-buttons>
50708
+ </ion-toolbar>
50709
+ </ion-header>
50710
+ <ion-content class="ion-padding">
50711
+ <val-permissions-view [config]="config" [isModal]="true" />
50712
+ </ion-content>
50713
+ </ng-template>
50714
+ </ion-modal>
50715
+ `, isInline: true, dependencies: [{ kind: "component", type: IonModal, selector: "ion-modal" }, { kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: PermissionsViewComponent, selector: "val-permissions-view", inputs: ["config", "isModal"] }] }); }
50716
+ }
50717
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsModalComponent, decorators: [{
50718
+ type: Component,
50719
+ args: [{
50720
+ selector: 'val-permissions-modal',
50721
+ standalone: true,
50722
+ imports: [IonModal, IonHeader, IonToolbar, IonButtons, IonButton, IonContent, PermissionsViewComponent],
50723
+ template: `
50724
+ <ion-modal [isOpen]="isOpen" (didDismiss)="close()" cssClass="val-modal-adaptive">
50725
+ <ng-template>
50726
+ <ion-header>
50727
+ <ion-toolbar>
50728
+ <ion-buttons slot="end">
50729
+ <ion-button fill="clear" color="dark" shape="round" (click)="close()">
50730
+ <strong>{{ t('close') }}</strong>
50731
+ </ion-button>
50732
+ </ion-buttons>
50733
+ </ion-toolbar>
50734
+ </ion-header>
50735
+ <ion-content class="ion-padding">
50736
+ <val-permissions-view [config]="config" [isModal]="true" />
50737
+ </ion-content>
50738
+ </ng-template>
50739
+ </ion-modal>
50740
+ `,
50741
+ }]
50742
+ }], propDecorators: { isOpen: [{
50743
+ type: Input
50744
+ }], config: [{
50745
+ type: Input
50746
+ }], dismissed: [{
50747
+ type: Output
50748
+ }] } });
50749
+
50750
+ /**
50751
+ * Defaults i18n (es/en) embebidos en `val-transfer-ownership-modal`.
50752
+ * Auto-registrados en el constructor del componente si el consumer no proveyó el
50753
+ * namespace (`Settings.TransferModal` por default). SOLO incluye las keys que el
50754
+ * modal usa.
50755
+ */
50756
+ const TRANSFER_OWNERSHIP_MODAL_I18N = {
50757
+ es: {
50758
+ title: 'Transferir propiedad',
50759
+ hint: 'Selecciona el nuevo propietario de la organización.',
50760
+ searchPlaceholder: 'Buscar miembro...',
50761
+ empty: 'No hay otros miembros disponibles.',
50762
+ noResults: 'Sin resultados para',
50763
+ confirmCta: 'Confirmar transferencia',
50764
+ close: 'Cancelar',
50765
+ },
50766
+ en: {
50767
+ title: 'Transfer ownership',
50768
+ hint: 'Select the new owner of the organization.',
50769
+ searchPlaceholder: 'Search member...',
50770
+ empty: 'No other members available.',
50771
+ noResults: 'No results for',
50772
+ confirmCta: 'Confirm transfer',
50773
+ close: 'Cancel',
50774
+ },
50775
+ };
50776
+
50777
+ const DEFAULT_NAMESPACE$8 = 'Settings.TransferModal';
50778
+ /**
50779
+ * `val-transfer-ownership-modal` — selector de miembro para transferir la
50780
+ * propiedad de la organización. Promovido desde `showcase` bajo el proceso de
50781
+ * ADR-021.
50782
+ *
50783
+ * Recibe la lista de candidatos por `members` (todos excepto el owner actual),
50784
+ * permite filtrarlos con `val-searchbar` y, al confirmar, ejecuta el callback
50785
+ * `onConfirm(newOwnerId)`. La ejecución real de la transferencia (vía
50786
+ * `OrgService.transferOwnership`) queda en el consumer.
50787
+ *
50788
+ * Abierto vía `ModalService` — recibe `_modalRef` por `componentProps`. Header
50789
+ * canónico (Regla #5): sin `ion-title`, botón Cerrar texto en `slot=end`,
50790
+ * título/subtítulo en el body con `val-display`/`val-title`.
50791
+ *
50792
+ * Auto-registra sus defaults i18n (es/en) si el consumer no proveyó el namespace
50793
+ * `Settings.TransferModal`.
50794
+ */
50795
+ class TransferOwnershipModalComponent {
50796
+ constructor() {
50797
+ this.i18n = inject(I18nService);
50798
+ /** Lista de miembros transferibles (todos excepto el owner actual). */
50799
+ this.members = [];
50800
+ /** Namespace i18n con que la vista resuelve sus textos. */
50801
+ this.i18nNamespace = DEFAULT_NAMESPACE$8;
50802
+ this.query = signal('');
50803
+ this.selectedId = signal('');
50804
+ this.filtered = computed(() => {
50805
+ const q = this.query().trim().toLowerCase();
50806
+ if (!q)
50807
+ return this.members;
50808
+ return this.members.filter(m => m.name?.toLowerCase().includes(q) || m.email?.toLowerCase().includes(q) || m.userId.toLowerCase().includes(q));
50809
+ });
50810
+ this.noResultsLabel = computed(() => `${this.t('noResults')} "${this.query()}"`);
50811
+ if (!this.i18n.hasNamespace(DEFAULT_NAMESPACE$8)) {
50812
+ this.i18n.registerContent(DEFAULT_NAMESPACE$8, TRANSFER_OWNERSHIP_MODAL_I18N);
50813
+ }
50814
+ }
50815
+ ngOnInit() {
50816
+ if (!this.i18n.hasNamespace(this.i18nNamespace)) {
50817
+ this.i18n.registerContent(this.i18nNamespace, TRANSFER_OWNERSHIP_MODAL_I18N);
50818
+ }
50819
+ }
50820
+ confirm() {
50821
+ const id = this.selectedId();
50822
+ if (!id)
50823
+ return;
50824
+ this.onConfirm?.(id);
50825
+ this.dismiss();
50826
+ }
50827
+ dismiss() {
50828
+ this._modalRef?.dismiss(null, 'cancel');
50829
+ }
50830
+ t(key) {
50831
+ return this.i18n.t(key, this.i18nNamespace);
50832
+ }
50833
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TransferOwnershipModalComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
50834
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: TransferOwnershipModalComponent, isStandalone: true, selector: "val-transfer-ownership-modal", inputs: { members: "members", onConfirm: "onConfirm", _modalRef: "_modalRef", i18nNamespace: "i18nNamespace" }, ngImport: i0, template: `
50835
+ <ion-header>
50836
+ <ion-toolbar>
50837
+ <ion-buttons slot="end">
50838
+ <ion-button fill="clear" color="dark" shape="round" (click)="dismiss()">
50839
+ <strong>{{ t('close') }}</strong>
50840
+ </ion-button>
50841
+ </ion-buttons>
50842
+ </ion-toolbar>
50843
+ </ion-header>
50844
+
50845
+ <ion-content class="ion-padding">
50846
+ <val-display [props]="{ content: t('title'), size: 'small', color: 'dark' }" />
50847
+ <val-title
50848
+ [props]="{
50849
+ content: t('hint'),
50850
+ size: 'large',
50851
+ color: '',
50852
+ bold: false,
50853
+ }"
50854
+ />
50855
+
50856
+ <val-searchbar
50857
+ [props]="{ placeholder: t('searchPlaceholder'), debounce: 150 }"
50858
+ (filterEvent)="query.set($event)"
50859
+ />
50860
+
50861
+ @if (members.length === 0) {
50862
+ <val-text
50863
+ [props]="{
50864
+ content: t('empty'),
50865
+ size: 'medium',
50866
+ color: 'medium',
50867
+ bold: false,
50868
+ }"
50869
+ />
50870
+ } @else if (filtered().length === 0) {
50871
+ <val-text
50872
+ [props]="{
50873
+ content: noResultsLabel(),
50874
+ size: 'medium',
50875
+ color: 'medium',
50876
+ bold: false,
50877
+ }"
50878
+ />
50879
+ } @else {
50880
+ <div class="transfer-member-list">
50881
+ @for (m of filtered(); track m.userId) {
50882
+ <button
50883
+ class="transfer-member-row"
50884
+ [class.transfer-member-row--selected]="selectedId() === m.userId"
50885
+ (click)="selectedId.set(m.userId)"
50886
+ type="button"
50887
+ >
50888
+ <span class="transfer-member-name">{{ m.name || m.email || m.userId }}</span>
50889
+ @if (selectedId() === m.userId) {
50890
+ <span class="transfer-member-check">&#10003;</span>
50891
+ }
50892
+ </button>
50893
+ }
50894
+ </div>
50895
+ }
50896
+
50897
+ <div class="transfer-confirm-row">
50898
+ <ion-button expand="block" color="dark" shape="round" [disabled]="!selectedId()" (click)="confirm()">
50899
+ {{ t('confirmCta') }}
50900
+ </ion-button>
50901
+ </div>
50902
+
50903
+ <div class="modal-close-bottom">
50904
+ <ion-button expand="block" fill="outline" color="dark" shape="round" (click)="dismiss()">
50905
+ {{ t('close') }}
50906
+ </ion-button>
50907
+ </div>
50908
+ </ion-content>
50909
+ `, isInline: true, styles: ["val-display{display:block;margin-bottom:4px}val-title{display:block;margin-bottom:16px}val-searchbar{display:block;margin-bottom:12px}.transfer-member-list{display:flex;flex-direction:column;gap:4px}.transfer-member-row{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-radius:10px;border:1.5px solid var(--ion-color-light);background:transparent;cursor:pointer;width:100%;text-align:left;font-size:15px;color:var(--ion-color-dark);transition:border-color .15s}.transfer-member-row--selected{border-color:var(--ion-color-dark);background:var(--ion-color-light)}.transfer-member-check{font-size:17px;color:var(--ion-color-dark)}.transfer-confirm-row{margin-top:24px}.modal-close-bottom{margin-top:12px}\n"], dependencies: [{ kind: "component", type: IonHeader, selector: "ion-header", inputs: ["collapse", "mode", "translucent"] }, { kind: "component", type: IonToolbar, selector: "ion-toolbar", inputs: ["color", "mode"] }, { kind: "component", type: IonButtons, selector: "ion-buttons", inputs: ["collapse"] }, { kind: "component", type: IonButton, selector: "ion-button", inputs: ["buttonType", "color", "disabled", "download", "expand", "fill", "form", "href", "mode", "rel", "routerAnimation", "routerDirection", "shape", "size", "strong", "target", "type"] }, { kind: "component", type: IonContent, selector: "ion-content", inputs: ["color", "fixedSlotPlacement", "forceOverscroll", "fullscreen", "scrollEvents", "scrollX", "scrollY"] }, { kind: "component", type: SearchbarComponent, selector: "val-searchbar", inputs: ["preset", "props"], outputs: ["filterEvent", "focusEvent", "blurEvent"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }] }); }
50910
+ }
50911
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: TransferOwnershipModalComponent, decorators: [{
50912
+ type: Component,
50913
+ args: [{ selector: 'val-transfer-ownership-modal', standalone: true, imports: [
50914
+ IonHeader,
50915
+ IonToolbar,
50916
+ IonButtons,
50917
+ IonButton,
50918
+ IonContent,
50919
+ SearchbarComponent,
50920
+ DisplayComponent,
50921
+ TextComponent,
50922
+ TitleComponent,
50923
+ ], template: `
50924
+ <ion-header>
50925
+ <ion-toolbar>
50926
+ <ion-buttons slot="end">
50927
+ <ion-button fill="clear" color="dark" shape="round" (click)="dismiss()">
50928
+ <strong>{{ t('close') }}</strong>
50929
+ </ion-button>
50930
+ </ion-buttons>
50931
+ </ion-toolbar>
50932
+ </ion-header>
50933
+
50934
+ <ion-content class="ion-padding">
50935
+ <val-display [props]="{ content: t('title'), size: 'small', color: 'dark' }" />
50936
+ <val-title
50937
+ [props]="{
50938
+ content: t('hint'),
50939
+ size: 'large',
50940
+ color: '',
50941
+ bold: false,
50942
+ }"
50943
+ />
50944
+
50945
+ <val-searchbar
50946
+ [props]="{ placeholder: t('searchPlaceholder'), debounce: 150 }"
50947
+ (filterEvent)="query.set($event)"
50948
+ />
50949
+
50950
+ @if (members.length === 0) {
50951
+ <val-text
50952
+ [props]="{
50953
+ content: t('empty'),
50954
+ size: 'medium',
50955
+ color: 'medium',
50956
+ bold: false,
50957
+ }"
50958
+ />
50959
+ } @else if (filtered().length === 0) {
50960
+ <val-text
50961
+ [props]="{
50962
+ content: noResultsLabel(),
50963
+ size: 'medium',
50964
+ color: 'medium',
50965
+ bold: false,
50966
+ }"
50967
+ />
50968
+ } @else {
50969
+ <div class="transfer-member-list">
50970
+ @for (m of filtered(); track m.userId) {
50971
+ <button
50972
+ class="transfer-member-row"
50973
+ [class.transfer-member-row--selected]="selectedId() === m.userId"
50974
+ (click)="selectedId.set(m.userId)"
50975
+ type="button"
50976
+ >
50977
+ <span class="transfer-member-name">{{ m.name || m.email || m.userId }}</span>
50978
+ @if (selectedId() === m.userId) {
50979
+ <span class="transfer-member-check">&#10003;</span>
50980
+ }
50981
+ </button>
50982
+ }
50983
+ </div>
50984
+ }
50985
+
50986
+ <div class="transfer-confirm-row">
50987
+ <ion-button expand="block" color="dark" shape="round" [disabled]="!selectedId()" (click)="confirm()">
50988
+ {{ t('confirmCta') }}
50989
+ </ion-button>
50990
+ </div>
50247
50991
 
50248
50992
  <div class="modal-close-bottom">
50249
50993
  <ion-button expand="block" fill="outline" color="dark" shape="round" (click)="dismiss()">
@@ -50341,7 +51085,7 @@ const MEMBER_IMPORT_MODAL_I18N = {
50341
51085
  },
50342
51086
  };
50343
51087
 
50344
- const DEFAULT_NAMESPACE$8 = 'Settings.ImportModal';
51088
+ const DEFAULT_NAMESPACE$7 = 'Settings.ImportModal';
50345
51089
  addIcons({ cloudUploadOutline, documentTextOutline });
50346
51090
  /**
50347
51091
  * `val-member-import-modal` — panel de carga masiva de miembros (ADR-023 fase 6).
@@ -50361,7 +51105,7 @@ class MemberImportModalComponent {
50361
51105
  /** Nombres de roles disponibles (para el hint). Opcional. */
50362
51106
  this.availableRoles = [];
50363
51107
  /** Namespace i18n. */
50364
- this.i18nNamespace = DEFAULT_NAMESPACE$8;
51108
+ this.i18nNamespace = DEFAULT_NAMESPACE$7;
50365
51109
  this.csv = '';
50366
51110
  this.importMode = signal('file');
50367
51111
  this.onConflictControl = new FormControl('assignRole');
@@ -50403,8 +51147,8 @@ class MemberImportModalComponent {
50403
51147
  return this.t('summary');
50404
51148
  return `${this.t('summary')}: ${s.created} ${this.t('created')} · ${s.assigned} ${this.t('assigned')} · ${s.skipped} ${this.t('skipped')} · ${s.failed} ${this.t('failed')}`;
50405
51149
  });
50406
- if (!this.i18n.hasNamespace(DEFAULT_NAMESPACE$8)) {
50407
- this.i18n.registerContent(DEFAULT_NAMESPACE$8, MEMBER_IMPORT_MODAL_I18N);
51150
+ if (!this.i18n.hasNamespace(DEFAULT_NAMESPACE$7)) {
51151
+ this.i18n.registerContent(DEFAULT_NAMESPACE$7, MEMBER_IMPORT_MODAL_I18N);
50408
51152
  }
50409
51153
  }
50410
51154
  ngOnInit() {
@@ -50996,7 +51740,7 @@ const ORGANIZATION_VIEW_I18N = {
50996
51740
  },
50997
51741
  };
50998
51742
 
50999
- const DEFAULT_NAMESPACE$7 = 'Settings.Organization';
51743
+ const DEFAULT_NAMESPACE$6 = 'Settings.Organization';
51000
51744
  const DEFAULT_VIEW_PERMISSIONS_ROUTE = '/app/settings/permissions';
51001
51745
  const DEFAULT_API_KEYS_ROUTE = '/app/settings/api-keys';
51002
51746
  const DEFAULT_LEAVE_REDIRECT_ROUTE = '/app/settings/account';
@@ -51054,7 +51798,7 @@ class OrganizationViewComponent {
51054
51798
  showInvite: merged.showInvite ?? true,
51055
51799
  showTransferOwnership: merged.showTransferOwnership ?? true,
51056
51800
  showLeave: merged.showLeave ?? true,
51057
- i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$7,
51801
+ i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$6,
51058
51802
  viewPermissionsRoute: merged.viewPermissionsRoute ?? DEFAULT_VIEW_PERMISSIONS_ROUTE,
51059
51803
  apiKeysRoute: merged.apiKeysRoute ?? DEFAULT_API_KEYS_ROUTE,
51060
51804
  leaveRedirectRoute: merged.leaveRedirectRoute ?? DEFAULT_LEAVE_REDIRECT_ROUTE,
@@ -51067,6 +51811,7 @@ class OrganizationViewComponent {
51067
51811
  this.org = signal(null);
51068
51812
  this.loading = signal(false);
51069
51813
  this.leaving = signal(false);
51814
+ this.permissionsOpen = signal(false);
51070
51815
  this.orgLoadError = signal(null);
51071
51816
  this.orgErrorState = computed(() => {
51072
51817
  this.i18n.lang();
@@ -51365,7 +52110,7 @@ class OrganizationViewComponent {
51365
52110
  });
51366
52111
  }
51367
52112
  onViewPermissions() {
51368
- this.nav.navigateByUrl(this.resolvedConfig().viewPermissionsRoute);
52113
+ this.permissionsOpen.set(true);
51369
52114
  }
51370
52115
  onOpenTransfer() {
51371
52116
  void this.modalService.openAdaptive({
@@ -51838,8 +52583,13 @@ class OrganizationViewComponent {
51838
52583
  </div>
51839
52584
  </section>
51840
52585
  }
52586
+ <val-permissions-modal
52587
+ [isOpen]="permissionsOpen()"
52588
+ [config]="{ i18nNamespace: resolvedConfig().i18nNamespace }"
52589
+ (dismissed)="permissionsOpen.set(false)"
52590
+ />
51841
52591
  </div>
51842
- `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.org-more-info-link{background:none;border:none;padding:4px 0;margin-top:4px;font-size:13px;font-weight:600;color:var(--ion-color-primary, #7026df);cursor:pointer;text-align:left}.page-header{margin-bottom:16px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-header-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}.section-body{display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.row-actions--gap{display:flex;gap:8px}.invite-cta{display:flex;flex-direction:column;gap:16px}.invite-cta__text{display:flex;flex-direction:column;gap:4px}.invite-cta__actions{display:flex;gap:12px;flex-wrap:wrap}.org-info-card{border-radius:14px;background:var(--ion-color-light, #f4f5f8);overflow:hidden}:host-context(body.dark) .org-info-card,:host-context(html.ion-palette-dark) .org-info-card,:host-context([data-theme=\"dark\"]) .org-info-card{background:#ffffff0d}.org-info-field{padding:12px 16px;display:flex;flex-direction:column;gap:4px;border-bottom:1px solid var(--val-border-color, rgba(0, 0, 0, .06))}.org-info-field:last-child{border-bottom:none}.org-info-label{font-size:.74rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--ion-color-medium)}.org-info-value{font-size:.95rem;font-weight:500;color:var(--ion-color-dark)}.org-info-value--muted{color:var(--ion-color-medium);font-weight:400}.plan-badge{display:inline-block;font-size:.78rem;font-weight:700;padding:3px 10px;border-radius:20px;width:fit-content}.plan-badge--free{background:var(--ion-color-light-shade, #d7d8da);color:var(--ion-color-dark)}.plan-badge--pro{background:var(--ion-color-primary);color:#fff}.plan-badge--enterprise{background:#f5c542;color:#222}.members-list{display:flex;flex-direction:column;gap:8px}.members-show-more{background:none;border:none;color:var(--ion-color-primary);font-size:14px;font-weight:500;cursor:pointer;padding:8px 0}.rbac-debug{opacity:.7}.rbac-debug__body{display:flex;flex-direction:column;gap:6px;font-family:monospace;font-size:.78rem}.rbac-debug__row{display:flex;gap:8px;align-items:flex-start}.rbac-debug__label{color:var(--ion-color-medium);min-width:72px;flex-shrink:0}.rbac-debug__value{color:var(--ion-color-dark);word-break:break-all}.rbac-debug__value--perms{color:var(--ion-color-medium)}\n"], dependencies: [{ kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: EmptyStateComponent, selector: "val-empty-state", inputs: ["props"] }, { kind: "directive", type: HasPermissionDirective, selector: "[valHasPermission]", inputs: ["valHasPermission"] }, { kind: "component", type: SkeletonLayoutComponent, selector: "val-skeleton-layout", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: MemberCardComponent, selector: "val-member-card", inputs: ["props"], outputs: ["onAction"] }] }); }
52592
+ `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.org-more-info-link{background:none;border:none;padding:4px 0;margin-top:4px;font-size:13px;font-weight:600;color:var(--ion-color-primary, #7026df);cursor:pointer;text-align:left}.page-header{margin-bottom:16px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-header-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}.section-body{display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.row-actions--gap{display:flex;gap:8px}.invite-cta{display:flex;flex-direction:column;gap:16px}.invite-cta__text{display:flex;flex-direction:column;gap:4px}.invite-cta__actions{display:flex;gap:12px;flex-wrap:wrap}.org-info-card{border-radius:14px;background:var(--ion-color-light, #f4f5f8);overflow:hidden}:host-context(body.dark) .org-info-card,:host-context(html.ion-palette-dark) .org-info-card,:host-context([data-theme=\"dark\"]) .org-info-card{background:#ffffff0d}.org-info-field{padding:12px 16px;display:flex;flex-direction:column;gap:4px;border-bottom:1px solid var(--val-border-color, rgba(0, 0, 0, .06))}.org-info-field:last-child{border-bottom:none}.org-info-label{font-size:.74rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--ion-color-medium)}.org-info-value{font-size:.95rem;font-weight:500;color:var(--ion-color-dark)}.org-info-value--muted{color:var(--ion-color-medium);font-weight:400}.plan-badge{display:inline-block;font-size:.78rem;font-weight:700;padding:3px 10px;border-radius:20px;width:fit-content}.plan-badge--free{background:var(--ion-color-light-shade, #d7d8da);color:var(--ion-color-dark)}.plan-badge--pro{background:var(--ion-color-primary);color:#fff}.plan-badge--enterprise{background:#f5c542;color:#222}.members-list{display:flex;flex-direction:column;gap:8px}.members-show-more{background:none;border:none;color:var(--ion-color-primary);font-size:14px;font-weight:500;cursor:pointer;padding:8px 0}.rbac-debug{opacity:.7}.rbac-debug__body{display:flex;flex-direction:column;gap:6px;font-family:monospace;font-size:.78rem}.rbac-debug__row{display:flex;gap:8px;align-items:flex-start}.rbac-debug__label{color:var(--ion-color-medium);min-width:72px;flex-shrink:0}.rbac-debug__value{color:var(--ion-color-dark);word-break:break-all}.rbac-debug__value--perms{color:var(--ion-color-medium)}\n"], dependencies: [{ kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: EmptyStateComponent, selector: "val-empty-state", inputs: ["props"] }, { kind: "directive", type: HasPermissionDirective, selector: "[valHasPermission]", inputs: ["valHasPermission"] }, { kind: "component", type: SkeletonLayoutComponent, selector: "val-skeleton-layout", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: MemberCardComponent, selector: "val-member-card", inputs: ["props"], outputs: ["onAction"] }, { kind: "component", type: PermissionsModalComponent, selector: "val-permissions-modal", inputs: ["isOpen", "config"], outputs: ["dismissed"] }] }); }
51843
52593
  }
51844
52594
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: OrganizationViewComponent, decorators: [{
51845
52595
  type: Component,
@@ -51852,6 +52602,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
51852
52602
  TextComponent,
51853
52603
  ButtonComponent,
51854
52604
  MemberCardComponent,
52605
+ PermissionsModalComponent,
51855
52606
  TransferOwnershipModalComponent,
51856
52607
  MemberImportModalComponent,
51857
52608
  OrgInfoSheetComponent,
@@ -51928,1618 +52679,1007 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
51928
52679
  <val-title
51929
52680
  [props]="{
51930
52681
  size: 'medium',
51931
- color: 'dark',
51932
- bold: true,
51933
- content: tt('membersTitle'),
51934
- }"
51935
- />
51936
- <div class="section-body">
51937
- @if (membersLoading()) {
51938
- <val-skeleton-layout [props]="{ preset: 'list', rows: 3 }" />
51939
- } @else if (membersErrorState()) {
51940
- <val-empty-state [props]="membersErrorState()!" />
51941
- } @else if (members().length === 0) {
51942
- <val-text
51943
- [props]="{
51944
- content: tt('membersEmpty'),
51945
- size: 'medium',
51946
- color: 'dark',
51947
- bold: false,
51948
- }"
51949
- />
51950
- } @else {
51951
- <div class="members-list">
51952
- @for (m of visibleMembers(); track m.userId) {
51953
- <val-member-card
51954
- [attr.data-testid]="'org-member-' + m.userId"
51955
- [props]="{
51956
- id: m.userId,
51957
- name: m.name,
51958
- handle: m.handle,
51959
- email: m.email,
51960
- avatarUrl: m.avatar,
51961
- roleLabel: m.roles[0] ? roleLabel(m.roles[0]) : '',
51962
- actionAriaLabel: tt('viewMemberCta'),
51963
- }"
51964
- (onAction)="onViewMember(m)"
51965
- />
51966
- }
51967
- </div>
51968
- @if (!showAllMembers() && members().length > 3) {
51969
- <button class="members-show-more" (click)="showAllMembers.set(true)">
51970
- {{ tt('membersShowMore') }} ({{ members().length - 3 }})
51971
- </button>
51972
- }
51973
- @if (showAllMembers() && membersNextToken()) {
51974
- <button class="members-show-more" [disabled]="loadingMoreMembers()" (click)="loadMoreMembers()">
51975
- {{ loadingMoreMembers() ? '…' : tt('membersShowMore') }}
51976
- </button>
51977
- }
51978
- }
51979
- </div>
51980
- </section>
51981
- }
51982
-
51983
- @if (resolvedConfig().showRoles) {
51984
- <!-- Roles y permisos: seccion explicativa, detalle en la ruta de permisos -->
51985
- <section class="settings-section">
51986
- <val-title
51987
- [props]="{
51988
- size: 'medium',
51989
- color: 'dark',
51990
- bold: true,
51991
- content: tt('rolesSectionTitle'),
51992
- }"
51993
- />
51994
- <div class="section-body">
51995
- <val-text
51996
- [props]="{
51997
- content: tt('rolesHint'),
51998
- size: 'medium',
51999
- color: 'dark',
52000
- bold: false,
52001
- }"
52002
- />
52003
- <div class="row-actions">
52004
- <val-button [props]="viewPermissionsButtonProps()" (click)="onViewPermissions()" />
52005
- </div>
52006
- </div>
52007
- </section>
52008
- }
52009
-
52010
- @if (resolvedConfig().showInvite) {
52011
- <!-- Invite: solo admin+ (gating RBAC interno) -->
52012
- <section *valHasPermission="'users:*'" class="settings-section" data-testid="org-invite-section">
52013
- <div class="invite-cta">
52014
- <div class="invite-cta__text">
52015
- <val-title
52016
- [props]="{
52017
- size: 'medium',
52018
- color: 'dark',
52019
- bold: true,
52020
- content: tt('inviteCtaTitle'),
52021
- }"
52022
- />
52023
- <val-text
52024
- [props]="{
52025
- size: 'medium',
52026
- color: 'dark',
52027
- bold: false,
52028
- content: tt('inviteCtaHint'),
52029
- }"
52030
- />
52031
- </div>
52032
- <div class="invite-cta__actions">
52033
- <val-button [props]="openInviteButtonProps()" (click)="onOpenInvite()" />
52034
- <val-button [props]="openImportButtonProps()" (click)="onOpenImport()" />
52035
- </div>
52036
- </div>
52037
- </section>
52038
- }
52039
-
52040
- <!-- API Keys: sección propia. Integraciones M2M (import de miembros, etc.). -->
52041
- <section *valHasPermission="'api-keys:read'" class="settings-section" data-testid="org-apikeys-section">
52042
- <div class="invite-cta">
52043
- <div class="invite-cta__text">
52044
- <val-title [props]="{ size: 'medium', color: 'dark', bold: true, content: tt('apiKeysSectionTitle') }" />
52045
- <val-text [props]="{ size: 'medium', color: 'dark', bold: false, content: tt('apiKeysSectionHint') }" />
52046
- <button class="org-more-info-link" type="button" (click)="onApiKeysInfo()">
52047
- {{ tt('apiKeysMoreInfo') }}
52048
- </button>
52049
- </div>
52050
- <div class="invite-cta__actions">
52051
- <val-button [props]="openApiKeysButtonProps()" (click)="onOpenApiKeys()" />
52052
- </div>
52053
- </div>
52054
- </section>
52055
-
52056
- @if (resolvedConfig().showTransferOwnership && isOwner()) {
52057
- <!-- Transfer ownership: solo owner (gating RBAC interno) -->
52058
- <section class="settings-section" data-testid="org-transfer-section">
52059
- <val-title
52060
- [props]="{
52061
- size: 'medium',
52062
- color: 'dark',
52063
- bold: true,
52064
- content: tt('transferTitle'),
52065
- }"
52066
- />
52067
- <div class="section-body">
52068
- <val-text
52069
- [props]="{
52070
- content: tt('transferHint'),
52071
- size: 'medium',
52072
- color: 'dark',
52073
- bold: false,
52074
- }"
52075
- />
52076
- @if (transferableMembers().length > 0) {
52077
- <div class="row-actions">
52078
- <val-button [props]="transferButtonProps()" (click)="onOpenTransfer()" />
52079
- </div>
52080
- } @else {
52081
- <val-text
52082
- [props]="{
52083
- content: tt('membersEmpty'),
52084
- size: 'medium',
52085
- color: 'dark',
52086
- bold: false,
52087
- }"
52088
- />
52089
- }
52090
- </div>
52091
- </section>
52092
- }
52093
-
52094
- @if (resolvedConfig().showLeave) {
52095
- <!-- Leave org -->
52096
- <section class="settings-section">
52097
- <val-title
52098
- [props]="{
52099
- size: 'medium',
52100
- color: 'dark',
52101
- bold: true,
52102
- content: tt('leaveTitle'),
52103
- }"
52104
- />
52105
- <div class="section-body">
52106
- <val-text
52107
- [props]="{
52108
- content: isOwner() ? tt('leaveOwnerHint') : tt('leaveHint'),
52109
- size: 'medium',
52110
- color: 'dark',
52111
- bold: false,
52112
- }"
52113
- />
52114
- <div class="row-actions">
52115
- <val-button [props]="leaveButtonProps()" (click)="onLeave()" />
52116
- </div>
52117
- </div>
52118
- </section>
52119
- }
52120
-
52121
- @if (isDebug) {
52122
- <section class="settings-section rbac-debug">
52123
- <div class="section-header-row">
52124
- <val-title
52125
- [props]="{
52126
- content: 'RBAC (live)',
52127
- size: 'small',
52128
- color: 'medium',
52129
- bold: false,
52130
- }"
52131
- />
52132
- </div>
52133
- <div class="rbac-debug__body">
52134
- <div class="rbac-debug__row">
52135
- <span class="rbac-debug__label">Roles</span>
52136
- <span class="rbac-debug__value">{{ auth.roles().join(', ') || '—' }}</span>
52137
- </div>
52138
- <div class="rbac-debug__row">
52139
- <span class="rbac-debug__label">Permisos</span>
52140
- <span class="rbac-debug__value rbac-debug__value--perms">{{ auth.permissions().join(', ') || '—' }}</span>
52141
- </div>
52142
- </div>
52143
- </section>
52144
- }
52145
- </div>
52146
- `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.org-more-info-link{background:none;border:none;padding:4px 0;margin-top:4px;font-size:13px;font-weight:600;color:var(--ion-color-primary, #7026df);cursor:pointer;text-align:left}.page-header{margin-bottom:16px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-header-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}.section-body{display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.row-actions--gap{display:flex;gap:8px}.invite-cta{display:flex;flex-direction:column;gap:16px}.invite-cta__text{display:flex;flex-direction:column;gap:4px}.invite-cta__actions{display:flex;gap:12px;flex-wrap:wrap}.org-info-card{border-radius:14px;background:var(--ion-color-light, #f4f5f8);overflow:hidden}:host-context(body.dark) .org-info-card,:host-context(html.ion-palette-dark) .org-info-card,:host-context([data-theme=\"dark\"]) .org-info-card{background:#ffffff0d}.org-info-field{padding:12px 16px;display:flex;flex-direction:column;gap:4px;border-bottom:1px solid var(--val-border-color, rgba(0, 0, 0, .06))}.org-info-field:last-child{border-bottom:none}.org-info-label{font-size:.74rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--ion-color-medium)}.org-info-value{font-size:.95rem;font-weight:500;color:var(--ion-color-dark)}.org-info-value--muted{color:var(--ion-color-medium);font-weight:400}.plan-badge{display:inline-block;font-size:.78rem;font-weight:700;padding:3px 10px;border-radius:20px;width:fit-content}.plan-badge--free{background:var(--ion-color-light-shade, #d7d8da);color:var(--ion-color-dark)}.plan-badge--pro{background:var(--ion-color-primary);color:#fff}.plan-badge--enterprise{background:#f5c542;color:#222}.members-list{display:flex;flex-direction:column;gap:8px}.members-show-more{background:none;border:none;color:var(--ion-color-primary);font-size:14px;font-weight:500;cursor:pointer;padding:8px 0}.rbac-debug{opacity:.7}.rbac-debug__body{display:flex;flex-direction:column;gap:6px;font-family:monospace;font-size:.78rem}.rbac-debug__row{display:flex;gap:8px;align-items:flex-start}.rbac-debug__label{color:var(--ion-color-medium);min-width:72px;flex-shrink:0}.rbac-debug__value{color:var(--ion-color-dark);word-break:break-all}.rbac-debug__value--perms{color:var(--ion-color-medium)}\n"] }]
52147
- }], ctorParameters: () => [], propDecorators: { config: [{
52148
- type: Input
52149
- }] } });
52150
-
52151
- /**
52152
- * Helper para montar la vista Gestión de organización (`val-organization-view`)
52153
- * como ruta en una app del factory. El `config` se pasa por route `data`
52154
- * (`organizationConfig`) y el componente lo lee como fallback de su
52155
- * `@Input() config`.
52156
- *
52157
- * @example
52158
- * ```ts
52159
- * // settings.routes.ts
52160
- * export const settingsRoutes: Routes = [
52161
- * ...provideValtechOrganizationRoutes(), // → /app/settings/organization
52162
- * { path: 'preferences', loadComponent: () => ... },
52163
- * ];
52164
- *
52165
- * // con config acotada:
52166
- * ...provideValtechOrganizationRoutes({ config: { showLeave: false } }),
52167
- * ...provideValtechOrganizationRoutes({ config: { viewPermissionsRoute: '/app/roles' } }),
52168
- * ```
52169
- */
52170
- function provideValtechOrganizationRoutes(opts) {
52171
- return [
52172
- {
52173
- path: opts?.path ?? 'organization',
52174
- component: OrganizationViewComponent,
52175
- data: { organizationConfig: opts?.config },
52176
- },
52177
- ];
52178
- }
52179
-
52180
- /**
52181
- * Defaults i18n (es/en) embebidos en `val-notification-preferences-view`.
52182
- * Auto-registrados en el constructor del componente si el consumer no proveyó
52183
- * el namespace (`Settings.Notifications` por default). SOLO incluye las keys que
52184
- * la vista usa.
52185
- *
52186
- * Portado del override `Settings.Notifications` (showcase) bajo el proceso de
52187
- * ADR-021.
52188
- */
52189
- const NOTIFICATION_PREFERENCES_VIEW_I18N = {
52190
- es: {
52191
- pageTitle: 'Notificaciones',
52192
- pageDescription: 'Configura como quieres recibir notificaciones',
52193
- // Control principal (UX simplificado)
52194
- simpleTitle: 'Notificaciones push en este dispositivo',
52195
- simpleDescription: 'Recibe alertas en tiempo real incluso con la app cerrada.',
52196
- enabling: 'Activando…',
52197
- disabling: 'Desactivando…',
52198
- enabled: 'Activadas',
52199
- errorGeneric: 'No pudimos completar la configuración. Verifica los permisos del navegador y reintenta.',
52200
- retryCta: 'Reintentar',
52201
- // Mensajes contextuales
52202
- permissionUnsupported: 'Tu navegador no soporta notificaciones push.',
52203
- permissionDeniedInfo: 'Tu navegador esta bloqueando las notificaciones. Para habilitarlas, ve a la configuracion del sitio y permitelas manualmente.',
52204
- // iOS PWA install
52205
- iosInstallTitle: 'En iOS, instala la app para recibir notificaciones',
52206
- iosInstallHint: 'Las notificaciones push en iOS (iPhone/iPad) solo funcionan cuando la app está instalada en la pantalla de inicio. Edge, Chrome y Firefox en iOS comparten esta limitación.',
52207
- iosInstallStep1: 'Abre este sitio en Safari (no en Chrome/Edge).',
52208
- iosInstallStep2: 'Toca el botón Compartir (□ con flecha hacia arriba).',
52209
- iosInstallStep3: "Elige 'Añadir a pantalla de inicio'.",
52210
- iosInstallStep4: 'Abre la app desde el ícono recién creado y vuelve aquí.',
52211
- // Detalles técnicos (expandible)
52212
- detailsShow: 'Ver detalles técnicos',
52213
- detailsHide: 'Ocultar detalles',
52214
- detailsToken: 'Token',
52215
- detailsNone: 'Sin registrar',
52216
- diagnosticsPermission: 'Permiso del navegador',
52217
- diagnosticsSupported: 'Soporte del navegador',
52218
- // Toasts
52219
- enableSuccess: 'Notificaciones habilitadas',
52220
- disableSuccess: 'Notificaciones deshabilitadas',
52221
- },
52222
- en: {
52223
- pageTitle: 'Notifications',
52224
- pageDescription: 'Configure how you want to receive notifications',
52225
- simpleTitle: 'Push notifications on this device',
52226
- simpleDescription: 'Get real-time alerts even when the app is closed.',
52227
- enabling: 'Enabling…',
52228
- disabling: 'Disabling…',
52229
- enabled: 'Enabled',
52230
- errorGeneric: "We couldn't complete the setup. Check your browser permissions and retry.",
52231
- retryCta: 'Retry',
52232
- permissionUnsupported: 'Your browser does not support push notifications.',
52233
- permissionDeniedInfo: 'Your browser is blocking notifications. To enable them, go to site settings and allow them manually.',
52234
- iosInstallTitle: 'On iOS, install the app to receive notifications',
52235
- iosInstallHint: 'Push notifications on iOS (iPhone/iPad) only work when the app is installed on the Home Screen. Edge, Chrome and Firefox on iOS share this limitation.',
52236
- iosInstallStep1: 'Open this site in Safari (not Chrome/Edge).',
52237
- iosInstallStep2: 'Tap the Share button (□ with an up arrow).',
52238
- iosInstallStep3: "Choose 'Add to Home Screen'.",
52239
- iosInstallStep4: 'Open the app from the new icon and come back here.',
52240
- detailsShow: 'View technical details',
52241
- detailsHide: 'Hide details',
52242
- detailsToken: 'Token',
52243
- detailsNone: 'Not registered',
52244
- diagnosticsPermission: 'Browser permission',
52245
- diagnosticsSupported: 'Browser support',
52246
- enableSuccess: 'Notifications enabled',
52247
- disableSuccess: 'Notifications disabled',
52248
- },
52249
- };
52250
-
52251
- addIcons({
52252
- alertCircleOutline,
52253
- chevronDownOutline,
52254
- chevronUpOutline,
52255
- closeCircleOutline,
52256
- notificationsOffOutline,
52257
- notificationsOutline,
52258
- refreshOutline,
52259
- });
52260
- const DEFAULT_NAMESPACE$6 = 'Settings.Notifications';
52261
- /**
52262
- * `val-notification-preferences-view` — **preferencias de notificaciones push**
52263
- * (FCM) full-feature autocontenida (organism). Promovida desde `showcase` bajo el
52264
- * proceso de ADR-021.
52265
- *
52266
- * Separada de `val-notifications-view` (el inbox / feed). Aquí el user **activa o
52267
- * desactiva** las notificaciones push en este dispositivo.
52268
- *
52269
- * UX simplificado: UN solo toggle que ejecuta toda la secuencia (pedir permiso →
52270
- * SW ready → getToken → registrar en backend → set master pref) vía
52271
- * `MessagingService.enable()`. El registro del device en el backend va como
52272
- * callback (la lib no depende de `AuthService` para evitar ciclos). Mensajes
52273
- * contextuales y botón "Reintentar" aparecen solo cuando aplican.
52274
- *
52275
- * NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
52276
- * inyecta `{ optional: true }` → funciona sin router (embebida / Storybook).
52277
- */
52278
- class NotificationPreferencesViewComponent {
52279
- /** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
52280
- get ns() {
52281
- return this.resolvedConfig().i18nNamespace;
52282
- }
52283
- // ---------------------------------------------------------------------------
52284
- // Lifecycle
52285
- // ---------------------------------------------------------------------------
52286
- constructor() {
52287
- this.nav = inject(NavigationService);
52288
- this.i18n = inject(I18nService);
52289
- this.toast = inject(ToastService);
52290
- this.auth = inject(AuthService);
52291
- this.messaging = inject(MessagingService);
52292
- this.pageRefresh = inject(PageRefreshService);
52293
- this.prefs = inject(PreferencesService);
52294
- // `ActivatedRoute` opcional → la vista funciona embebida / en Storybook sin
52295
- // router (solo lo usa para leer el route data de config como fallback).
52296
- this.route = inject(ActivatedRoute, { optional: true });
52297
- this.resolvedConfig = computed(() => {
52298
- const fromRoute = (this.route?.snapshot.data['notificationPreferencesConfig'] ??
52299
- {});
52300
- const merged = { ...fromRoute, ...(this.config ?? {}) };
52301
- return {
52302
- showDetails: merged.showDetails ?? true,
52303
- i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$6,
52304
- onPushEnabled: merged.onPushEnabled,
52305
- onPushDisabled: merged.onPushDisabled,
52306
- };
52307
- });
52308
- /** Operación en curso (gobierna spinner del toggle + label "Activando…"). */
52309
- this._busy = signal(null);
52310
- /** True si el último intento falló — gobierna estado `error` + retry button. */
52311
- this._lastFailed = signal(false);
52312
- /** True mientras se re-obtiene el token FCM en background (no afecta la UI). */
52313
- this._repairingToken = false;
52314
- /** Toggle del expand de detalles técnicos. */
52315
- this.showDetails = signal(false);
52316
- this.permission = signal('default');
52317
- this.isSupported = signal(false);
52318
- this.currentToken = signal(null);
52319
- this.needsIOSInstall = signal(this.detectNeedsIOSInstall());
52320
- /** FormControl del toggle (sync-eado con `isEnabled` via effect). */
52321
- this.toggleControl = new FormControl(false, {
52322
- nonNullable: true,
52323
- });
52324
- /** Handler del listener `focus` — referencia guardada para poder removerlo. */
52325
- this.onWindowFocus = () => this.scheduleBrowserRefresh();
52326
- /** Timer del debounce del refresh disparado por `focus`. */
52327
- this.focusDebounceTimer = null;
52328
- /** Ventana de debounce (ms) para el refresh disparado por `focus`. */
52329
- this.FOCUS_DEBOUNCE_MS = 500;
52330
- // ---------------------------------------------------------------------------
52331
- // Estado derivado
52332
- // ---------------------------------------------------------------------------
52333
- this.pageTitle = computed(() => this.tt('pageTitle'));
52334
- this.pageDescription = computed(() => this.tt('pageDescription'));
52335
- /**
52336
- * Estado efectivo del toggle: "el usuario tiene push activo".
52337
- *
52338
- * Se deriva SOLO de la decisión del usuario (`master`) y del permiso del SO
52339
- * (`permission`). El token FCM NO forma parte del estado de UI: vive en
52340
- * memoria, se pierde en cada recarga/suspensión de la PWA y produciría flicker.
52341
- * Si falta token con `master && granted`, es un detalle reparable en
52342
- * background (ver `repairTokenIfNeeded`), no un cambio del toggle.
52343
- */
52344
- this.isEnabled = computed(() => this.prefs.notificationsMaster() && this.permission() === 'granted');
52345
- this.state = computed(() => {
52346
- if (this.needsIOSInstall())
52347
- return 'needs-install';
52348
- if (this.permission() === 'unsupported' || !this.isSupported()) {
52349
- // iOS standalone aún puede dar isSupported false temprano antes de init —
52350
- // solo bloqueamos si el browser explícitamente no soporta APIs.
52351
- if (this.permission() === 'unsupported')
52352
- return 'unsupported';
52353
- }
52354
- const busy = this._busy();
52355
- if (busy === 'enabling')
52356
- return 'enabling';
52357
- if (busy === 'disabling')
52358
- return 'disabling';
52359
- if (this.isEnabled())
52360
- return 'enabled';
52361
- if (this.permission() === 'denied')
52362
- return 'blocked';
52363
- if (this._lastFailed())
52364
- return 'error';
52365
- return 'idle';
52366
- });
52367
- this.busyLabel = computed(() => {
52368
- const s = this.state();
52369
- if (s === 'enabling')
52370
- return this.tt('enabling');
52371
- if (s === 'disabling')
52372
- return this.tt('disabling');
52373
- return null;
52374
- });
52375
- this.contextMessage = computed(() => {
52376
- switch (this.state()) {
52377
- case 'unsupported':
52378
- return this.tt('permissionUnsupported');
52379
- case 'blocked':
52380
- return this.tt('permissionDeniedInfo');
52381
- case 'error':
52382
- return this.tt('errorGeneric');
52383
- default:
52384
- return null;
52385
- }
52386
- });
52387
- this.alertVariant = computed(() => {
52388
- const s = this.state();
52389
- if (s === 'error' || s === 'blocked')
52390
- return 'warning';
52391
- if (s === 'unsupported')
52392
- return 'danger';
52393
- return 'info';
52394
- });
52395
- this.alertIcon = computed(() => {
52396
- const s = this.state();
52397
- if (s === 'unsupported')
52398
- return 'close-circle-outline';
52399
- return 'alert-circle-outline';
52400
- });
52401
- this.toggleProps = computed(() => ({
52402
- control: this.toggleControl,
52403
- color: 'primary',
52404
- disabled: this._busy() !== null ||
52405
- this.state() === 'unsupported' ||
52406
- this.state() === 'needs-install' ||
52407
- this.state() === 'blocked',
52408
- }));
52409
- this.retryButtonProps = computed(() => ({
52410
- text: this.tt('retryCta'),
52411
- color: 'dark',
52412
- fill: 'outline',
52413
- size: 'default',
52414
- shape: 'round',
52415
- type: 'button',
52416
- icon: { name: 'refresh-outline', slot: 'start' },
52417
- state: this._busy() !== null ? ComponentStates.WORKING : ComponentStates.ENABLED,
52418
- }));
52419
- this.tokenPreview = computed(() => {
52420
- const t = this.currentToken();
52421
- if (!t)
52422
- return null;
52423
- return `${t.slice(0, 22)}…${t.slice(-6)}`;
52424
- });
52425
- const ns = this.ns;
52426
- if (!this.i18n.hasNamespace(ns)) {
52427
- this.i18n.registerContent(ns, NOTIFICATION_PREFERENCES_VIEW_I18N);
52428
- }
52429
- this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
52430
- void this.refreshBrowserState();
52431
- // Re-aplicar header en cambio de idioma.
52432
- effect(() => {
52433
- this.i18n.lang();
52434
- this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
52435
- }, { allowSignalWrites: true });
52436
- // Sync FormControl ←→ estado efectivo. Cuando el estado cambia (cambio de
52437
- // permission por el browser, o snapshot Firestore de `master`), el toggle se
52438
- // re-alinea con la realidad.
52439
- //
52440
- // IMPORTANTE: se ignoran los cambios mientras hay una operación en curso
52441
- // (`_busy() !== null`). Durante `onEnable`/`onDisable` las señales fuente se
52442
- // actualizan en momentos distintos y producirían estados intermedios →
52443
- // flicker visible. El toggle se re-alinea UNA sola vez en el `finally`.
52444
- effect(() => {
52445
- const eff = this.isEnabled();
52446
- if (this._busy() !== null)
52447
- return;
52448
- if (this.toggleControl.value !== eff) {
52449
- this.toggleControl.setValue(eff, { emitEvent: false });
52450
- }
52451
- }, { allowSignalWrites: true });
52452
- // Interceptar click del user: el toggle ya cambió visualmente, ejecutar
52453
- // la acción correspondiente.
52454
- this.toggleControl.valueChanges.subscribe(v => {
52455
- if (this._busy() !== null)
52456
- return;
52457
- if (v && !this.isEnabled()) {
52458
- void this.onEnable();
52459
- }
52460
- else if (!v && this.isEnabled()) {
52461
- void this.onDisable();
52462
- }
52463
- });
52464
- // Detectar regreso a la pestaña — el user pudo haber cambiado permisos
52465
- // desde la config del browser. El refresh va con debounce para no
52466
- // dispararse en ráfagas (en PWA mobile `focus` salta con cada cambio de
52467
- // app / aparición del teclado). El listener se remueve en ngOnDestroy.
52468
- if (typeof window !== 'undefined') {
52469
- window.addEventListener('focus', this.onWindowFocus);
52470
- }
52471
- }
52472
- /**
52473
- * Registra el pull-to-refresh estándar. Aunque esta vista no muestra una
52474
- * lista remota, sí refleja estado mutable del browser (permiso, token,
52475
- * soporte FCM) que puede cambiar fuera de la app — el refresh re-evalúa todo.
52476
- *
52477
- * Se usa `ngOnInit`/`ngOnDestroy` (NO `ionViewWillEnter`) porque
52478
- * `val-page-wrapper` usa `<router-outlet>` plano — los hooks `ionView*` solo
52479
- * disparan dentro de `<ion-router-outlet>`.
52480
- */
52481
- ngOnInit() {
52482
- this.pageRefresh.register(() => this.refreshBrowserState());
52483
- }
52484
- ngOnDestroy() {
52485
- this.pageRefresh.unregister();
52486
- if (typeof window !== 'undefined') {
52487
- window.removeEventListener('focus', this.onWindowFocus);
52488
- }
52489
- if (this.focusDebounceTimer !== null) {
52490
- clearTimeout(this.focusDebounceTimer);
52491
- this.focusDebounceTimer = null;
52492
- }
52493
- }
52494
- /** Refresca el estado del browser con debounce — evita ráfagas de `focus`. */
52495
- scheduleBrowserRefresh() {
52496
- if (this.focusDebounceTimer !== null) {
52497
- clearTimeout(this.focusDebounceTimer);
52498
- }
52499
- this.focusDebounceTimer = setTimeout(() => {
52500
- this.focusDebounceTimer = null;
52501
- void this.refreshBrowserState();
52502
- }, this.FOCUS_DEBOUNCE_MS);
52503
- }
52504
- // ---------------------------------------------------------------------------
52505
- // Actions
52506
- // ---------------------------------------------------------------------------
52507
- /**
52508
- * Secuencia completa de activación.
52509
- *
52510
- * Toda la mecánica (permission → SW ready → getToken con retry → watchdog de
52511
- * auto-reload) vive en `MessagingService.enable()` de la lib. El paso de
52512
- * registro del device en el backend se pasa como callback (`registerDevice`)
52513
- * porque la lib no puede depender de `AuthService` sin crear un ciclo.
52514
- *
52515
- * `enable()` no hace throw: resuelve con un `EnablePushResult` descriptivo.
52516
- */
52517
- async onEnable() {
52518
- this._busy.set('enabling');
52519
- this._lastFailed.set(false);
52520
- try {
52521
- const result = await this.messaging.enable({
52522
- registerDevice: token => this.auth.registerDevice(token).then(r => r.registered),
52523
- });
52524
- if (result.status === 'enabled') {
52525
- // Persistir flag master si aún no estaba activo.
52526
- if (!this.prefs.notificationsMaster()) {
52527
- await this.prefs.setNotificationsMaster(true);
52528
- }
52529
- this.toast.show({
52530
- message: this.tt('enableSuccess'),
52531
- duration: 2000,
52532
- color: 'dark',
52533
- position: 'top',
52534
- });
52535
- this.resolvedConfig().onPushEnabled?.();
52536
- }
52537
- else if (result.status === 'timeout' && result.reloaded) {
52538
- // La lib disparó el auto-reload — la página está por recargarse.
52539
- return;
52540
- }
52541
- else {
52542
- // denied / unsupported / error / timeout sin reload → fallo recuperable.
52543
- this._lastFailed.set(true);
52544
- this.toast.show({
52545
- message: this.tt('errorGeneric'),
52546
- duration: 3000,
52547
- color: 'dark',
52548
- position: 'top',
52549
- });
52550
- }
52551
- }
52552
- catch {
52553
- // `enable()` no hace throw, pero el callback de registro sí puede.
52554
- this._lastFailed.set(true);
52555
- this.toast.show({
52556
- message: this.tt('errorGeneric'),
52557
- duration: 3000,
52558
- color: 'dark',
52559
- position: 'top',
52560
- });
52561
- }
52562
- finally {
52563
- // Único refresh final: re-alinea el toggle UNA sola vez con el estado real.
52564
- await this.refreshBrowserState();
52565
- this._busy.set(null);
52566
- }
52567
- }
52568
- async onDisable() {
52569
- this._busy.set('disabling');
52570
- this._lastFailed.set(false);
52571
- try {
52572
- await this.auth.disableNotifications();
52573
- if (this.prefs.notificationsMaster()) {
52574
- await this.prefs.setNotificationsMaster(false);
52575
- }
52576
- this.toast.show({
52577
- message: this.tt('disableSuccess'),
52578
- duration: 2000,
52579
- color: 'dark',
52580
- position: 'top',
52581
- });
52582
- this.resolvedConfig().onPushDisabled?.();
52583
- }
52584
- catch {
52585
- this._lastFailed.set(true);
52586
- }
52587
- finally {
52588
- await this.refreshBrowserState();
52589
- this._busy.set(null);
52590
- }
52591
- }
52592
- /** Reintentar la secuencia completa de activación, sin importar el estado actual. */
52593
- async onRetry() {
52594
- this._lastFailed.set(false);
52595
- await this.onEnable();
52596
- }
52597
- toggleDetails() {
52598
- this.showDetails.update(v => !v);
52599
- }
52600
- // ---------------------------------------------------------------------------
52601
- // Privados
52602
- // ---------------------------------------------------------------------------
52603
- async refreshBrowserState() {
52604
- this.isSupported.set(await this.messaging.isSupported());
52605
- this.permission.set(this.auth.getNotificationPermissionState());
52606
- this.currentToken.set(this.messaging.currentToken ?? null);
52607
- void this.repairTokenIfNeeded();
52608
- }
52609
- /**
52610
- * Repara el token FCM en background.
52611
- *
52612
- * Si el usuario tiene push activo (`master && granted`) pero no hay token en
52613
- * memoria — caso típico tras un cold start de la PWA — re-obtenemos el token
52614
- * y re-registramos el device sin tocar el toggle ni mostrar un error
52615
- * alarmista. El token es un detalle de implementación reparable, no parte del
52616
- * estado de UI (ver `isEnabled`).
52617
- */
52618
- async repairTokenIfNeeded() {
52619
- if (this._busy() !== null)
52620
- return;
52621
- if (this._repairingToken)
52622
- return;
52623
- if (!this.isEnabled())
52624
- return;
52625
- if (this.currentToken())
52626
- return;
52627
- this._repairingToken = true;
52628
- try {
52629
- const token = await this.messaging.getToken();
52630
- if (token) {
52631
- this.currentToken.set(token);
52632
- await this.auth.registerDevice(token);
52682
+ color: 'dark',
52683
+ bold: true,
52684
+ content: tt('membersTitle'),
52685
+ }"
52686
+ />
52687
+ <div class="section-body">
52688
+ @if (membersLoading()) {
52689
+ <val-skeleton-layout [props]="{ preset: 'list', rows: 3 }" />
52690
+ } @else if (membersErrorState()) {
52691
+ <val-empty-state [props]="membersErrorState()!" />
52692
+ } @else if (members().length === 0) {
52693
+ <val-text
52694
+ [props]="{
52695
+ content: tt('membersEmpty'),
52696
+ size: 'medium',
52697
+ color: 'dark',
52698
+ bold: false,
52699
+ }"
52700
+ />
52701
+ } @else {
52702
+ <div class="members-list">
52703
+ @for (m of visibleMembers(); track m.userId) {
52704
+ <val-member-card
52705
+ [attr.data-testid]="'org-member-' + m.userId"
52706
+ [props]="{
52707
+ id: m.userId,
52708
+ name: m.name,
52709
+ handle: m.handle,
52710
+ email: m.email,
52711
+ avatarUrl: m.avatar,
52712
+ roleLabel: m.roles[0] ? roleLabel(m.roles[0]) : '',
52713
+ actionAriaLabel: tt('viewMemberCta'),
52714
+ }"
52715
+ (onAction)="onViewMember(m)"
52716
+ />
52717
+ }
52718
+ </div>
52719
+ @if (!showAllMembers() && members().length > 3) {
52720
+ <button class="members-show-more" (click)="showAllMembers.set(true)">
52721
+ {{ tt('membersShowMore') }} ({{ members().length - 3 }})
52722
+ </button>
52723
+ }
52724
+ @if (showAllMembers() && membersNextToken()) {
52725
+ <button class="members-show-more" [disabled]="loadingMoreMembers()" (click)="loadMoreMembers()">
52726
+ {{ loadingMoreMembers() ? '…' : tt('membersShowMore') }}
52727
+ </button>
52728
+ }
52633
52729
  }
52634
- }
52635
- catch {
52636
- // Reparación best-effort: no degradamos el toggle ni mostramos error.
52637
- }
52638
- finally {
52639
- this._repairingToken = false;
52640
- }
52641
- }
52642
- /**
52643
- * iOS browser sin estar instalado como PWA — push web no disponible.
52644
- * Aplica a Safari y a Edge/Chrome/Firefox en iOS (todos WebKit por mandato Apple).
52645
- */
52646
- detectNeedsIOSInstall() {
52647
- if (typeof window === 'undefined' || typeof navigator === 'undefined') {
52648
- return false;
52649
- }
52650
- const ua = navigator.userAgent || '';
52651
- const isIOS = /iPad|iPhone|iPod/.test(ua);
52652
- if (!isIOS)
52653
- return false;
52654
- const isStandalone = window.matchMedia?.('(display-mode: standalone)').matches ||
52655
- navigator.standalone === true;
52656
- return !isStandalone;
52657
- }
52658
- tt(key) {
52659
- return this.i18n.t(key, this.ns);
52660
- }
52661
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationPreferencesViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
52662
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: NotificationPreferencesViewComponent, isStandalone: true, selector: "val-notification-preferences-view", inputs: { config: "config" }, ngImport: i0, template: `
52663
- <div class="page">
52664
- <header class="page-header">
52665
- <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
52666
- <val-title
52667
- [props]="{
52668
- size: 'large',
52669
- color: 'dark',
52670
- bold: false,
52671
- content: pageDescription(),
52672
- }"
52673
- />
52674
- </header>
52730
+ </div>
52731
+ </section>
52732
+ }
52675
52733
 
52676
- @if (state() === 'needs-install') {
52734
+ @if (resolvedConfig().showRoles) {
52735
+ <!-- Roles y permisos: seccion explicativa, detalle en la ruta de permisos -->
52677
52736
  <section class="settings-section">
52678
52737
  <val-title
52679
52738
  [props]="{
52680
52739
  size: 'medium',
52681
52740
  color: 'dark',
52682
52741
  bold: true,
52683
- content: tt('iosInstallTitle'),
52742
+ content: tt('rolesSectionTitle'),
52684
52743
  }"
52685
52744
  />
52686
52745
  <div class="section-body">
52687
52746
  <val-text
52688
52747
  [props]="{
52689
- size: 'small',
52690
- color: 'medium',
52748
+ content: tt('rolesHint'),
52749
+ size: 'medium',
52750
+ color: 'dark',
52691
52751
  bold: false,
52692
- content: tt('iosInstallHint'),
52693
52752
  }"
52694
52753
  />
52695
- <ol class="install-steps">
52696
- <li>{{ tt('iosInstallStep1') }}</li>
52697
- <li>{{ tt('iosInstallStep2') }}</li>
52698
- <li>{{ tt('iosInstallStep3') }}</li>
52699
- <li>{{ tt('iosInstallStep4') }}</li>
52700
- </ol>
52754
+ <div class="row-actions">
52755
+ <val-button [props]="viewPermissionsButtonProps()" (click)="onViewPermissions()" />
52756
+ </div>
52701
52757
  </div>
52702
52758
  </section>
52703
52759
  }
52704
52760
 
52705
- <section class="settings-section">
52706
- <div class="notif-row">
52707
- <div class="notif-row__icon">
52708
- <ion-icon name="notifications-outline" aria-hidden="true" />
52761
+ @if (resolvedConfig().showInvite) {
52762
+ <!-- Invite: solo admin+ (gating RBAC interno) -->
52763
+ <section *valHasPermission="'users:*'" class="settings-section" data-testid="org-invite-section">
52764
+ <div class="invite-cta">
52765
+ <div class="invite-cta__text">
52766
+ <val-title
52767
+ [props]="{
52768
+ size: 'medium',
52769
+ color: 'dark',
52770
+ bold: true,
52771
+ content: tt('inviteCtaTitle'),
52772
+ }"
52773
+ />
52774
+ <val-text
52775
+ [props]="{
52776
+ size: 'medium',
52777
+ color: 'dark',
52778
+ bold: false,
52779
+ content: tt('inviteCtaHint'),
52780
+ }"
52781
+ />
52782
+ </div>
52783
+ <div class="invite-cta__actions">
52784
+ <val-button [props]="openInviteButtonProps()" (click)="onOpenInvite()" />
52785
+ <val-button [props]="openImportButtonProps()" (click)="onOpenImport()" />
52786
+ </div>
52709
52787
  </div>
52710
- <div class="notif-row__body">
52711
- <val-title
52788
+ </section>
52789
+ }
52790
+
52791
+ <!-- API Keys: sección propia. Integraciones M2M (import de miembros, etc.). -->
52792
+ <section *valHasPermission="'api-keys:read'" class="settings-section" data-testid="org-apikeys-section">
52793
+ <div class="invite-cta">
52794
+ <div class="invite-cta__text">
52795
+ <val-title [props]="{ size: 'medium', color: 'dark', bold: true, content: tt('apiKeysSectionTitle') }" />
52796
+ <val-text [props]="{ size: 'medium', color: 'dark', bold: false, content: tt('apiKeysSectionHint') }" />
52797
+ <button class="org-more-info-link" type="button" (click)="onApiKeysInfo()">
52798
+ {{ tt('apiKeysMoreInfo') }}
52799
+ </button>
52800
+ </div>
52801
+ <div class="invite-cta__actions">
52802
+ <val-button [props]="openApiKeysButtonProps()" (click)="onOpenApiKeys()" />
52803
+ </div>
52804
+ </div>
52805
+ </section>
52806
+
52807
+ @if (resolvedConfig().showTransferOwnership && isOwner()) {
52808
+ <!-- Transfer ownership: solo owner (gating RBAC interno) -->
52809
+ <section class="settings-section" data-testid="org-transfer-section">
52810
+ <val-title
52811
+ [props]="{
52812
+ size: 'medium',
52813
+ color: 'dark',
52814
+ bold: true,
52815
+ content: tt('transferTitle'),
52816
+ }"
52817
+ />
52818
+ <div class="section-body">
52819
+ <val-text
52712
52820
  [props]="{
52821
+ content: tt('transferHint'),
52713
52822
  size: 'medium',
52714
52823
  color: 'dark',
52715
- bold: true,
52716
- content: tt('simpleTitle'),
52717
- }"
52718
- />
52719
- <val-text
52720
- [props]="{
52721
- size: 'small',
52722
- color: 'medium',
52723
52824
  bold: false,
52724
- content: tt('simpleDescription'),
52725
52825
  }"
52726
52826
  />
52727
- @if (busyLabel(); as label) {
52728
- <span class="notif-row__busy">{{ label }}</span>
52827
+ @if (transferableMembers().length > 0) {
52828
+ <div class="row-actions">
52829
+ <val-button [props]="transferButtonProps()" (click)="onOpenTransfer()" />
52830
+ </div>
52831
+ } @else {
52832
+ <val-text
52833
+ [props]="{
52834
+ content: tt('membersEmpty'),
52835
+ size: 'medium',
52836
+ color: 'dark',
52837
+ bold: false,
52838
+ }"
52839
+ />
52729
52840
  }
52730
52841
  </div>
52731
- <div class="notif-row__control">
52732
- <val-toggle-input [props]="toggleProps()" />
52733
- </div>
52734
- </div>
52735
-
52736
- @if (contextMessage(); as msg) {
52737
- <div class="alert" [class]="'alert--' + alertVariant()">
52738
- <ion-icon [name]="alertIcon()" aria-hidden="true" />
52739
- <span>{{ msg }}</span>
52740
- </div>
52741
- }
52742
-
52743
- @if (state() === 'error') {
52744
- <div class="row-actions">
52745
- <val-button [props]="retryButtonProps()" (click)="onRetry()" />
52746
- </div>
52747
- }
52748
- </section>
52749
-
52750
- @if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
52751
- <section class="settings-section">
52752
- <button class="details-toggle" type="button" (click)="toggleDetails()">
52753
- <span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
52754
- <ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
52755
- </button>
52756
- @if (showDetails()) {
52757
- <dl class="details">
52758
- <div class="details__row">
52759
- <dt>{{ tt('detailsToken') }}</dt>
52760
- <dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
52761
- </div>
52762
- <div class="details__row">
52763
- <dt>{{ tt('diagnosticsPermission') }}</dt>
52764
- <dd>{{ permission() }}</dd>
52765
- </div>
52766
- <div class="details__row">
52767
- <dt>{{ tt('diagnosticsSupported') }}</dt>
52768
- <dd>{{ isSupported() ? '✓' : '✗' }}</dd>
52769
- </div>
52770
- </dl>
52771
- }
52772
52842
  </section>
52773
52843
  }
52774
- </div>
52775
- `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:8px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.notif-row{display:grid;grid-template-columns:44px 1fr auto;gap:16px;align-items:center}.notif-row__icon{width:44px;height:44px;border-radius:50%;background:var(--ion-color-light, rgba(0, 0, 0, .06));display:flex;align-items:center;justify-content:center}.notif-row__icon ion-icon{font-size:22px;color:var(--ion-color-dark, #1a1a1a)}.notif-row__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif-row__busy{font-size:12px;color:var(--ion-color-medium, #92949c);font-style:italic;margin-top:4px}.notif-row__control{flex-shrink:0}.alert{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.45;background:var(--ion-color-light, rgba(0, 0, 0, .04));color:var(--ion-color-dark, #1a1a1a)}.alert ion-icon{font-size:18px;flex-shrink:0;margin-top:1px}.alert--warning ion-icon{color:var(--ion-color-warning-shade, #c79e08)}.alert--danger ion-icon{color:var(--ion-color-danger-shade, #cf3c4f)}.alert--info ion-icon{color:var(--ion-color-medium, #6b6b6b)}.install-steps{margin:0;padding-left:20px;font-size:13px;line-height:1.6;color:var(--ion-color-dark, #1a1a1a)}.install-steps li+li{margin-top:4px}.details-toggle{display:flex;align-items:center;gap:6px;background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-medium, #6b6b6b);cursor:pointer}.details-toggle ion-icon{font-size:14px}.details{margin:12px 0 0;display:flex;flex-direction:column;gap:6px}.details__row{display:grid;grid-template-columns:140px 1fr;gap:12px;font-size:12px;align-items:baseline}.details__row dt{margin:0;color:var(--ion-color-medium, #6b6b6b);text-transform:uppercase;letter-spacing:.4px;font-weight:500}.details__row dd{margin:0;color:var(--ion-color-dark, #1a1a1a);font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;word-break:break-all}\n"], dependencies: [{ kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: ToggleInputComponent, selector: "val-toggle-input", inputs: ["preset", "props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }] }); }
52776
- }
52777
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationPreferencesViewComponent, decorators: [{
52778
- type: Component,
52779
- args: [{ selector: 'val-notification-preferences-view', standalone: true, imports: [IonIcon, ToggleInputComponent, ButtonComponent, DisplayComponent, TitleComponent, TextComponent], template: `
52780
- <div class="page">
52781
- <header class="page-header">
52782
- <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
52783
- <val-title
52784
- [props]="{
52785
- size: 'large',
52786
- color: 'dark',
52787
- bold: false,
52788
- content: pageDescription(),
52789
- }"
52790
- />
52791
- </header>
52792
52844
 
52793
- @if (state() === 'needs-install') {
52845
+ @if (resolvedConfig().showLeave) {
52846
+ <!-- Leave org -->
52794
52847
  <section class="settings-section">
52795
52848
  <val-title
52796
52849
  [props]="{
52797
52850
  size: 'medium',
52798
52851
  color: 'dark',
52799
52852
  bold: true,
52800
- content: tt('iosInstallTitle'),
52853
+ content: tt('leaveTitle'),
52801
52854
  }"
52802
52855
  />
52803
52856
  <div class="section-body">
52804
52857
  <val-text
52805
52858
  [props]="{
52806
- size: 'small',
52807
- color: 'medium',
52859
+ content: isOwner() ? tt('leaveOwnerHint') : tt('leaveHint'),
52860
+ size: 'medium',
52861
+ color: 'dark',
52808
52862
  bold: false,
52809
- content: tt('iosInstallHint'),
52810
52863
  }"
52811
52864
  />
52812
- <ol class="install-steps">
52813
- <li>{{ tt('iosInstallStep1') }}</li>
52814
- <li>{{ tt('iosInstallStep2') }}</li>
52815
- <li>{{ tt('iosInstallStep3') }}</li>
52816
- <li>{{ tt('iosInstallStep4') }}</li>
52817
- </ol>
52865
+ <div class="row-actions">
52866
+ <val-button [props]="leaveButtonProps()" (click)="onLeave()" />
52867
+ </div>
52818
52868
  </div>
52819
52869
  </section>
52820
52870
  }
52821
52871
 
52822
- <section class="settings-section">
52823
- <div class="notif-row">
52824
- <div class="notif-row__icon">
52825
- <ion-icon name="notifications-outline" aria-hidden="true" />
52826
- </div>
52827
- <div class="notif-row__body">
52872
+ @if (isDebug) {
52873
+ <section class="settings-section rbac-debug">
52874
+ <div class="section-header-row">
52828
52875
  <val-title
52829
52876
  [props]="{
52830
- size: 'medium',
52831
- color: 'dark',
52832
- bold: true,
52833
- content: tt('simpleTitle'),
52834
- }"
52835
- />
52836
- <val-text
52837
- [props]="{
52877
+ content: 'RBAC (live)',
52838
52878
  size: 'small',
52839
52879
  color: 'medium',
52840
52880
  bold: false,
52841
- content: tt('simpleDescription'),
52842
52881
  }"
52843
52882
  />
52844
- @if (busyLabel(); as label) {
52845
- <span class="notif-row__busy">{{ label }}</span>
52846
- }
52847
- </div>
52848
- <div class="notif-row__control">
52849
- <val-toggle-input [props]="toggleProps()" />
52850
52883
  </div>
52851
- </div>
52852
-
52853
- @if (contextMessage(); as msg) {
52854
- <div class="alert" [class]="'alert--' + alertVariant()">
52855
- <ion-icon [name]="alertIcon()" aria-hidden="true" />
52856
- <span>{{ msg }}</span>
52857
- </div>
52858
- }
52859
-
52860
- @if (state() === 'error') {
52861
- <div class="row-actions">
52862
- <val-button [props]="retryButtonProps()" (click)="onRetry()" />
52884
+ <div class="rbac-debug__body">
52885
+ <div class="rbac-debug__row">
52886
+ <span class="rbac-debug__label">Roles</span>
52887
+ <span class="rbac-debug__value">{{ auth.roles().join(', ') || '—' }}</span>
52888
+ </div>
52889
+ <div class="rbac-debug__row">
52890
+ <span class="rbac-debug__label">Permisos</span>
52891
+ <span class="rbac-debug__value rbac-debug__value--perms">{{ auth.permissions().join(', ') || '—' }}</span>
52892
+ </div>
52863
52893
  </div>
52864
- }
52865
- </section>
52866
-
52867
- @if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
52868
- <section class="settings-section">
52869
- <button class="details-toggle" type="button" (click)="toggleDetails()">
52870
- <span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
52871
- <ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
52872
- </button>
52873
- @if (showDetails()) {
52874
- <dl class="details">
52875
- <div class="details__row">
52876
- <dt>{{ tt('detailsToken') }}</dt>
52877
- <dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
52878
- </div>
52879
- <div class="details__row">
52880
- <dt>{{ tt('diagnosticsPermission') }}</dt>
52881
- <dd>{{ permission() }}</dd>
52882
- </div>
52883
- <div class="details__row">
52884
- <dt>{{ tt('diagnosticsSupported') }}</dt>
52885
- <dd>{{ isSupported() ? '✓' : '✗' }}</dd>
52886
- </div>
52887
- </dl>
52888
- }
52889
52894
  </section>
52890
52895
  }
52896
+ <val-permissions-modal
52897
+ [isOpen]="permissionsOpen()"
52898
+ [config]="{ i18nNamespace: resolvedConfig().i18nNamespace }"
52899
+ (dismissed)="permissionsOpen.set(false)"
52900
+ />
52891
52901
  </div>
52892
- `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:8px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.notif-row{display:grid;grid-template-columns:44px 1fr auto;gap:16px;align-items:center}.notif-row__icon{width:44px;height:44px;border-radius:50%;background:var(--ion-color-light, rgba(0, 0, 0, .06));display:flex;align-items:center;justify-content:center}.notif-row__icon ion-icon{font-size:22px;color:var(--ion-color-dark, #1a1a1a)}.notif-row__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif-row__busy{font-size:12px;color:var(--ion-color-medium, #92949c);font-style:italic;margin-top:4px}.notif-row__control{flex-shrink:0}.alert{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.45;background:var(--ion-color-light, rgba(0, 0, 0, .04));color:var(--ion-color-dark, #1a1a1a)}.alert ion-icon{font-size:18px;flex-shrink:0;margin-top:1px}.alert--warning ion-icon{color:var(--ion-color-warning-shade, #c79e08)}.alert--danger ion-icon{color:var(--ion-color-danger-shade, #cf3c4f)}.alert--info ion-icon{color:var(--ion-color-medium, #6b6b6b)}.install-steps{margin:0;padding-left:20px;font-size:13px;line-height:1.6;color:var(--ion-color-dark, #1a1a1a)}.install-steps li+li{margin-top:4px}.details-toggle{display:flex;align-items:center;gap:6px;background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-medium, #6b6b6b);cursor:pointer}.details-toggle ion-icon{font-size:14px}.details{margin:12px 0 0;display:flex;flex-direction:column;gap:6px}.details__row{display:grid;grid-template-columns:140px 1fr;gap:12px;font-size:12px;align-items:baseline}.details__row dt{margin:0;color:var(--ion-color-medium, #6b6b6b);text-transform:uppercase;letter-spacing:.4px;font-weight:500}.details__row dd{margin:0;color:var(--ion-color-dark, #1a1a1a);font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;word-break:break-all}\n"] }]
52902
+ `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.org-more-info-link{background:none;border:none;padding:4px 0;margin-top:4px;font-size:13px;font-weight:600;color:var(--ion-color-primary, #7026df);cursor:pointer;text-align:left}.page-header{margin-bottom:16px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-header-row{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-bottom:8px}.section-body{display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.row-actions--gap{display:flex;gap:8px}.invite-cta{display:flex;flex-direction:column;gap:16px}.invite-cta__text{display:flex;flex-direction:column;gap:4px}.invite-cta__actions{display:flex;gap:12px;flex-wrap:wrap}.org-info-card{border-radius:14px;background:var(--ion-color-light, #f4f5f8);overflow:hidden}:host-context(body.dark) .org-info-card,:host-context(html.ion-palette-dark) .org-info-card,:host-context([data-theme=\"dark\"]) .org-info-card{background:#ffffff0d}.org-info-field{padding:12px 16px;display:flex;flex-direction:column;gap:4px;border-bottom:1px solid var(--val-border-color, rgba(0, 0, 0, .06))}.org-info-field:last-child{border-bottom:none}.org-info-label{font-size:.74rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--ion-color-medium)}.org-info-value{font-size:.95rem;font-weight:500;color:var(--ion-color-dark)}.org-info-value--muted{color:var(--ion-color-medium);font-weight:400}.plan-badge{display:inline-block;font-size:.78rem;font-weight:700;padding:3px 10px;border-radius:20px;width:fit-content}.plan-badge--free{background:var(--ion-color-light-shade, #d7d8da);color:var(--ion-color-dark)}.plan-badge--pro{background:var(--ion-color-primary);color:#fff}.plan-badge--enterprise{background:#f5c542;color:#222}.members-list{display:flex;flex-direction:column;gap:8px}.members-show-more{background:none;border:none;color:var(--ion-color-primary);font-size:14px;font-weight:500;cursor:pointer;padding:8px 0}.rbac-debug{opacity:.7}.rbac-debug__body{display:flex;flex-direction:column;gap:6px;font-family:monospace;font-size:.78rem}.rbac-debug__row{display:flex;gap:8px;align-items:flex-start}.rbac-debug__label{color:var(--ion-color-medium);min-width:72px;flex-shrink:0}.rbac-debug__value{color:var(--ion-color-dark);word-break:break-all}.rbac-debug__value--perms{color:var(--ion-color-medium)}\n"] }]
52893
52903
  }], ctorParameters: () => [], propDecorators: { config: [{
52894
52904
  type: Input
52895
52905
  }] } });
52896
52906
 
52897
52907
  /**
52898
- * Helper para montar las **preferencias de notificaciones push**
52899
- * (`val-notification-preferences-view`) como ruta en una app del factory. El
52900
- * `config` se pasa por route `data` (`notificationPreferencesConfig`) y el
52901
- * componente lo lee como fallback de su `@Input() config`.
52902
- *
52903
- * No confundir con `provideValtechNotificationsRoutes` (el inbox / feed).
52908
+ * Helper para montar la vista Gestión de organización (`val-organization-view`)
52909
+ * como ruta en una app del factory. El `config` se pasa por route `data`
52910
+ * (`organizationConfig`) y el componente lo lee como fallback de su
52911
+ * `@Input() config`.
52904
52912
  *
52905
52913
  * @example
52906
52914
  * ```ts
52907
- * // app.routes.ts (típicamente bajo settings)
52915
+ * // settings.routes.ts
52908
52916
  * export const settingsRoutes: Routes = [
52909
- * ...provideValtechNotificationPreferencesRoutes(), // → notifications
52917
+ * ...provideValtechOrganizationRoutes(), // → /app/settings/organization
52918
+ * { path: 'preferences', loadComponent: () => ... },
52910
52919
  * ];
52911
52920
  *
52912
52921
  * // con config acotada:
52913
- * ...provideValtechNotificationPreferencesRoutes({ config: { showDetails: false } }),
52914
- * ...provideValtechNotificationPreferencesRoutes({ path: 'push' }),
52922
+ * ...provideValtechOrganizationRoutes({ config: { showLeave: false } }),
52923
+ * ...provideValtechOrganizationRoutes({ config: { viewPermissionsRoute: '/app/roles' } }),
52915
52924
  * ```
52916
52925
  */
52917
- function provideValtechNotificationPreferencesRoutes(opts) {
52926
+ function provideValtechOrganizationRoutes(opts) {
52918
52927
  return [
52919
52928
  {
52920
- path: opts?.path ?? 'notifications',
52921
- component: NotificationPreferencesViewComponent,
52922
- data: { notificationPreferencesConfig: opts?.config },
52929
+ path: opts?.path ?? 'organization',
52930
+ component: OrganizationViewComponent,
52931
+ data: { organizationConfig: opts?.config },
52923
52932
  },
52924
52933
  ];
52925
52934
  }
52926
52935
 
52927
52936
  /**
52928
- * Defaults i18n (es/en) embebidos en `val-permissions-view`. Auto-registrados en
52929
- * el constructor del componente si el consumer no proveyó el namespace
52930
- * (`Settings.Permissions` por default). SOLO incluye las keys que la vista usa.
52937
+ * Defaults i18n (es/en) embebidos en `val-notification-preferences-view`.
52938
+ * Auto-registrados en el constructor del componente si el consumer no proveyó
52939
+ * el namespace (`Settings.Notifications` por default). SOLO incluye las keys que
52940
+ * la vista usa.
52931
52941
  *
52932
- * Portado EXACTO de `PERMISSIONS_I18N` (showcase) bajo el proceso de ADR-021.
52942
+ * Portado del override `Settings.Notifications` (showcase) bajo el proceso de
52943
+ * ADR-021.
52933
52944
  */
52934
- const PERMISSIONS_VIEW_I18N = {
52945
+ const NOTIFICATION_PREFERENCES_VIEW_I18N = {
52935
52946
  es: {
52936
- pageTitle: 'Permisos',
52937
- pageDescription: 'Roles y permisos de esta app en la organización activa',
52938
- systemRolesTitle: 'Roles del sistema',
52939
- systemRolesHint: 'Predefinidos por la plataforma. No editables.',
52940
- customRolesTitle: 'Roles personalizados',
52941
- customRolesHint: 'Definidos por tu organización.',
52942
- systemBadge: 'Sistema',
52943
- noCustomRoles: 'Esta organización no tiene roles personalizados.',
52944
- noPermissions: 'Sin permisos asignados.',
52945
- // Grupos de alcance
52946
- groupApp: 'Permisos de la app',
52947
- groupOrg: 'Permisos de la organización',
52948
- groupPlatform: 'Permisos de plataforma',
52949
- groupOther: 'Otras apps',
52950
- // Roles del sistema
52951
- roleViewer: 'Visor',
52952
- roleEditor: 'Editor',
52953
- roleAdmin: 'Administrador',
52954
- roleSuperAdmin: 'Super Administrador',
52955
- roleBingoAdmin: 'Bingo · Administrador',
52956
- roleBingoTesorero: 'Bingo · Tesorero',
52957
- roleBingoTaquilla: 'Bingo · Taquilla',
52958
- roleBingoOperator: 'Bingo · Operador',
52959
- // Apps
52960
- app_showcase: 'Showcase',
52961
- app_bingo: 'Bingo',
52962
- // Recursos
52963
- permUsers: 'Usuarios',
52964
- permRoles: 'Roles',
52965
- permRbac: 'Control de acceso',
52966
- permApps: 'Apps',
52967
- 'permApi-keys': 'API keys',
52968
- 'permEmail-logs': 'Logs de correo',
52969
- 'permSms-logs': 'Logs de SMS',
52970
- permDocuments: 'Documentos',
52971
- permMedia: 'Media',
52972
- permTemplates: 'Plantillas',
52973
- permRequests: 'Solicitudes',
52974
- permBingo: 'Bingo',
52975
- // Acciones
52976
- permRead: 'Leer',
52977
- permWrite: 'Escribir',
52978
- permCreate: 'Crear',
52979
- permDelete: 'Eliminar',
52980
- permManage: 'Gestionar',
52981
- permEdit: 'Editar',
52982
- permSell: 'Vender',
52983
- permCheckin: 'Canjear',
52984
- permSettle: 'Rendir',
52985
- permExport: 'Exportar',
52986
- permOperate: 'Operar',
52987
- permAll: 'Todo',
52988
- // Loading / error
52989
- errorTitle: 'Error al cargar permisos',
52990
- retry: 'Reintentar',
52991
- },
52992
- en: {
52993
- pageTitle: 'Permissions',
52994
- pageDescription: 'Roles and permissions of this app in the active organization',
52995
- systemRolesTitle: 'System roles',
52996
- systemRolesHint: 'Defined by the platform. Not editable.',
52997
- customRolesTitle: 'Custom roles',
52998
- customRolesHint: 'Defined by your organization.',
52999
- systemBadge: 'System',
53000
- noCustomRoles: 'This organization has no custom roles.',
53001
- noPermissions: 'No permissions assigned.',
53002
- groupApp: 'App permissions',
53003
- groupOrg: 'Organization permissions',
53004
- groupPlatform: 'Platform permissions',
53005
- groupOther: 'Other apps',
53006
- roleViewer: 'Viewer',
53007
- roleEditor: 'Editor',
53008
- roleAdmin: 'Administrator',
53009
- roleSuperAdmin: 'Super Administrator',
53010
- roleBingoAdmin: 'Bingo · Administrator',
53011
- roleBingoTesorero: 'Bingo · Treasurer',
53012
- roleBingoTaquilla: 'Bingo · Box office',
53013
- roleBingoOperator: 'Bingo · Operator',
53014
- app_showcase: 'Showcase',
53015
- app_bingo: 'Bingo',
53016
- permUsers: 'Users',
53017
- permRoles: 'Roles',
53018
- permRbac: 'Access control',
53019
- permApps: 'Apps',
53020
- 'permApi-keys': 'API keys',
53021
- 'permEmail-logs': 'Email logs',
53022
- 'permSms-logs': 'SMS logs',
53023
- permDocuments: 'Documents',
53024
- permMedia: 'Media',
53025
- permTemplates: 'Templates',
53026
- permRequests: 'Requests',
53027
- permBingo: 'Bingo',
53028
- permRead: 'Read',
53029
- permWrite: 'Write',
53030
- permCreate: 'Create',
53031
- permDelete: 'Delete',
53032
- permManage: 'Manage',
53033
- permEdit: 'Edit',
53034
- permSell: 'Sell',
53035
- permCheckin: 'Check-in',
53036
- permSettle: 'Settle',
53037
- permExport: 'Export',
53038
- permOperate: 'Operate',
53039
- permAll: 'All',
53040
- errorTitle: 'Error loading permissions',
53041
- retry: 'Retry',
52947
+ pageTitle: 'Notificaciones',
52948
+ pageDescription: 'Configura como quieres recibir notificaciones',
52949
+ // Control principal (UX simplificado)
52950
+ simpleTitle: 'Notificaciones push en este dispositivo',
52951
+ simpleDescription: 'Recibe alertas en tiempo real incluso con la app cerrada.',
52952
+ enabling: 'Activando…',
52953
+ disabling: 'Desactivando…',
52954
+ enabled: 'Activadas',
52955
+ errorGeneric: 'No pudimos completar la configuración. Verifica los permisos del navegador y reintenta.',
52956
+ retryCta: 'Reintentar',
52957
+ // Mensajes contextuales
52958
+ permissionUnsupported: 'Tu navegador no soporta notificaciones push.',
52959
+ permissionDeniedInfo: 'Tu navegador esta bloqueando las notificaciones. Para habilitarlas, ve a la configuracion del sitio y permitelas manualmente.',
52960
+ // iOS PWA install
52961
+ iosInstallTitle: 'En iOS, instala la app para recibir notificaciones',
52962
+ iosInstallHint: 'Las notificaciones push en iOS (iPhone/iPad) solo funcionan cuando la app está instalada en la pantalla de inicio. Edge, Chrome y Firefox en iOS comparten esta limitación.',
52963
+ iosInstallStep1: 'Abre este sitio en Safari (no en Chrome/Edge).',
52964
+ iosInstallStep2: 'Toca el botón Compartir (□ con flecha hacia arriba).',
52965
+ iosInstallStep3: "Elige 'Añadir a pantalla de inicio'.",
52966
+ iosInstallStep4: 'Abre la app desde el ícono recién creado y vuelve aquí.',
52967
+ // Detalles técnicos (expandible)
52968
+ detailsShow: 'Ver detalles técnicos',
52969
+ detailsHide: 'Ocultar detalles',
52970
+ detailsToken: 'Token',
52971
+ detailsNone: 'Sin registrar',
52972
+ diagnosticsPermission: 'Permiso del navegador',
52973
+ diagnosticsSupported: 'Soporte del navegador',
52974
+ // Toasts
52975
+ enableSuccess: 'Notificaciones habilitadas',
52976
+ disableSuccess: 'Notificaciones deshabilitadas',
52977
+ },
52978
+ en: {
52979
+ pageTitle: 'Notifications',
52980
+ pageDescription: 'Configure how you want to receive notifications',
52981
+ simpleTitle: 'Push notifications on this device',
52982
+ simpleDescription: 'Get real-time alerts even when the app is closed.',
52983
+ enabling: 'Enabling…',
52984
+ disabling: 'Disabling…',
52985
+ enabled: 'Enabled',
52986
+ errorGeneric: "We couldn't complete the setup. Check your browser permissions and retry.",
52987
+ retryCta: 'Retry',
52988
+ permissionUnsupported: 'Your browser does not support push notifications.',
52989
+ permissionDeniedInfo: 'Your browser is blocking notifications. To enable them, go to site settings and allow them manually.',
52990
+ iosInstallTitle: 'On iOS, install the app to receive notifications',
52991
+ iosInstallHint: 'Push notifications on iOS (iPhone/iPad) only work when the app is installed on the Home Screen. Edge, Chrome and Firefox on iOS share this limitation.',
52992
+ iosInstallStep1: 'Open this site in Safari (not Chrome/Edge).',
52993
+ iosInstallStep2: 'Tap the Share button (□ with an up arrow).',
52994
+ iosInstallStep3: "Choose 'Add to Home Screen'.",
52995
+ iosInstallStep4: 'Open the app from the new icon and come back here.',
52996
+ detailsShow: 'View technical details',
52997
+ detailsHide: 'Hide details',
52998
+ detailsToken: 'Token',
52999
+ detailsNone: 'Not registered',
53000
+ diagnosticsPermission: 'Browser permission',
53001
+ diagnosticsSupported: 'Browser support',
53002
+ enableSuccess: 'Notifications enabled',
53003
+ disableSuccess: 'Notifications disabled',
53042
53004
  },
53043
53005
  };
53044
53006
 
53045
- const DEFAULT_NAMESPACE$5 = 'Settings.Permissions';
53007
+ addIcons({
53008
+ alertCircleOutline,
53009
+ chevronDownOutline,
53010
+ chevronUpOutline,
53011
+ closeCircleOutline,
53012
+ notificationsOffOutline,
53013
+ notificationsOutline,
53014
+ refreshOutline,
53015
+ });
53016
+ const DEFAULT_NAMESPACE$5 = 'Settings.Notifications';
53046
53017
  /**
53047
- * `val-permissions-view` — vista Permisos full-feature autocontenida (organism).
53048
- * Visor RBAC de solo lectura: muestra los roles del sistema (Visor/Editor/Admin +
53049
- * roles de plataforma) y los roles personalizados de la org activa, con el
53050
- * catálogo de permisos agrupado por alcance (app actual / plataforma / otras apps).
53051
- * Promovida desde `showcase` bajo el proceso de ADR-021.
53018
+ * `val-notification-preferences-view` — **preferencias de notificaciones push**
53019
+ * (FCM) full-feature autocontenida (organism). Promovida desde `showcase` bajo el
53020
+ * proceso de ADR-021.
53052
53021
  *
53053
- * **RBAC interno** (como `organization-view`): lee la org activa vía
53054
- * `AuthService.user().activeOrgId`. La vista NO muta nada — el control de quién
53055
- * puede verla es responsabilidad del guard de ruta del consumer; no hay gating
53056
- * fino interno ni se expone como config.
53022
+ * Separada de `val-notifications-view` (el inbox / feed). Aquí el user **activa o
53023
+ * desactiva** las notificaciones push en este dispositivo.
53057
53024
  *
53058
- * Datos: `forkJoin` de `OrgService.listOrgRoles(orgId)` (roles de la org) +
53059
- * `PermissionCatalogService.getCatalog(orgId)` (catálogo + appId actual). El
53060
- * catálogo define el alcance (`scope`/`appId`) de cada recurso para agrupar.
53025
+ * UX simplificado: UN solo toggle que ejecuta toda la secuencia (pedir permiso →
53026
+ * SW ready getToken → registrar en backend → set master pref) vía
53027
+ * `MessagingService.enable()`. El registro del device en el backend va como
53028
+ * callback (la lib no depende de `AuthService` para evitar ciclos). Mensajes
53029
+ * contextuales y botón "Reintentar" aparecen solo cuando aplican.
53061
53030
  *
53062
53031
  * NO renderiza ion-content — vive dentro de val-page-wrapper. `ActivatedRoute` se
53063
- * inyecta `{ optional: true }` solo para leer el route data de config. Auto-registra
53064
- * sus defaults i18n (es/en) si el consumer no proveyó el namespace (default
53065
- * `Settings.Permissions`).
53032
+ * inyecta `{ optional: true }` funciona sin router (embebida / Storybook).
53066
53033
  */
53067
- class PermissionsViewComponent {
53034
+ class NotificationPreferencesViewComponent {
53068
53035
  /** Namespace i18n resuelto (capturado para llamadas no-reactivas). */
53069
53036
  get ns() {
53070
53037
  return this.resolvedConfig().i18nNamespace;
53071
53038
  }
53039
+ // ---------------------------------------------------------------------------
53040
+ // Lifecycle
53041
+ // ---------------------------------------------------------------------------
53072
53042
  constructor() {
53043
+ this.nav = inject(NavigationService);
53073
53044
  this.i18n = inject(I18nService);
53074
- this.orgService = inject(OrgService);
53075
- this.catalog = inject(PermissionCatalogService);
53045
+ this.toast = inject(ToastService);
53076
53046
  this.auth = inject(AuthService);
53077
- this.nav = inject(NavigationService);
53047
+ this.messaging = inject(MessagingService);
53048
+ this.pageRefresh = inject(PageRefreshService);
53049
+ this.prefs = inject(PreferencesService);
53050
+ // `ActivatedRoute` opcional → la vista funciona embebida / en Storybook sin
53051
+ // router (solo lo usa para leer el route data de config como fallback).
53078
53052
  this.route = inject(ActivatedRoute, { optional: true });
53079
53053
  this.resolvedConfig = computed(() => {
53080
- const fromRoute = (this.route?.snapshot.data['permissionsConfig'] ?? {});
53054
+ const fromRoute = (this.route?.snapshot.data['notificationPreferencesConfig'] ??
53055
+ {});
53081
53056
  const merged = { ...fromRoute, ...(this.config ?? {}) };
53082
53057
  return {
53083
- showSystemRoles: merged.showSystemRoles ?? true,
53084
- showCustomRoles: merged.showCustomRoles ?? true,
53058
+ showDetails: merged.showDetails ?? true,
53085
53059
  i18nNamespace: merged.i18nNamespace ?? DEFAULT_NAMESPACE$5,
53060
+ onPushEnabled: merged.onPushEnabled,
53061
+ onPushDisabled: merged.onPushDisabled,
53086
53062
  };
53087
53063
  });
53088
- this.loading = signal(false);
53089
- this.loadError = signal(null);
53090
- this.roles = signal([]);
53091
- this.systemRoles = computed(() => this.roles().filter(r => r.isSystem));
53092
- this.customRoles = computed(() => this.roles().filter(r => !r.isSystem));
53093
- this.activeOrgId = computed(() => {
53094
- const u = this.auth.user();
53095
- return u?.activeOrgId ?? u?.activeOrg ?? '';
53064
+ /** Operación en curso (gobierna spinner del toggle + label "Activando…"). */
53065
+ this._busy = signal(null);
53066
+ /** True si el último intento falló — gobierna estado `error` + retry button. */
53067
+ this._lastFailed = signal(false);
53068
+ /** True mientras se re-obtiene el token FCM en background (no afecta la UI). */
53069
+ this._repairingToken = false;
53070
+ /** Toggle del expand de detalles técnicos. */
53071
+ this.showDetails = signal(false);
53072
+ this.permission = signal('default');
53073
+ this.isSupported = signal(false);
53074
+ this.currentToken = signal(null);
53075
+ this.needsIOSInstall = signal(this.detectNeedsIOSInstall());
53076
+ /** FormControl del toggle (sync-eado con `isEnabled` via effect). */
53077
+ this.toggleControl = new FormControl(false, {
53078
+ nonNullable: true,
53079
+ });
53080
+ /** Handler del listener `focus` — referencia guardada para poder removerlo. */
53081
+ this.onWindowFocus = () => this.scheduleBrowserRefresh();
53082
+ /** Timer del debounce del refresh disparado por `focus`. */
53083
+ this.focusDebounceTimer = null;
53084
+ /** Ventana de debounce (ms) para el refresh disparado por `focus`. */
53085
+ this.FOCUS_DEBOUNCE_MS = 500;
53086
+ // ---------------------------------------------------------------------------
53087
+ // Estado derivado
53088
+ // ---------------------------------------------------------------------------
53089
+ this.pageTitle = computed(() => this.tt('pageTitle'));
53090
+ this.pageDescription = computed(() => this.tt('pageDescription'));
53091
+ /**
53092
+ * Estado efectivo del toggle: "el usuario tiene push activo".
53093
+ *
53094
+ * Se deriva SOLO de la decisión del usuario (`master`) y del permiso del SO
53095
+ * (`permission`). El token FCM NO forma parte del estado de UI: vive en
53096
+ * memoria, se pierde en cada recarga/suspensión de la PWA y produciría flicker.
53097
+ * Si falta token con `master && granted`, es un detalle reparable en
53098
+ * background (ver `repairTokenIfNeeded`), no un cambio del toggle.
53099
+ */
53100
+ this.isEnabled = computed(() => this.prefs.notificationsMaster() && this.permission() === 'granted');
53101
+ this.state = computed(() => {
53102
+ if (this.needsIOSInstall())
53103
+ return 'needs-install';
53104
+ if (this.permission() === 'unsupported' || !this.isSupported()) {
53105
+ // iOS standalone aún puede dar isSupported false temprano antes de init —
53106
+ // solo bloqueamos si el browser explícitamente no soporta APIs.
53107
+ if (this.permission() === 'unsupported')
53108
+ return 'unsupported';
53109
+ }
53110
+ const busy = this._busy();
53111
+ if (busy === 'enabling')
53112
+ return 'enabling';
53113
+ if (busy === 'disabling')
53114
+ return 'disabling';
53115
+ if (this.isEnabled())
53116
+ return 'enabled';
53117
+ if (this.permission() === 'denied')
53118
+ return 'blocked';
53119
+ if (this._lastFailed())
53120
+ return 'error';
53121
+ return 'idle';
53122
+ });
53123
+ this.busyLabel = computed(() => {
53124
+ const s = this.state();
53125
+ if (s === 'enabling')
53126
+ return this.tt('enabling');
53127
+ if (s === 'disabling')
53128
+ return this.tt('disabling');
53129
+ return null;
53130
+ });
53131
+ this.contextMessage = computed(() => {
53132
+ switch (this.state()) {
53133
+ case 'unsupported':
53134
+ return this.tt('permissionUnsupported');
53135
+ case 'blocked':
53136
+ return this.tt('permissionDeniedInfo');
53137
+ case 'error':
53138
+ return this.tt('errorGeneric');
53139
+ default:
53140
+ return null;
53141
+ }
53142
+ });
53143
+ this.alertVariant = computed(() => {
53144
+ const s = this.state();
53145
+ if (s === 'error' || s === 'blocked')
53146
+ return 'warning';
53147
+ if (s === 'unsupported')
53148
+ return 'danger';
53149
+ return 'info';
53150
+ });
53151
+ this.alertIcon = computed(() => {
53152
+ const s = this.state();
53153
+ if (s === 'unsupported')
53154
+ return 'close-circle-outline';
53155
+ return 'alert-circle-outline';
53096
53156
  });
53157
+ this.toggleProps = computed(() => ({
53158
+ control: this.toggleControl,
53159
+ color: 'primary',
53160
+ disabled: this._busy() !== null ||
53161
+ this.state() === 'unsupported' ||
53162
+ this.state() === 'needs-install' ||
53163
+ this.state() === 'blocked',
53164
+ }));
53097
53165
  this.retryButtonProps = computed(() => ({
53098
- text: this.tt('retry'),
53166
+ text: this.tt('retryCta'),
53099
53167
  color: 'dark',
53100
53168
  fill: 'outline',
53101
53169
  size: 'default',
53170
+ shape: 'round',
53102
53171
  type: 'button',
53103
- state: this.loading() ? ComponentStates.DISABLED : ComponentStates.ENABLED,
53172
+ icon: { name: 'refresh-outline', slot: 'start' },
53173
+ state: this._busy() !== null ? ComponentStates.WORKING : ComponentStates.ENABLED,
53104
53174
  }));
53175
+ this.tokenPreview = computed(() => {
53176
+ const t = this.currentToken();
53177
+ if (!t)
53178
+ return null;
53179
+ return `${t.slice(0, 22)}…${t.slice(-6)}`;
53180
+ });
53105
53181
  const ns = this.ns;
53106
53182
  if (!this.i18n.hasNamespace(ns)) {
53107
- this.i18n.registerContent(ns, PERMISSIONS_VIEW_I18N);
53183
+ this.i18n.registerContent(ns, NOTIFICATION_PREFERENCES_VIEW_I18N);
53108
53184
  }
53109
53185
  this.nav.setBackHeader('pageTitle', ns, { withMenu: true });
53110
- this.load();
53111
- connectPageRefresh(() => this.load());
53112
- }
53113
- load() {
53114
- const orgId = this.activeOrgId();
53115
- if (!orgId)
53116
- return;
53117
- this.loading.set(true);
53118
- this.loadError.set(null);
53119
- forkJoin({
53120
- roles: this.orgService.listOrgRoles(orgId),
53121
- catalog: this.catalog.getCatalog(orgId),
53122
- }).subscribe({
53123
- next: ({ roles: rawRoles, catalog }) => {
53124
- const byResource = new Map();
53125
- for (const r of catalog.permissions)
53126
- byResource.set(r.resource, r);
53127
- const currentAppId = catalog.appId;
53128
- // Labeler con la SSOT del backend (labels del catálogo) — reemplaza el
53129
- // mapa hardcodeado `perm*`. Locale del momento de carga (igual que antes:
53130
- // los labels se hornean en el RoleView; cambio de idioma requiere reload).
53131
- const labeler = createPermissionLabeler(catalog, this.i18n.lang(), {
53132
- allLabel: this.tt('permAll'),
53133
- });
53134
- this.roles.set((rawRoles ?? []).map((r) => this.buildRoleView({
53135
- id: r.id,
53136
- name: r.name,
53137
- description: r.description,
53138
- permissions: r.permissions ?? [],
53139
- isSystem: r.isSystem ?? false,
53140
- }, byResource, currentAppId, labeler)));
53141
- this.loading.set(false);
53142
- },
53143
- error: err => {
53144
- this.loadError.set(err);
53145
- this.loading.set(false);
53146
- },
53186
+ void this.refreshBrowserState();
53187
+ // Re-aplicar header en cambio de idioma.
53188
+ effect(() => {
53189
+ this.i18n.lang();
53190
+ this.nav.setBackHeader('pageTitle', this.ns, { withMenu: true });
53191
+ }, { allowSignalWrites: true });
53192
+ // Sync FormControl ←→ estado efectivo. Cuando el estado cambia (cambio de
53193
+ // permission por el browser, o snapshot Firestore de `master`), el toggle se
53194
+ // re-alinea con la realidad.
53195
+ //
53196
+ // IMPORTANTE: se ignoran los cambios mientras hay una operación en curso
53197
+ // (`_busy() !== null`). Durante `onEnable`/`onDisable` las señales fuente se
53198
+ // actualizan en momentos distintos y producirían estados intermedios →
53199
+ // flicker visible. El toggle se re-alinea UNA sola vez en el `finally`.
53200
+ effect(() => {
53201
+ const eff = this.isEnabled();
53202
+ if (this._busy() !== null)
53203
+ return;
53204
+ if (this.toggleControl.value !== eff) {
53205
+ this.toggleControl.setValue(eff, { emitEvent: false });
53206
+ }
53207
+ }, { allowSignalWrites: true });
53208
+ // Interceptar click del user: el toggle ya cambió visualmente, ejecutar
53209
+ // la acción correspondiente.
53210
+ this.toggleControl.valueChanges.subscribe(v => {
53211
+ if (this._busy() !== null)
53212
+ return;
53213
+ if (v && !this.isEnabled()) {
53214
+ void this.onEnable();
53215
+ }
53216
+ else if (!v && this.isEnabled()) {
53217
+ void this.onDisable();
53218
+ }
53147
53219
  });
53220
+ // Detectar regreso a la pestaña — el user pudo haber cambiado permisos
53221
+ // desde la config del browser. El refresh va con debounce para no
53222
+ // dispararse en ráfagas (en PWA mobile `focus` salta con cada cambio de
53223
+ // app / aparición del teclado). El listener se remueve en ngOnDestroy.
53224
+ if (typeof window !== 'undefined') {
53225
+ window.addEventListener('focus', this.onWindowFocus);
53226
+ }
53148
53227
  }
53149
- buildRoleView(role, byResource, currentAppId, labeler) {
53150
- const appPerms = [];
53151
- const orgPerms = [];
53152
- const otherByApp = new Map();
53153
- for (const perm of role.permissions) {
53154
- const [resource] = perm.split(':');
53155
- const entry = byResource.get(resource);
53156
- // Sin entrada en el catálogo (el backend omite los recursos internal para
53157
- // no-platform-admins) o entrada internal → NUNCA se muestra a nivel de
53158
- // producto. Defensa en profundidad junto al filtro del backend (ADR-024).
53159
- // Nota: el rol puede traer el permiso aunque el recurso esté filtrado del
53160
- // catálogo (ej. admin tiene templates:*); por eso se descarta lo no resuelto.
53161
- if (!entry || entry.scope === 'internal') {
53162
- continue;
53163
- }
53164
- const label = labeler(perm);
53165
- if (entry.scope === 'app') {
53166
- if (entry.appId === currentAppId) {
53167
- appPerms.push(label);
53168
- }
53169
- else {
53170
- const appId = entry.appId ?? '?';
53171
- const arr = otherByApp.get(appId) ?? [];
53172
- arr.push(label);
53173
- otherByApp.set(appId, arr);
53228
+ /**
53229
+ * Registra el pull-to-refresh estándar. Aunque esta vista no muestra una
53230
+ * lista remota, sí refleja estado mutable del browser (permiso, token,
53231
+ * soporte FCM) que puede cambiar fuera de la app — el refresh re-evalúa todo.
53232
+ *
53233
+ * Se usa `ngOnInit`/`ngOnDestroy` (NO `ionViewWillEnter`) porque
53234
+ * `val-page-wrapper` usa `<router-outlet>` plano — los hooks `ionView*` solo
53235
+ * disparan dentro de `<ion-router-outlet>`.
53236
+ */
53237
+ ngOnInit() {
53238
+ this.pageRefresh.register(() => this.refreshBrowserState());
53239
+ }
53240
+ ngOnDestroy() {
53241
+ this.pageRefresh.unregister();
53242
+ if (typeof window !== 'undefined') {
53243
+ window.removeEventListener('focus', this.onWindowFocus);
53244
+ }
53245
+ if (this.focusDebounceTimer !== null) {
53246
+ clearTimeout(this.focusDebounceTimer);
53247
+ this.focusDebounceTimer = null;
53248
+ }
53249
+ }
53250
+ /** Refresca el estado del browser con debounce — evita ráfagas de `focus`. */
53251
+ scheduleBrowserRefresh() {
53252
+ if (this.focusDebounceTimer !== null) {
53253
+ clearTimeout(this.focusDebounceTimer);
53254
+ }
53255
+ this.focusDebounceTimer = setTimeout(() => {
53256
+ this.focusDebounceTimer = null;
53257
+ void this.refreshBrowserState();
53258
+ }, this.FOCUS_DEBOUNCE_MS);
53259
+ }
53260
+ // ---------------------------------------------------------------------------
53261
+ // Actions
53262
+ // ---------------------------------------------------------------------------
53263
+ /**
53264
+ * Secuencia completa de activación.
53265
+ *
53266
+ * Toda la mecánica (permission → SW ready → getToken con retry → watchdog de
53267
+ * auto-reload) vive en `MessagingService.enable()` de la lib. El paso de
53268
+ * registro del device en el backend se pasa como callback (`registerDevice`)
53269
+ * porque la lib no puede depender de `AuthService` sin crear un ciclo.
53270
+ *
53271
+ * `enable()` no hace throw: resuelve con un `EnablePushResult` descriptivo.
53272
+ */
53273
+ async onEnable() {
53274
+ this._busy.set('enabling');
53275
+ this._lastFailed.set(false);
53276
+ try {
53277
+ const result = await this.messaging.enable({
53278
+ registerDevice: token => this.auth.registerDevice(token).then(r => r.registered),
53279
+ });
53280
+ if (result.status === 'enabled') {
53281
+ // Persistir flag master si aún no estaba activo.
53282
+ if (!this.prefs.notificationsMaster()) {
53283
+ await this.prefs.setNotificationsMaster(true);
53174
53284
  }
53285
+ this.toast.show({
53286
+ message: this.tt('enableSuccess'),
53287
+ duration: 2000,
53288
+ color: 'dark',
53289
+ position: 'top',
53290
+ });
53291
+ this.resolvedConfig().onPushEnabled?.();
53292
+ }
53293
+ else if (result.status === 'timeout' && result.reloaded) {
53294
+ // La lib disparó el auto-reload — la página está por recargarse.
53295
+ return;
53175
53296
  }
53176
53297
  else {
53177
- // scope === 'org' gestión de la organización del cliente
53178
- orgPerms.push(label);
53298
+ // denied / unsupported / error / timeout sin reload → fallo recuperable.
53299
+ this._lastFailed.set(true);
53300
+ this.toast.show({
53301
+ message: this.tt('errorGeneric'),
53302
+ duration: 3000,
53303
+ color: 'dark',
53304
+ position: 'top',
53305
+ });
53179
53306
  }
53180
53307
  }
53181
- const groups = [];
53182
- if (appPerms.length) {
53183
- groups.push({ key: 'app', label: this.tt('groupApp'), perms: appPerms });
53184
- }
53185
- if (orgPerms.length) {
53186
- groups.push({
53187
- key: 'org',
53188
- label: this.tt('groupOrg'),
53189
- perms: orgPerms,
53308
+ catch {
53309
+ // `enable()` no hace throw, pero el callback de registro sí puede.
53310
+ this._lastFailed.set(true);
53311
+ this.toast.show({
53312
+ message: this.tt('errorGeneric'),
53313
+ duration: 3000,
53314
+ color: 'dark',
53315
+ position: 'top',
53190
53316
  });
53191
53317
  }
53192
- for (const [appId, perms] of otherByApp) {
53193
- groups.push({
53194
- key: `other:${appId}`,
53195
- label: `${this.tt('groupOther')}: ${this.appLabel(appId)}`,
53196
- perms,
53318
+ finally {
53319
+ // Único refresh final: re-alinea el toggle UNA sola vez con el estado real.
53320
+ await this.refreshBrowserState();
53321
+ this._busy.set(null);
53322
+ }
53323
+ }
53324
+ async onDisable() {
53325
+ this._busy.set('disabling');
53326
+ this._lastFailed.set(false);
53327
+ try {
53328
+ await this.auth.disableNotifications();
53329
+ if (this.prefs.notificationsMaster()) {
53330
+ await this.prefs.setNotificationsMaster(false);
53331
+ }
53332
+ this.toast.show({
53333
+ message: this.tt('disableSuccess'),
53334
+ duration: 2000,
53335
+ color: 'dark',
53336
+ position: 'top',
53197
53337
  });
53338
+ this.resolvedConfig().onPushDisabled?.();
53198
53339
  }
53199
- return {
53200
- id: role.id,
53201
- name: role.name,
53202
- description: role.description,
53203
- isSystem: role.isSystem,
53204
- groups,
53205
- hasPerms: groups.length > 0,
53206
- };
53340
+ catch {
53341
+ this._lastFailed.set(true);
53342
+ }
53343
+ finally {
53344
+ await this.refreshBrowserState();
53345
+ this._busy.set(null);
53346
+ }
53347
+ }
53348
+ /** Reintentar la secuencia completa de activación, sin importar el estado actual. */
53349
+ async onRetry() {
53350
+ this._lastFailed.set(false);
53351
+ await this.onEnable();
53352
+ }
53353
+ toggleDetails() {
53354
+ this.showDetails.update(v => !v);
53355
+ }
53356
+ // ---------------------------------------------------------------------------
53357
+ // Privados
53358
+ // ---------------------------------------------------------------------------
53359
+ async refreshBrowserState() {
53360
+ this.isSupported.set(await this.messaging.isSupported());
53361
+ this.permission.set(this.auth.getNotificationPermissionState());
53362
+ this.currentToken.set(this.messaging.currentToken ?? null);
53363
+ void this.repairTokenIfNeeded();
53364
+ }
53365
+ /**
53366
+ * Repara el token FCM en background.
53367
+ *
53368
+ * Si el usuario tiene push activo (`master && granted`) pero no hay token en
53369
+ * memoria — caso típico tras un cold start de la PWA — re-obtenemos el token
53370
+ * y re-registramos el device sin tocar el toggle ni mostrar un error
53371
+ * alarmista. El token es un detalle de implementación reparable, no parte del
53372
+ * estado de UI (ver `isEnabled`).
53373
+ */
53374
+ async repairTokenIfNeeded() {
53375
+ if (this._busy() !== null)
53376
+ return;
53377
+ if (this._repairingToken)
53378
+ return;
53379
+ if (!this.isEnabled())
53380
+ return;
53381
+ if (this.currentToken())
53382
+ return;
53383
+ this._repairingToken = true;
53384
+ try {
53385
+ const token = await this.messaging.getToken();
53386
+ if (token) {
53387
+ this.currentToken.set(token);
53388
+ await this.auth.registerDevice(token);
53389
+ }
53390
+ }
53391
+ catch {
53392
+ // Reparación best-effort: no degradamos el toggle ni mostramos error.
53393
+ }
53394
+ finally {
53395
+ this._repairingToken = false;
53396
+ }
53397
+ }
53398
+ /**
53399
+ * iOS browser sin estar instalado como PWA — push web no disponible.
53400
+ * Aplica a Safari y a Edge/Chrome/Firefox en iOS (todos WebKit por mandato Apple).
53401
+ */
53402
+ detectNeedsIOSInstall() {
53403
+ if (typeof window === 'undefined' || typeof navigator === 'undefined') {
53404
+ return false;
53405
+ }
53406
+ const ua = navigator.userAgent || '';
53407
+ const isIOS = /iPad|iPhone|iPod/.test(ua);
53408
+ if (!isIOS)
53409
+ return false;
53410
+ const isStandalone = window.matchMedia?.('(display-mode: standalone)').matches ||
53411
+ navigator.standalone === true;
53412
+ return !isStandalone;
53207
53413
  }
53208
53414
  tt(key) {
53209
53415
  return this.i18n.t(key, this.ns);
53210
53416
  }
53211
- appLabel(appId) {
53212
- const key = `app_${appId}`;
53213
- const label = this.tt(key);
53214
- return label === key ? appId : label;
53215
- }
53216
- roleLabel(name) {
53217
- const map = {
53218
- viewer: 'roleViewer',
53219
- editor: 'roleEditor',
53220
- admin: 'roleAdmin',
53221
- super_admin: 'roleSuperAdmin',
53222
- bingo_admin: 'roleBingoAdmin',
53223
- bingo_tesorero: 'roleBingoTesorero',
53224
- bingo_taquilla: 'roleBingoTaquilla',
53225
- bingo_operator: 'roleBingoOperator',
53226
- };
53227
- return this.tt(map[name] ?? name);
53228
- }
53229
- static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
53230
- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: PermissionsViewComponent, isStandalone: true, selector: "val-permissions-view", inputs: { config: "config" }, ngImport: i0, template: `
53417
+ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationPreferencesViewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); }
53418
+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.2.14", type: NotificationPreferencesViewComponent, isStandalone: true, selector: "val-notification-preferences-view", inputs: { config: "config" }, ngImport: i0, template: `
53231
53419
  <div class="page">
53232
53420
  <header class="page-header">
53233
- <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
53421
+ <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
53234
53422
  <val-title
53235
53423
  [props]="{
53236
53424
  size: 'large',
53237
53425
  color: 'dark',
53238
53426
  bold: false,
53239
- content: tt('pageDescription'),
53427
+ content: pageDescription(),
53240
53428
  }"
53241
53429
  />
53242
53430
  </header>
53243
53431
 
53244
- @if (loading()) {
53245
- <div class="spinner-row">
53246
- <ion-spinner name="crescent" />
53247
- </div>
53248
- } @else if (loadError()) {
53249
- <div class="error-row">
53250
- <val-text
53432
+ @if (state() === 'needs-install') {
53433
+ <section class="settings-section">
53434
+ <val-title
53251
53435
  [props]="{
53252
- content: tt('errorTitle'),
53253
53436
  size: 'medium',
53254
- color: 'medium',
53255
- bold: false,
53437
+ color: 'dark',
53438
+ bold: true,
53439
+ content: tt('iosInstallTitle'),
53256
53440
  }"
53257
53441
  />
53258
- <val-button [props]="retryButtonProps()" (click)="load()" />
53442
+ <div class="section-body">
53443
+ <val-text
53444
+ [props]="{
53445
+ size: 'small',
53446
+ color: 'medium',
53447
+ bold: false,
53448
+ content: tt('iosInstallHint'),
53449
+ }"
53450
+ />
53451
+ <ol class="install-steps">
53452
+ <li>{{ tt('iosInstallStep1') }}</li>
53453
+ <li>{{ tt('iosInstallStep2') }}</li>
53454
+ <li>{{ tt('iosInstallStep3') }}</li>
53455
+ <li>{{ tt('iosInstallStep4') }}</li>
53456
+ </ol>
53457
+ </div>
53458
+ </section>
53459
+ }
53460
+
53461
+ <section class="settings-section">
53462
+ <div class="notif-row">
53463
+ <div class="notif-row__icon">
53464
+ <ion-icon name="notifications-outline" aria-hidden="true" />
53465
+ </div>
53466
+ <div class="notif-row__body">
53467
+ <val-title
53468
+ [props]="{
53469
+ size: 'medium',
53470
+ color: 'dark',
53471
+ bold: true,
53472
+ content: tt('simpleTitle'),
53473
+ }"
53474
+ />
53475
+ <val-text
53476
+ [props]="{
53477
+ size: 'small',
53478
+ color: 'medium',
53479
+ bold: false,
53480
+ content: tt('simpleDescription'),
53481
+ }"
53482
+ />
53483
+ @if (busyLabel(); as label) {
53484
+ <span class="notif-row__busy">{{ label }}</span>
53485
+ }
53486
+ </div>
53487
+ <div class="notif-row__control">
53488
+ <val-toggle-input [props]="toggleProps()" />
53489
+ </div>
53259
53490
  </div>
53260
- } @else {
53261
- @if (resolvedConfig().showSystemRoles) {
53262
- <section class="perm-section">
53263
- <div class="perm-section__header">
53264
- <val-title
53265
- [props]="{
53266
- size: 'medium',
53267
- color: 'dark',
53268
- bold: true,
53269
- content: tt('systemRolesTitle'),
53270
- }"
53271
- />
53272
- <val-text
53273
- [props]="{
53274
- content: tt('systemRolesHint'),
53275
- size: 'medium',
53276
- color: 'medium',
53277
- bold: false,
53278
- }"
53279
- />
53280
- </div>
53281
- <div class="role-list">
53282
- @for (role of systemRoles(); track role.id) {
53283
- <div class="role-card">
53284
- <div class="role-card__header">
53285
- <span class="role-card__name">{{ roleLabel(role.name) }}</span>
53286
- <val-pill
53287
- [props]="{
53288
- label: tt('systemBadge'),
53289
- color: 'primary',
53290
- weight: 'bold',
53291
- }"
53292
- />
53293
- </div>
53294
- @if (role.description) {
53295
- <span class="role-card__desc">{{ role.description }}</span>
53296
- }
53297
- @if (!role.hasPerms) {
53298
- <div class="perm-chips">
53299
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
53300
- </div>
53301
- } @else {
53302
- @for (g of role.groups; track g.key) {
53303
- <div class="perm-group">
53304
- <span class="perm-group__label">{{ g.label }}</span>
53305
- <div class="perm-chips">
53306
- @for (p of g.perms; track p) {
53307
- <span class="perm-chip">{{ p }}</span>
53308
- }
53309
- </div>
53310
- </div>
53311
- }
53312
- }
53313
- </div>
53314
- }
53315
- </div>
53316
- </section>
53491
+
53492
+ @if (contextMessage(); as msg) {
53493
+ <div class="alert" [class]="'alert--' + alertVariant()">
53494
+ <ion-icon [name]="alertIcon()" aria-hidden="true" />
53495
+ <span>{{ msg }}</span>
53496
+ </div>
53317
53497
  }
53318
53498
 
53319
- @if (resolvedConfig().showCustomRoles) {
53320
- <section class="perm-section">
53321
- <div class="perm-section__header">
53322
- <val-title
53323
- [props]="{
53324
- size: 'medium',
53325
- color: 'dark',
53326
- bold: true,
53327
- content: tt('customRolesTitle'),
53328
- }"
53329
- />
53330
- <val-text
53331
- [props]="{
53332
- content: tt('customRolesHint'),
53333
- size: 'medium',
53334
- color: 'medium',
53335
- bold: false,
53336
- }"
53337
- />
53338
- </div>
53339
- @if (customRoles().length === 0) {
53340
- <val-text
53341
- [props]="{
53342
- content: tt('noCustomRoles'),
53343
- size: 'medium',
53344
- color: 'medium',
53345
- bold: false,
53346
- }"
53347
- />
53348
- } @else {
53349
- <div class="role-list">
53350
- @for (role of customRoles(); track role.id) {
53351
- <div class="role-card">
53352
- <div class="role-card__header">
53353
- <span class="role-card__name">{{ role.name }}</span>
53354
- </div>
53355
- @if (role.description) {
53356
- <span class="role-card__desc">{{ role.description }}</span>
53357
- }
53358
- @if (!role.hasPerms) {
53359
- <div class="perm-chips">
53360
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
53361
- </div>
53362
- } @else {
53363
- @for (g of role.groups; track g.key) {
53364
- <div class="perm-group">
53365
- <span class="perm-group__label">{{ g.label }}</span>
53366
- <div class="perm-chips">
53367
- @for (p of g.perms; track p) {
53368
- <span class="perm-chip">{{ p }}</span>
53369
- }
53370
- </div>
53371
- </div>
53372
- }
53373
- }
53374
- </div>
53375
- }
53376
- </div>
53377
- }
53378
- </section>
53499
+ @if (state() === 'error') {
53500
+ <div class="row-actions">
53501
+ <val-button [props]="retryButtonProps()" (click)="onRetry()" />
53502
+ </div>
53379
53503
  }
53504
+ </section>
53505
+
53506
+ @if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
53507
+ <section class="settings-section">
53508
+ <button class="details-toggle" type="button" (click)="toggleDetails()">
53509
+ <span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
53510
+ <ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
53511
+ </button>
53512
+ @if (showDetails()) {
53513
+ <dl class="details">
53514
+ <div class="details__row">
53515
+ <dt>{{ tt('detailsToken') }}</dt>
53516
+ <dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
53517
+ </div>
53518
+ <div class="details__row">
53519
+ <dt>{{ tt('diagnosticsPermission') }}</dt>
53520
+ <dd>{{ permission() }}</dd>
53521
+ </div>
53522
+ <div class="details__row">
53523
+ <dt>{{ tt('diagnosticsSupported') }}</dt>
53524
+ <dd>{{ isSupported() ? '✓' : '✗' }}</dd>
53525
+ </div>
53526
+ </dl>
53527
+ }
53528
+ </section>
53380
53529
  }
53381
53530
  </div>
53382
- `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:24px}.spinner-row,.error-row{display:flex;flex-direction:column;align-items:center;gap:16px;padding:32px 0}.perm-section{margin-bottom:32px}.perm-section__header{margin-bottom:12px}.role-list{display:flex;flex-direction:column;gap:10px}.role-card{border:1.5px solid var(--ion-color-light);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}.role-card__header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.role-card__name{font-size:15px;font-weight:600;color:var(--ion-color-dark)}.role-card__desc{font-size:13px;color:var(--ion-color-medium);line-height:1.4}.perm-group{display:flex;flex-direction:column;gap:6px}.perm-group__label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--ion-color-medium)}.perm-chips{display:flex;flex-wrap:wrap;gap:6px}.perm-chip{font-size:12px;padding:3px 10px;border-radius:20px;background:var(--ion-color-light);color:var(--ion-color-dark)}.perm-chip--empty{color:var(--ion-color-medium);background:transparent;padding-left:0}\n"], dependencies: [{ kind: "component", type: IonSpinner, selector: "ion-spinner", inputs: ["color", "duration", "name", "paused"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: PillComponent, selector: "val-pill", inputs: ["preset", "props"], outputs: ["pillClick", "pillAction"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }] }); }
53531
+ `, isInline: true, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:8px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.notif-row{display:grid;grid-template-columns:44px 1fr auto;gap:16px;align-items:center}.notif-row__icon{width:44px;height:44px;border-radius:50%;background:var(--ion-color-light, rgba(0, 0, 0, .06));display:flex;align-items:center;justify-content:center}.notif-row__icon ion-icon{font-size:22px;color:var(--ion-color-dark, #1a1a1a)}.notif-row__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif-row__busy{font-size:12px;color:var(--ion-color-medium, #92949c);font-style:italic;margin-top:4px}.notif-row__control{flex-shrink:0}.alert{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.45;background:var(--ion-color-light, rgba(0, 0, 0, .04));color:var(--ion-color-dark, #1a1a1a)}.alert ion-icon{font-size:18px;flex-shrink:0;margin-top:1px}.alert--warning ion-icon{color:var(--ion-color-warning-shade, #c79e08)}.alert--danger ion-icon{color:var(--ion-color-danger-shade, #cf3c4f)}.alert--info ion-icon{color:var(--ion-color-medium, #6b6b6b)}.install-steps{margin:0;padding-left:20px;font-size:13px;line-height:1.6;color:var(--ion-color-dark, #1a1a1a)}.install-steps li+li{margin-top:4px}.details-toggle{display:flex;align-items:center;gap:6px;background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-medium, #6b6b6b);cursor:pointer}.details-toggle ion-icon{font-size:14px}.details{margin:12px 0 0;display:flex;flex-direction:column;gap:6px}.details__row{display:grid;grid-template-columns:140px 1fr;gap:12px;font-size:12px;align-items:baseline}.details__row dt{margin:0;color:var(--ion-color-medium, #6b6b6b);text-transform:uppercase;letter-spacing:.4px;font-weight:500}.details__row dd{margin:0;color:var(--ion-color-dark, #1a1a1a);font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;word-break:break-all}\n"], dependencies: [{ kind: "component", type: IonIcon, selector: "ion-icon", inputs: ["color", "flipRtl", "icon", "ios", "lazy", "md", "mode", "name", "sanitize", "size", "src"] }, { kind: "component", type: ToggleInputComponent, selector: "val-toggle-input", inputs: ["preset", "props"] }, { kind: "component", type: ButtonComponent, selector: "val-button", inputs: ["preset", "props"], outputs: ["onClick"] }, { kind: "component", type: DisplayComponent, selector: "val-display", inputs: ["props"] }, { kind: "component", type: TitleComponent, selector: "val-title", inputs: ["props"] }, { kind: "component", type: TextComponent, selector: "val-text", inputs: ["props"] }] }); }
53383
53532
  }
53384
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: PermissionsViewComponent, decorators: [{
53533
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImport: i0, type: NotificationPreferencesViewComponent, decorators: [{
53385
53534
  type: Component,
53386
- args: [{ selector: 'val-permissions-view', standalone: true, imports: [IonSpinner, DisplayComponent, PillComponent, TitleComponent, TextComponent, ButtonComponent], template: `
53535
+ args: [{ selector: 'val-notification-preferences-view', standalone: true, imports: [IonIcon, ToggleInputComponent, ButtonComponent, DisplayComponent, TitleComponent, TextComponent], template: `
53387
53536
  <div class="page">
53388
53537
  <header class="page-header">
53389
- <val-display [props]="{ size: 'small', color: 'dark', content: tt('pageTitle') }" />
53538
+ <val-display [props]="{ size: 'small', color: 'dark', content: pageTitle() }" />
53390
53539
  <val-title
53391
53540
  [props]="{
53392
53541
  size: 'large',
53393
53542
  color: 'dark',
53394
53543
  bold: false,
53395
- content: tt('pageDescription'),
53544
+ content: pageDescription(),
53396
53545
  }"
53397
53546
  />
53398
53547
  </header>
53399
53548
 
53400
- @if (loading()) {
53401
- <div class="spinner-row">
53402
- <ion-spinner name="crescent" />
53403
- </div>
53404
- } @else if (loadError()) {
53405
- <div class="error-row">
53406
- <val-text
53549
+ @if (state() === 'needs-install') {
53550
+ <section class="settings-section">
53551
+ <val-title
53407
53552
  [props]="{
53408
- content: tt('errorTitle'),
53409
53553
  size: 'medium',
53410
- color: 'medium',
53411
- bold: false,
53554
+ color: 'dark',
53555
+ bold: true,
53556
+ content: tt('iosInstallTitle'),
53412
53557
  }"
53413
53558
  />
53414
- <val-button [props]="retryButtonProps()" (click)="load()" />
53559
+ <div class="section-body">
53560
+ <val-text
53561
+ [props]="{
53562
+ size: 'small',
53563
+ color: 'medium',
53564
+ bold: false,
53565
+ content: tt('iosInstallHint'),
53566
+ }"
53567
+ />
53568
+ <ol class="install-steps">
53569
+ <li>{{ tt('iosInstallStep1') }}</li>
53570
+ <li>{{ tt('iosInstallStep2') }}</li>
53571
+ <li>{{ tt('iosInstallStep3') }}</li>
53572
+ <li>{{ tt('iosInstallStep4') }}</li>
53573
+ </ol>
53574
+ </div>
53575
+ </section>
53576
+ }
53577
+
53578
+ <section class="settings-section">
53579
+ <div class="notif-row">
53580
+ <div class="notif-row__icon">
53581
+ <ion-icon name="notifications-outline" aria-hidden="true" />
53582
+ </div>
53583
+ <div class="notif-row__body">
53584
+ <val-title
53585
+ [props]="{
53586
+ size: 'medium',
53587
+ color: 'dark',
53588
+ bold: true,
53589
+ content: tt('simpleTitle'),
53590
+ }"
53591
+ />
53592
+ <val-text
53593
+ [props]="{
53594
+ size: 'small',
53595
+ color: 'medium',
53596
+ bold: false,
53597
+ content: tt('simpleDescription'),
53598
+ }"
53599
+ />
53600
+ @if (busyLabel(); as label) {
53601
+ <span class="notif-row__busy">{{ label }}</span>
53602
+ }
53603
+ </div>
53604
+ <div class="notif-row__control">
53605
+ <val-toggle-input [props]="toggleProps()" />
53606
+ </div>
53415
53607
  </div>
53416
- } @else {
53417
- @if (resolvedConfig().showSystemRoles) {
53418
- <section class="perm-section">
53419
- <div class="perm-section__header">
53420
- <val-title
53421
- [props]="{
53422
- size: 'medium',
53423
- color: 'dark',
53424
- bold: true,
53425
- content: tt('systemRolesTitle'),
53426
- }"
53427
- />
53428
- <val-text
53429
- [props]="{
53430
- content: tt('systemRolesHint'),
53431
- size: 'medium',
53432
- color: 'medium',
53433
- bold: false,
53434
- }"
53435
- />
53436
- </div>
53437
- <div class="role-list">
53438
- @for (role of systemRoles(); track role.id) {
53439
- <div class="role-card">
53440
- <div class="role-card__header">
53441
- <span class="role-card__name">{{ roleLabel(role.name) }}</span>
53442
- <val-pill
53443
- [props]="{
53444
- label: tt('systemBadge'),
53445
- color: 'primary',
53446
- weight: 'bold',
53447
- }"
53448
- />
53449
- </div>
53450
- @if (role.description) {
53451
- <span class="role-card__desc">{{ role.description }}</span>
53452
- }
53453
- @if (!role.hasPerms) {
53454
- <div class="perm-chips">
53455
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
53456
- </div>
53457
- } @else {
53458
- @for (g of role.groups; track g.key) {
53459
- <div class="perm-group">
53460
- <span class="perm-group__label">{{ g.label }}</span>
53461
- <div class="perm-chips">
53462
- @for (p of g.perms; track p) {
53463
- <span class="perm-chip">{{ p }}</span>
53464
- }
53465
- </div>
53466
- </div>
53467
- }
53468
- }
53469
- </div>
53470
- }
53471
- </div>
53472
- </section>
53608
+
53609
+ @if (contextMessage(); as msg) {
53610
+ <div class="alert" [class]="'alert--' + alertVariant()">
53611
+ <ion-icon [name]="alertIcon()" aria-hidden="true" />
53612
+ <span>{{ msg }}</span>
53613
+ </div>
53473
53614
  }
53474
53615
 
53475
- @if (resolvedConfig().showCustomRoles) {
53476
- <section class="perm-section">
53477
- <div class="perm-section__header">
53478
- <val-title
53479
- [props]="{
53480
- size: 'medium',
53481
- color: 'dark',
53482
- bold: true,
53483
- content: tt('customRolesTitle'),
53484
- }"
53485
- />
53486
- <val-text
53487
- [props]="{
53488
- content: tt('customRolesHint'),
53489
- size: 'medium',
53490
- color: 'medium',
53491
- bold: false,
53492
- }"
53493
- />
53494
- </div>
53495
- @if (customRoles().length === 0) {
53496
- <val-text
53497
- [props]="{
53498
- content: tt('noCustomRoles'),
53499
- size: 'medium',
53500
- color: 'medium',
53501
- bold: false,
53502
- }"
53503
- />
53504
- } @else {
53505
- <div class="role-list">
53506
- @for (role of customRoles(); track role.id) {
53507
- <div class="role-card">
53508
- <div class="role-card__header">
53509
- <span class="role-card__name">{{ role.name }}</span>
53510
- </div>
53511
- @if (role.description) {
53512
- <span class="role-card__desc">{{ role.description }}</span>
53513
- }
53514
- @if (!role.hasPerms) {
53515
- <div class="perm-chips">
53516
- <span class="perm-chip perm-chip--empty">{{ tt('noPermissions') }}</span>
53517
- </div>
53518
- } @else {
53519
- @for (g of role.groups; track g.key) {
53520
- <div class="perm-group">
53521
- <span class="perm-group__label">{{ g.label }}</span>
53522
- <div class="perm-chips">
53523
- @for (p of g.perms; track p) {
53524
- <span class="perm-chip">{{ p }}</span>
53525
- }
53526
- </div>
53527
- </div>
53528
- }
53529
- }
53530
- </div>
53531
- }
53532
- </div>
53533
- }
53534
- </section>
53616
+ @if (state() === 'error') {
53617
+ <div class="row-actions">
53618
+ <val-button [props]="retryButtonProps()" (click)="onRetry()" />
53619
+ </div>
53535
53620
  }
53621
+ </section>
53622
+
53623
+ @if (resolvedConfig().showDetails && state() !== 'needs-install' && state() !== 'unsupported') {
53624
+ <section class="settings-section">
53625
+ <button class="details-toggle" type="button" (click)="toggleDetails()">
53626
+ <span>{{ showDetails() ? tt('detailsHide') : tt('detailsShow') }}</span>
53627
+ <ion-icon [name]="showDetails() ? 'chevron-up-outline' : 'chevron-down-outline'" aria-hidden="true" />
53628
+ </button>
53629
+ @if (showDetails()) {
53630
+ <dl class="details">
53631
+ <div class="details__row">
53632
+ <dt>{{ tt('detailsToken') }}</dt>
53633
+ <dd>{{ tokenPreview() ?? tt('detailsNone') }}</dd>
53634
+ </div>
53635
+ <div class="details__row">
53636
+ <dt>{{ tt('diagnosticsPermission') }}</dt>
53637
+ <dd>{{ permission() }}</dd>
53638
+ </div>
53639
+ <div class="details__row">
53640
+ <dt>{{ tt('diagnosticsSupported') }}</dt>
53641
+ <dd>{{ isSupported() ? '✓' : '✗' }}</dd>
53642
+ </div>
53643
+ </dl>
53644
+ }
53645
+ </section>
53536
53646
  }
53537
53647
  </div>
53538
- `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:24px}.spinner-row,.error-row{display:flex;flex-direction:column;align-items:center;gap:16px;padding:32px 0}.perm-section{margin-bottom:32px}.perm-section__header{margin-bottom:12px}.role-list{display:flex;flex-direction:column;gap:10px}.role-card{border:1.5px solid var(--ion-color-light);border-radius:12px;padding:14px 16px;display:flex;flex-direction:column;gap:10px}.role-card__header{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.role-card__name{font-size:15px;font-weight:600;color:var(--ion-color-dark)}.role-card__desc{font-size:13px;color:var(--ion-color-medium);line-height:1.4}.perm-group{display:flex;flex-direction:column;gap:6px}.perm-group__label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--ion-color-medium)}.perm-chips{display:flex;flex-wrap:wrap;gap:6px}.perm-chip{font-size:12px;padding:3px 10px;border-radius:20px;background:var(--ion-color-light);color:var(--ion-color-dark)}.perm-chip--empty{color:var(--ion-color-medium);background:transparent;padding-left:0}\n"] }]
53648
+ `, styles: [".page{padding:16px 0;max-width:720px;margin:0 auto}.page-header{margin-bottom:8px}.settings-section{padding:16px 0}.settings-section+.settings-section{border-top:1px solid var(--val-border-color, rgba(0, 0, 0, .08))}.section-body{margin-top:16px;display:flex;flex-direction:column;gap:10px}.row-actions{margin-top:12px}.notif-row{display:grid;grid-template-columns:44px 1fr auto;gap:16px;align-items:center}.notif-row__icon{width:44px;height:44px;border-radius:50%;background:var(--ion-color-light, rgba(0, 0, 0, .06));display:flex;align-items:center;justify-content:center}.notif-row__icon ion-icon{font-size:22px;color:var(--ion-color-dark, #1a1a1a)}.notif-row__body{display:flex;flex-direction:column;gap:4px;min-width:0}.notif-row__busy{font-size:12px;color:var(--ion-color-medium, #92949c);font-style:italic;margin-top:4px}.notif-row__control{flex-shrink:0}.alert{display:flex;gap:10px;align-items:flex-start;margin-top:12px;padding:12px 14px;border-radius:10px;font-size:13px;line-height:1.45;background:var(--ion-color-light, rgba(0, 0, 0, .04));color:var(--ion-color-dark, #1a1a1a)}.alert ion-icon{font-size:18px;flex-shrink:0;margin-top:1px}.alert--warning ion-icon{color:var(--ion-color-warning-shade, #c79e08)}.alert--danger ion-icon{color:var(--ion-color-danger-shade, #cf3c4f)}.alert--info ion-icon{color:var(--ion-color-medium, #6b6b6b)}.install-steps{margin:0;padding-left:20px;font-size:13px;line-height:1.6;color:var(--ion-color-dark, #1a1a1a)}.install-steps li+li{margin-top:4px}.details-toggle{display:flex;align-items:center;gap:6px;background:none;border:0;padding:0;font-size:13px;color:var(--ion-color-medium, #6b6b6b);cursor:pointer}.details-toggle ion-icon{font-size:14px}.details{margin:12px 0 0;display:flex;flex-direction:column;gap:6px}.details__row{display:grid;grid-template-columns:140px 1fr;gap:12px;font-size:12px;align-items:baseline}.details__row dt{margin:0;color:var(--ion-color-medium, #6b6b6b);text-transform:uppercase;letter-spacing:.4px;font-weight:500}.details__row dd{margin:0;color:var(--ion-color-dark, #1a1a1a);font-family:ui-monospace,SFMono-Regular,monospace;font-size:11px;word-break:break-all}\n"] }]
53539
53649
  }], ctorParameters: () => [], propDecorators: { config: [{
53540
53650
  type: Input
53541
53651
  }] } });
53542
53652
 
53653
+ /**
53654
+ * Helper para montar las **preferencias de notificaciones push**
53655
+ * (`val-notification-preferences-view`) como ruta en una app del factory. El
53656
+ * `config` se pasa por route `data` (`notificationPreferencesConfig`) y el
53657
+ * componente lo lee como fallback de su `@Input() config`.
53658
+ *
53659
+ * No confundir con `provideValtechNotificationsRoutes` (el inbox / feed).
53660
+ *
53661
+ * @example
53662
+ * ```ts
53663
+ * // app.routes.ts (típicamente bajo settings)
53664
+ * export const settingsRoutes: Routes = [
53665
+ * ...provideValtechNotificationPreferencesRoutes(), // → notifications
53666
+ * ];
53667
+ *
53668
+ * // con config acotada:
53669
+ * ...provideValtechNotificationPreferencesRoutes({ config: { showDetails: false } }),
53670
+ * ...provideValtechNotificationPreferencesRoutes({ path: 'push' }),
53671
+ * ```
53672
+ */
53673
+ function provideValtechNotificationPreferencesRoutes(opts) {
53674
+ return [
53675
+ {
53676
+ path: opts?.path ?? 'notifications',
53677
+ component: NotificationPreferencesViewComponent,
53678
+ data: { notificationPreferencesConfig: opts?.config },
53679
+ },
53680
+ ];
53681
+ }
53682
+
53543
53683
  /**
53544
53684
  * Helper para montar la vista Permisos (`val-permissions-view`) como ruta en una
53545
53685
  * app del factory. El `config` se pasa por route `data` (`permissionsConfig`) y el
@@ -67204,5 +67344,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.14", ngImpo
67204
67344
  * Generated bundle index. Do not edit.
67205
67345
  */
67206
67346
 
67207
- export { ACTION_CARD_DEFAULTS, AD_SIZE_MAP, API_TABLE_COLUMN_LABELS, APP_VERSION_PLATFORM_PLUGIN, APP_VERSION_REMOTE_PLUGIN, ARTICLE_CARD_DEFAULTS, ARTICLE_SPACING, ARTICLE_STRIP_DEFAULTS, AUTH_CTA_DEFAULTS, AVATAR_UPLOAD_DEFAULTS, AboutViewComponent, AccordionComponent, AccountViewComponent, ActionCardComponent, ActionHeaderComponent, ActionType, AdSlotComponent, AdsLoaderService, AdsService, AlertBoxComponent, AnalyticsErrorHandler, AnalyticsRouterTracker, AnalyticsService, AnimatedTerminalComponent, ApiKeyCreateModalComponent, ApiKeyService, ApiKeysModalComponent, ApiKeysViewComponent, AppConfigService, AppVersionService, ArticleBuilder, ArticleCardComponent, ArticleComponent, ArticleStripComponent, AttachmentUploaderComponent, AuthBackgroundComponent, AuthCtaComponent, AuthService, AuthStateService, AuthStorageService, AuthSyncService, AvatarComponent, AvatarUploadComponent, BOTTOM_NAV_DEFAULTS, BannerComponent, BlogPostBuilder, BottomNavComponent, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, CALLOUT_LABELS, CHEV_KEYS, CIRCLE_KEYS, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CORNER_KEYS, CTA_CARD_DEFAULTS, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, ChangeEmailModalComponent, ChangePasswordModalComponent, CheckInputComponent, CheckboxRadioInputComponent, ChipGroupComponent, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContainerComponent, ContentLoaderComponent, ContentReactionComponent, ContentService, ContentTransformer, CookieBannerComponent, CountdownComponent, CreateOrgModalComponent, CtaCardComponent, CurrencyInputComponent, DEFAULT_ADS_CONFIG, DEFAULT_APP_CONFIG_SERVICE_CONFIG, DEFAULT_APP_VERSION_SERVICE_CONFIG, DEFAULT_AUTH_CONFIG, DEFAULT_BACK_HEADER, DEFAULT_BUTTON_PRESETS, DEFAULT_CANCEL_BUTTON, DEFAULT_CHECK_INTERVAL_MS, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_DEBUG_CONSOLE_CONFIG, DEFAULT_DONATION_CONFIG, DEFAULT_EMPTY_STATE, DEFAULT_EMULATOR_CONFIG, DEFAULT_FEEDBACK_CONFIG, DEFAULT_FEEDBACK_TYPE_OPTIONS, DEFAULT_HOME_HEADER, DEFAULT_INFINITE_LIST_METADATA, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PLATFORMS, DEFAULT_PRESETS, DEFAULT_REFRESHER_METADATA, DEFAULT_SKELETON_CONFIG, DEFAULT_SPLASH_SCREEN_CONFIG, DataTableComponent, DateInputComponent, DatePickerComponent, DateRangeInputComponent, DebugConsoleComponent, DeleteAccountModalComponent, DetailSkeletonComponent, DeviceService, DisplayComponent, DividerComponent, DocsApiTableComponent, DocsBreadcrumbComponent, DocsBuilder, DocsCalloutComponent, DocsCodeExampleComponent, DocsLayoutComponent, DocsNavLinksComponent, DocsNavigationService, DocsPageComponent, DocsSearchComponent, DocsSectionComponent, DocsShellComponent, DocsSidebarComponent, DocsTocComponent, DonationService, DownloadService, EditOrgModalComponent, EmptyStateComponent, ExpandableTextComponent, FEATURES_LIST_DEFAULTS, FUN_MODAL_DEFAULTS, FabComponent, FaqComponent, FeaturesListComponent, FeedbackFormComponent, FeedbackService, FieldListComponent, FileInputComponent, FirebaseService, FirestoreCollectionFactory, FirestoreService, FontSizeOption, FontSizeService, FooterComponent, FooterLinksComponent, FormComponent, FormSkeletonComponent, FunHeaderComponent, FunModalComponent, GlassComponent, GlowCardComponent, GlowComponent, GridSkeletonComponent, HANDOFF_ROUTE_PARAM, HANDOFF_TOKEN_PARAM, HandoffService, HasPermissionDirective, HeaderComponent, HintComponent, HorizontalScrollComponent, HrefComponent, I18nService, IMAGE_DEFAULTS, INITIAL_AUTH_STATE, INITIAL_MFA_STATE, INVITATION_CARD_DEFAULTS, IONIC_COLORS$5 as IONIC_COLORS, Icon, IconComponent, IconService, ImageComponent, ImageCropComponent, ImageService, InAppBrowserService, InfiniteListComponent, InfoComponent, InputI18nHelper, InputType, InvitationCardComponent, InviteMemberModalComponent, ItemListComponent, LANG_STORAGE_KEY$1 as LANG_STORAGE_KEY, LEGAL_CONTENT_CONFIG, LOGIN_DEFAULTS, LandingSplitComponent, LandingStepsComponent, LanguageSelectorComponent, LayeredCardComponent, LegalContentService, LegalLinkService, LinkComponent, LinkProcessorService, LinkedProvidersComponent, LinksAccordionComponent, LinksCakeComponent, ListSkeletonComponent, LoadingDirective, LocalStorageService, LocaleService, LoginAttemptModalComponent, LoginComponent, MEDIA_OBJECT_DEFAULTS, MEMBER_CARD_DEFAULTS, METRIC_CARD_DEFAULTS, MODAL_SIZES, MOTIF_KEYS, MOTION, MaintenancePageComponent, MarkdownArticleParserService, MediaObjectComponent, MemberCardComponent, MemberDetailModalComponent, MemberImportModalComponent, MenuComponent, MessagingService, MetaService, MetricCardComponent, MfaModalComponent, ModalService, ModalShellComponent, MultiSelectSearchComponent, NUM_KEYS, NavigationService, NetworkBannerComponent, NetworkStatusService, NewsBuilder, NoContentComponent, NotesBoxComponent, NotificationActionService, NotificationPreferencesViewComponent, NotificationsService, NotificationsViewComponent, NumberFromToComponent, NumberStepperComponent, OAUTH_PROVIDERS_INFO, OAuthCallbackComponent, OAuthService, OrgInfoSheetComponent, OrgService, OrgSwitchService, OrganizationViewComponent, PATTERN_MOTIFS, PATTERN_PALETTES, PATTERN_STYLE_CONFIGS, PLATFORM_CONFIGS, PageContentComponent, PageLinksComponent, PageRefreshService, PageTemplateComponent, PageWavesComponent, PageWrapperComponent, PaginationComponent, PaginationService, PasswordInputComponent, PatternComponent, PermissionCatalogService, PermissionSelectorComponent, PermissionsViewComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, PreferencesService, PreferencesViewComponent, PresetService, PriceTagComponent, ProcessLinksPipe, ProfileSkeletonComponent, ProfileViewComponent, ProgressBarComponent, ProgressRingComponent, ProgressStatusComponent, PrompterComponent, QR_PRESETS, QrCodeComponent, QrGeneratorService, QrScannerComponent, QueryBuilder, QuoteBoxComponent, REQUEST_STATUSES, RadioInputComponent, RangeInputComponent, RatingComponent, RefresherComponent, RequestFirestoreService, RequestFormBuilderService, RequestService, RightsFooterComponent, RotatingTextComponent, SEARCH_HEADER_DEFAULTS, SETTINGS_SECTIONS_CATALOG, SHAPE_KEYS, SHARE_PROFILE_MODAL_DEFAULTS, SKELETON_LAYOUT_DEFAULT_ROWS, SKELETON_PRESETS, SOLID_KEYS, STATS_BAR_DEFAULTS, STROKE_KEYS, SearchHeaderComponent, SearchSelectorComponent, SearchbarComponent, SecurityViewComponent, SegmentControlComponent, SelectSearchComponent, SessionListModalComponent, SessionService, SettingsHubComponent, ShareButtonsComponent, ShareProfileModalComponent, SimpleComponent, SkeletonComponent, SkeletonLayoutComponent, SkeletonService, SplashScreenService, StatsBarComponent, StatsCardComponent, StepperComponent, StorageService, SwipeCarouselComponent, SwitchOrgModalComponent, TRI_KEYS, TabbedContentComponent, TableSkeletonComponent, TabsComponent, Terminal404Component, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, TicketCardComponent, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, TokenService, ToolbarActionType, ToolbarComponent, TransferOwnershipModalComponent, TranslatePipe, TypedCollection, UPDATE_BANNER_DEFAULT_CONTENT, UPDATE_BANNER_I18N_NAMESPACE, UpdateBannerComponent, UserAvatarComponent, UsernameInputComponent, VALTECH_ADS_CONFIG, VALTECH_APP_CONFIG, VALTECH_APP_VERSION, VALTECH_AUTH_CONFIG, VALTECH_COMPANY_LINKS, VALTECH_CONTENT_CONFIG, VALTECH_DEBUG_CONSOLE, VALTECH_DEFAULT_CONTENT, VALTECH_DONATION_CONFIG, VALTECH_FEEDBACK_CONFIG, VALTECH_FIREBASE_CONFIG, VALTECH_FOOTER_I18N, VALTECH_FOOTER_LOGO, VALTECH_LANGUAGE_SELECTOR, VALTECH_LEGAL_CONFIG, VALTECH_NETWORK_ERROR_KEY, VALTECH_SETTINGS_MENU_LINKS, VALTECH_SITE_PATHS, VALTECH_SOCIAL_LINKS, VALTECH_SPLASH_SCREEN, VALTECH_WEB_BASE_URLS, VERSION, ValtechErrorService, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, authGuard, authInterceptor, authPasswordValidator, beautifyLegalArticle, blogPost, buildCompanyFooterProps, buildFooterLinks, buildLegalLinkResolver, buildPath, buildSettingsCards, button, collections, connectPageRefresh, createErrorStateProps, createFirebaseConfig, createGlowCardProps, createInitialPaginationState, createNumberFromToField, createPageState, createPermissionLabeler, createRefreshableStream, createTitleProps, docs, errorLoggingInterceptor, extractPathParams, generatePatternTiles, generateRandomTile, getAppInfo, getAppVersion, getCollectionPath, getDocumentId, getTimeOfDayKey, goToTop, guestGuard, hasEmulators, iconButton, interpretError, isAtEnd, isCollectionPath, isDocumentPath, isEmulatorMode, isIonicColor, isValidPath, joinPath, maxLength, mulberry32, news, parseMarkdownArticle, permissionGuard, permissionGuardFromRoute, provideLegalContent, provideSplashScreen, provideValtechAboutRoutes, provideValtechAccountRoutes, provideValtechAds, provideValtechApiKeysRoutes, provideValtechAppConfig, provideValtechAppVersion, provideValtechAppVersionHttp, provideValtechAuth, provideValtechAuthInterceptor, provideValtechContent, provideValtechDebugConsole, provideValtechDonations, provideValtechErrorHandling, provideValtechFeedback, provideValtechFirebase, provideValtechI18n, provideValtechLegal, provideValtechNotificationPreferencesRoutes, provideValtechNotificationsRoutes, provideValtechOrganizationRoutes, provideValtechPermissionsRoutes, provideValtechPreferencesRoutes, provideValtechPresets, provideValtechProfileRoutes, provideValtechSecurityRoutes, provideValtechSettingsRoutes, provideValtechSite, provideValtechSkeleton, query, renderPatternSvgInner, replaceSpecialChars, resolveColor, resolveInputDefaultValue, resolveIonicColor, resolveWebBaseUrl, roleGuard, storagePaths, superAdminGuard, toArticle };
67347
+ export { ACTION_CARD_DEFAULTS, AD_SIZE_MAP, API_TABLE_COLUMN_LABELS, APP_VERSION_PLATFORM_PLUGIN, APP_VERSION_REMOTE_PLUGIN, ARTICLE_CARD_DEFAULTS, ARTICLE_SPACING, ARTICLE_STRIP_DEFAULTS, AUTH_CTA_DEFAULTS, AVATAR_UPLOAD_DEFAULTS, AboutViewComponent, AccordionComponent, AccountViewComponent, ActionCardComponent, ActionHeaderComponent, ActionType, AdSlotComponent, AdsLoaderService, AdsService, AlertBoxComponent, AnalyticsErrorHandler, AnalyticsRouterTracker, AnalyticsService, AnimatedTerminalComponent, ApiKeyCreateModalComponent, ApiKeyService, ApiKeysModalComponent, ApiKeysViewComponent, AppConfigService, AppVersionService, ArticleBuilder, ArticleCardComponent, ArticleComponent, ArticleStripComponent, AttachmentUploaderComponent, AuthBackgroundComponent, AuthCtaComponent, AuthService, AuthStateService, AuthStorageService, AuthSyncService, AvatarComponent, AvatarUploadComponent, BOTTOM_NAV_DEFAULTS, BannerComponent, BlogPostBuilder, BottomNavComponent, BoxComponent, BreadcrumbComponent, ButtonComponent, ButtonGroupComponent, CALLOUT_LABELS, CHEV_KEYS, CIRCLE_KEYS, COMMON_COUNTRY_CODES, COMMON_CURRENCIES, CORNER_KEYS, CTA_CARD_DEFAULTS, CURRENCY_INFO, CardComponent, CardSection, CardType, CardsCarouselComponent, ChangeEmailModalComponent, ChangePasswordModalComponent, CheckInputComponent, CheckboxRadioInputComponent, ChipGroupComponent, CodeDisplayComponent, CommandDisplayComponent, CommentComponent, CommentInputComponent, CommentSectionComponent, CompanyFooterComponent, ComponentStates, ConfirmationDialogService, ContainerComponent, ContentLoaderComponent, ContentReactionComponent, ContentService, ContentTransformer, CookieBannerComponent, CountdownComponent, CreateOrgModalComponent, CtaCardComponent, CurrencyInputComponent, DEFAULT_ADS_CONFIG, DEFAULT_APP_CONFIG_SERVICE_CONFIG, DEFAULT_APP_VERSION_SERVICE_CONFIG, DEFAULT_AUTH_CONFIG, DEFAULT_BACK_HEADER, DEFAULT_BUTTON_PRESETS, DEFAULT_CANCEL_BUTTON, DEFAULT_CHECK_INTERVAL_MS, DEFAULT_CONFIRM_BUTTON, DEFAULT_COUNTDOWN_LABELS, DEFAULT_COUNTDOWN_LABELS_EN, DEFAULT_DEBUG_CONSOLE_CONFIG, DEFAULT_DONATION_CONFIG, DEFAULT_EMPTY_STATE, DEFAULT_EMULATOR_CONFIG, DEFAULT_FEEDBACK_CONFIG, DEFAULT_FEEDBACK_TYPE_OPTIONS, DEFAULT_HOME_HEADER, DEFAULT_INFINITE_LIST_METADATA, DEFAULT_MODAL_CANCEL_BUTTON, DEFAULT_MODAL_CONFIRM_BUTTON, DEFAULT_PAGE_SIZE_OPTIONS, DEFAULT_PLATFORMS, DEFAULT_PRESETS, DEFAULT_REFRESHER_METADATA, DEFAULT_SKELETON_CONFIG, DEFAULT_SPLASH_SCREEN_CONFIG, DataTableComponent, DateInputComponent, DatePickerComponent, DateRangeInputComponent, DebugConsoleComponent, DeleteAccountModalComponent, DetailSkeletonComponent, DeviceService, DisplayComponent, DividerComponent, DocsApiTableComponent, DocsBreadcrumbComponent, DocsBuilder, DocsCalloutComponent, DocsCodeExampleComponent, DocsLayoutComponent, DocsNavLinksComponent, DocsNavigationService, DocsPageComponent, DocsSearchComponent, DocsSectionComponent, DocsShellComponent, DocsSidebarComponent, DocsTocComponent, DonationService, DownloadService, EditOrgModalComponent, EmptyStateComponent, ExpandableTextComponent, FEATURES_LIST_DEFAULTS, FUN_MODAL_DEFAULTS, FabComponent, FaqComponent, FeaturesListComponent, FeedbackFormComponent, FeedbackService, FieldListComponent, FileInputComponent, FirebaseService, FirestoreCollectionFactory, FirestoreService, FontSizeOption, FontSizeService, FooterComponent, FooterLinksComponent, FormComponent, FormSkeletonComponent, FunHeaderComponent, FunModalComponent, GlassComponent, GlowCardComponent, GlowComponent, GridSkeletonComponent, HANDOFF_ROUTE_PARAM, HANDOFF_TOKEN_PARAM, HandoffService, HasPermissionDirective, HeaderComponent, HintComponent, HorizontalScrollComponent, HrefComponent, I18nService, IMAGE_DEFAULTS, INITIAL_AUTH_STATE, INITIAL_MFA_STATE, INVITATION_CARD_DEFAULTS, IONIC_COLORS$5 as IONIC_COLORS, Icon, IconComponent, IconService, ImageComponent, ImageCropComponent, ImageService, InAppBrowserService, InfiniteListComponent, InfoComponent, InputI18nHelper, InputType, InvitationCardComponent, InviteMemberModalComponent, ItemListComponent, LANG_STORAGE_KEY$1 as LANG_STORAGE_KEY, LEGAL_CONTENT_CONFIG, LOGIN_DEFAULTS, LandingSplitComponent, LandingStepsComponent, LanguageSelectorComponent, LayeredCardComponent, LegalContentService, LegalLinkService, LinkComponent, LinkProcessorService, LinkedProvidersComponent, LinksAccordionComponent, LinksCakeComponent, ListSkeletonComponent, LoadingDirective, LocalStorageService, LocaleService, LoginAttemptModalComponent, LoginComponent, MEDIA_OBJECT_DEFAULTS, MEMBER_CARD_DEFAULTS, METRIC_CARD_DEFAULTS, MODAL_SIZES, MOTIF_KEYS, MOTION, MaintenancePageComponent, MarkdownArticleParserService, MediaObjectComponent, MemberCardComponent, MemberDetailModalComponent, MemberImportModalComponent, MenuComponent, MessagingService, MetaService, MetricCardComponent, MfaModalComponent, ModalService, ModalShellComponent, MultiSelectSearchComponent, NUM_KEYS, NavigationService, NetworkBannerComponent, NetworkStatusService, NewsBuilder, NoContentComponent, NotesBoxComponent, NotificationActionService, NotificationPreferencesViewComponent, NotificationsService, NotificationsViewComponent, NumberFromToComponent, NumberStepperComponent, OAUTH_PROVIDERS_INFO, OAuthCallbackComponent, OAuthService, OrgInfoSheetComponent, OrgService, OrgSwitchService, OrganizationViewComponent, PATTERN_MOTIFS, PATTERN_PALETTES, PATTERN_STYLE_CONFIGS, PLATFORM_CONFIGS, PageContentComponent, PageLinksComponent, PageRefreshService, PageTemplateComponent, PageWavesComponent, PageWrapperComponent, PaginationComponent, PaginationService, PasswordInputComponent, PatternComponent, PermissionCatalogService, PermissionSelectorComponent, PermissionsModalComponent, PermissionsViewComponent, PhoneInputComponent, PillComponent, PinInputComponent, PlainCodeBoxComponent, PopoverSelectorComponent, PreferencesService, PreferencesViewComponent, PresetService, PriceTagComponent, ProcessLinksPipe, ProfileSkeletonComponent, ProfileViewComponent, ProgressBarComponent, ProgressRingComponent, ProgressStatusComponent, PrompterComponent, QR_PRESETS, QrCodeComponent, QrGeneratorService, QrScannerComponent, QueryBuilder, QuoteBoxComponent, REQUEST_STATUSES, RadioInputComponent, RangeInputComponent, RatingComponent, RefresherComponent, RequestFirestoreService, RequestFormBuilderService, RequestService, RightsFooterComponent, RotatingTextComponent, SEARCH_HEADER_DEFAULTS, SETTINGS_SECTIONS_CATALOG, SHAPE_KEYS, SHARE_PROFILE_MODAL_DEFAULTS, SKELETON_LAYOUT_DEFAULT_ROWS, SKELETON_PRESETS, SOLID_KEYS, STATS_BAR_DEFAULTS, STROKE_KEYS, SearchHeaderComponent, SearchSelectorComponent, SearchbarComponent, SecurityViewComponent, SegmentControlComponent, SelectSearchComponent, SessionListModalComponent, SessionService, SettingsHubComponent, ShareButtonsComponent, ShareProfileModalComponent, SimpleComponent, SkeletonComponent, SkeletonLayoutComponent, SkeletonService, SplashScreenService, StatsBarComponent, StatsCardComponent, StepperComponent, StorageService, SwipeCarouselComponent, SwitchOrgModalComponent, TRI_KEYS, TabbedContentComponent, TableSkeletonComponent, TabsComponent, Terminal404Component, TestimonialCardComponent, TestimonialCarouselComponent, TextComponent, TextInputComponent, TextareaInputComponent, ThemeOption, ThemeService, TicketCardComponent, TimelineComponent, TitleBlockComponent, TitleComponent, ToastService, ToggleInputComponent, TokenService, ToolbarActionType, ToolbarComponent, TransferOwnershipModalComponent, TranslatePipe, TypedCollection, UPDATE_BANNER_DEFAULT_CONTENT, UPDATE_BANNER_I18N_NAMESPACE, UpdateBannerComponent, UserAvatarComponent, UsernameInputComponent, VALTECH_ADS_CONFIG, VALTECH_APP_CONFIG, VALTECH_APP_VERSION, VALTECH_AUTH_CONFIG, VALTECH_COMPANY_LINKS, VALTECH_CONTENT_CONFIG, VALTECH_DEBUG_CONSOLE, VALTECH_DEFAULT_CONTENT, VALTECH_DONATION_CONFIG, VALTECH_FEEDBACK_CONFIG, VALTECH_FIREBASE_CONFIG, VALTECH_FOOTER_I18N, VALTECH_FOOTER_LOGO, VALTECH_LANGUAGE_SELECTOR, VALTECH_LEGAL_CONFIG, VALTECH_NETWORK_ERROR_KEY, VALTECH_SETTINGS_MENU_LINKS, VALTECH_SITE_PATHS, VALTECH_SOCIAL_LINKS, VALTECH_SPLASH_SCREEN, VALTECH_WEB_BASE_URLS, VERSION, ValtechErrorService, WizardComponent, WizardFooterComponent, applyDefaultValueToControl, authGuard, authInterceptor, authPasswordValidator, beautifyLegalArticle, blogPost, buildCompanyFooterProps, buildFooterLinks, buildLegalLinkResolver, buildPath, buildSettingsCards, button, collections, connectPageRefresh, createErrorStateProps, createFirebaseConfig, createGlowCardProps, createInitialPaginationState, createNumberFromToField, createPageState, createPermissionLabeler, createRefreshableStream, createTitleProps, docs, errorLoggingInterceptor, extractPathParams, generatePatternTiles, generateRandomTile, getAppInfo, getAppVersion, getCollectionPath, getDocumentId, getTimeOfDayKey, goToTop, guestGuard, hasEmulators, iconButton, interpretError, isAtEnd, isCollectionPath, isDocumentPath, isEmulatorMode, isIonicColor, isValidPath, joinPath, maxLength, mulberry32, news, parseMarkdownArticle, permissionGuard, permissionGuardFromRoute, provideLegalContent, provideSplashScreen, provideValtechAboutRoutes, provideValtechAccountRoutes, provideValtechAds, provideValtechApiKeysRoutes, provideValtechAppConfig, provideValtechAppVersion, provideValtechAppVersionHttp, provideValtechAuth, provideValtechAuthInterceptor, provideValtechContent, provideValtechDebugConsole, provideValtechDonations, provideValtechErrorHandling, provideValtechFeedback, provideValtechFirebase, provideValtechI18n, provideValtechLegal, provideValtechNotificationPreferencesRoutes, provideValtechNotificationsRoutes, provideValtechOrganizationRoutes, provideValtechPermissionsRoutes, provideValtechPreferencesRoutes, provideValtechPresets, provideValtechProfileRoutes, provideValtechSecurityRoutes, provideValtechSettingsRoutes, provideValtechSite, provideValtechSkeleton, query, renderPatternSvgInner, replaceSpecialChars, resolveColor, resolveInputDefaultValue, resolveIonicColor, resolveWebBaseUrl, roleGuard, storagePaths, superAdminGuard, toArticle };
67208
67348
  //# sourceMappingURL=valtech-components.mjs.map