@oneuptime/common 11.5.6 → 11.5.8

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 (33) hide show
  1. package/Models/DatabaseModels/AIRun.ts +31 -0
  2. package/Server/API/CodeFixRunAPI.ts +25 -74
  3. package/Server/Infrastructure/Postgres/SchemaMigrations/1784033837629-MigrationName.ts +11 -0
  4. package/Server/Infrastructure/Postgres/SchemaMigrations/1784105912819-BackfillCodeFixTaskType.ts +41 -0
  5. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +2 -0
  6. package/Server/Middleware/MasterAdminAuthorization.ts +1 -1
  7. package/Server/Middleware/ProjectAuthorization.ts +1 -1
  8. package/Server/Services/AIRunService.ts +10 -11
  9. package/Server/Utils/AI/AIRunPrivacyFilter.ts +106 -0
  10. package/Tests/Server/Utils/AI/AIRunPrivacyFilter.test.ts +295 -0
  11. package/Types/Email/EmailTemplateType.ts +2 -0
  12. package/UI/Components/EditionLabel/EditionLabel.tsx +821 -331
  13. package/build/dist/Models/DatabaseModels/AIRun.js +31 -0
  14. package/build/dist/Models/DatabaseModels/AIRun.js.map +1 -1
  15. package/build/dist/Server/API/CodeFixRunAPI.js +25 -53
  16. package/build/dist/Server/API/CodeFixRunAPI.js.map +1 -1
  17. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784033837629-MigrationName.js +9 -0
  18. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784033837629-MigrationName.js.map +1 -1
  19. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784105912819-BackfillCodeFixTaskType.js +36 -0
  20. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1784105912819-BackfillCodeFixTaskType.js.map +1 -0
  21. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +2 -0
  22. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  23. package/build/dist/Server/Middleware/MasterAdminAuthorization.js +1 -1
  24. package/build/dist/Server/Middleware/ProjectAuthorization.js +1 -1
  25. package/build/dist/Server/Services/AIRunService.js +10 -3
  26. package/build/dist/Server/Services/AIRunService.js.map +1 -1
  27. package/build/dist/Server/Utils/AI/AIRunPrivacyFilter.js +82 -0
  28. package/build/dist/Server/Utils/AI/AIRunPrivacyFilter.js.map +1 -0
  29. package/build/dist/Types/Email/EmailTemplateType.js +1 -0
  30. package/build/dist/Types/Email/EmailTemplateType.js.map +1 -1
  31. package/build/dist/UI/Components/EditionLabel/EditionLabel.js +424 -139
  32. package/build/dist/UI/Components/EditionLabel/EditionLabel.js.map +1 -1
  33. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import Modal, { ModalWidth } from "../Modal/Modal";
2
- import Icon, { IconType, SizeProp } from "../Icon/Icon";
2
+ import Icon, { IconType } from "../Icon/Icon";
3
3
  import IconProp from "../../../Types/Icon/IconProp";
4
4
  import Input from "../Input/Input";
5
5
  import Button, { ButtonStyleType } from "../Button/Button";
@@ -34,6 +34,19 @@ export interface ComponentProps {
34
34
  }
35
35
 
36
36
  const ENTERPRISE_URL: string = "https://oneuptime.com/enterprise/demo";
37
+ const SALES_EMAIL: string = "sales@oneuptime.com";
38
+ const SALES_MAILTO_URL: string = "mailto:sales@oneuptime.com";
39
+
40
+ /*
41
+ * Seat usage at or above this percent asks the admin to talk to OneUptime about
42
+ * expanding. Matches CRITICAL_CAPACITY_PERCENT in
43
+ * App/FeatureSet/Workers/Jobs/InstanceHealth/EvaluateClickhouseCapacity.ts.
44
+ */
45
+ const SEAT_WARNING_PERCENT: number = 90;
46
+
47
+ type SeatTone = "healthy" | "approaching" | "breached";
48
+
49
+ type PillTone = "normal" | "warning" | "alerted";
37
50
 
38
51
  type ParseLicenseInstancesFunction = (
39
52
  value: unknown,
@@ -310,7 +323,12 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
310
323
  return currentUserCount > userLimit;
311
324
  }, [licenseValid, userLimit, currentUserCount]);
312
325
 
313
- const userUsagePercent: number | null = useMemo(() => {
326
+ /*
327
+ * Unclamped and unrounded. The only value that can tell 120/120 apart from
328
+ * 240/120. Null means there is no seat limit to measure against, either
329
+ * because the license is unlimited or because usage has not been reported.
330
+ */
331
+ const rawUserUsagePercent: number | null = useMemo(() => {
314
332
  if (typeof userLimit !== "number" || userLimit <= 0) {
315
333
  return null;
316
334
  }
@@ -319,12 +337,165 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
319
337
  return null;
320
338
  }
321
339
 
322
- return Math.min(
323
- 100,
324
- Math.max(0, Math.round((currentUserCount / userLimit) * 100)),
325
- );
340
+ return Math.max(0, (currentUserCount / userLimit) * 100);
341
+ }, [userLimit, currentUserCount]);
342
+
343
+ /*
344
+ * The percent the admin reads, and the one the warning fires on. May exceed
345
+ * 100 so a breach shows a real figure. Floored to 99 when rounding would
346
+ * claim 100% while a seat is genuinely free, so 599/600 never reads
347
+ * "100% of licensed seats in use" next to "1 seat remaining".
348
+ */
349
+ const seatUsageDisplayPercent: number | null = useMemo(() => {
350
+ if (rawUserUsagePercent === null) {
351
+ return null;
352
+ }
353
+
354
+ const rounded: number = Math.round(rawUserUsagePercent);
355
+
356
+ if (rounded >= 100 && rawUserUsagePercent < 100) {
357
+ return 99;
358
+ }
359
+
360
+ return rounded;
361
+ }, [rawUserUsagePercent]);
362
+
363
+ /*
364
+ * The displayed percent clamped to 0-100. Drives the bar width and
365
+ * aria-valuenow, so the fill can never overflow its track and aria-valuenow
366
+ * can never exceed aria-valuemax. Derived from the displayed percent rather
367
+ * than the raw ratio so the fill always agrees with the caption beneath it:
368
+ * 599/600 reads 99% and fills to 99%, not to a visually full 100%. The true
369
+ * figure for a breach goes in aria-valuetext.
370
+ */
371
+ const seatUsageBarPercent: number = useMemo(() => {
372
+ if (seatUsageDisplayPercent === null) {
373
+ return 0;
374
+ }
375
+
376
+ return Math.min(100, Math.max(0, seatUsageDisplayPercent));
377
+ }, [seatUsageDisplayPercent]);
378
+
379
+ const seatsRemaining: number | null = useMemo(() => {
380
+ if (typeof userLimit !== "number" || userLimit <= 0) {
381
+ return null;
382
+ }
383
+
384
+ if (typeof currentUserCount !== "number") {
385
+ return null;
386
+ }
387
+
388
+ return userLimit - currentUserCount;
326
389
  }, [userLimit, currentUserCount]);
327
390
 
391
+ const seatsRemainingText: string = useMemo(() => {
392
+ if (typeof seatsRemaining !== "number") {
393
+ return "";
394
+ }
395
+
396
+ if (seatsRemaining > 0) {
397
+ return `${seatsRemaining.toLocaleString()} ${
398
+ seatsRemaining === 1 ? "seat" : "seats"
399
+ } remaining`;
400
+ }
401
+
402
+ if (seatsRemaining === 0) {
403
+ return "No seats remaining";
404
+ }
405
+
406
+ return `${Math.abs(seatsRemaining).toLocaleString()} over limit`;
407
+ }, [seatsRemaining]);
408
+
409
+ /*
410
+ * Total and mutually exclusive. isUserLimitBreached is tested first, so
411
+ * reaching the >= 90 test structurally guarantees the limit is not exceeded
412
+ * and the amber nudge can never co-fire with the red breach.
413
+ *
414
+ * The test runs on the displayed percent rather than the raw ratio so that
415
+ * the number triggering the warning is the number printed under the bar.
416
+ */
417
+ const seatTone: SeatTone = useMemo(() => {
418
+ if (!licenseValid) {
419
+ return "healthy";
420
+ }
421
+
422
+ if (isUserLimitBreached) {
423
+ return "breached";
424
+ }
425
+
426
+ if (
427
+ typeof seatUsageDisplayPercent === "number" &&
428
+ seatUsageDisplayPercent >= SEAT_WARNING_PERCENT
429
+ ) {
430
+ return "approaching";
431
+ }
432
+
433
+ return "healthy";
434
+ }, [licenseValid, isUserLimitBreached, seatUsageDisplayPercent]);
435
+
436
+ /*
437
+ * Copy containing apostrophes lives in string literals rather than JSX text,
438
+ * because react/no-unescaped-entities is enforced in this repo.
439
+ */
440
+ const seatAdvisoryTitle: string = useMemo(() => {
441
+ if (seatTone === "breached") {
442
+ const over: number = Math.abs(seatsRemaining || 0);
443
+
444
+ return over === 1
445
+ ? "1 user over your licensed seats"
446
+ : `${over.toLocaleString()} users over your licensed seats`;
447
+ }
448
+
449
+ if (seatTone === "approaching") {
450
+ if (seatsRemaining === 0) {
451
+ return "Every licensed seat is in use";
452
+ }
453
+
454
+ return seatsRemaining === 1
455
+ ? "Only 1 seat left on your license"
456
+ : `Only ${(seatsRemaining || 0).toLocaleString()} seats left on your license`;
457
+ }
458
+
459
+ return "";
460
+ }, [seatTone, seatsRemaining]);
461
+
462
+ const seatAdvisoryBody: string = useMemo(() => {
463
+ const limitText: string = (userLimit || 0).toLocaleString();
464
+ const inUse: string = `${(currentUserCount || 0).toLocaleString()} of ${limitText}`;
465
+
466
+ if (seatTone === "breached") {
467
+ return `This installation is using ${inUse} licensed seats. Expand your license so it covers everyone on the platform.`;
468
+ }
469
+
470
+ if (seatTone === "approaching") {
471
+ return `You're using ${inUse} licensed seats. Anyone you add beyond ${limitText} puts this installation over its licensed limit — if more people are joining, it's worth talking to OneUptime about expanding now.`;
472
+ }
473
+
474
+ return "";
475
+ }, [seatTone, currentUserCount, userLimit]);
476
+
477
+ const seatUsageAriaValueText: string = useMemo(() => {
478
+ if (typeof seatUsageDisplayPercent !== "number") {
479
+ return "";
480
+ }
481
+
482
+ if (seatTone === "breached") {
483
+ return `${seatUsageDisplayPercent}% of licensed seats in use — ${seatAdvisoryTitle}`;
484
+ }
485
+
486
+ if (typeof seatsRemaining === "number") {
487
+ return `${seatUsageDisplayPercent}% of licensed seats in use — ${seatsRemainingText}`;
488
+ }
489
+
490
+ return `${seatUsageDisplayPercent}% of licensed seats in use`;
491
+ }, [
492
+ seatUsageDisplayPercent,
493
+ seatTone,
494
+ seatAdvisoryTitle,
495
+ seatsRemaining,
496
+ seatsRemainingText,
497
+ ]);
498
+
328
499
  const editionName: string = useMemo(() => {
329
500
  if (!IS_ENTERPRISE_EDITION) {
330
501
  return "Community Edition";
@@ -352,12 +523,16 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
352
523
  return "bg-red-500";
353
524
  }
354
525
 
355
- if (isUserLimitBreached) {
526
+ if (seatTone === "breached") {
356
527
  return "bg-red-500";
357
528
  }
358
529
 
530
+ if (seatTone === "approaching") {
531
+ return "bg-amber-500";
532
+ }
533
+
359
534
  return "bg-emerald-500";
360
- }, [isConfigLoading, licenseValid, isUserLimitBreached]);
535
+ }, [isConfigLoading, licenseValid, seatTone]);
361
536
 
362
537
  const ctaLabel: string = useMemo(() => {
363
538
  if (!IS_ENTERPRISE_EDITION) {
@@ -372,13 +547,110 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
372
547
  return "Validate license";
373
548
  }
374
549
 
375
- if (isUserLimitBreached) {
550
+ if (seatTone === "breached") {
376
551
  return "User limit exceeded";
377
552
  }
378
553
 
554
+ if (seatTone === "approaching") {
555
+ return "Seats nearly full";
556
+ }
557
+
379
558
  return "View details";
559
+ }, [isConfigLoading, licenseValid, seatTone]);
560
+
561
+ const pillTone: PillTone = useMemo(() => {
562
+ if (!IS_ENTERPRISE_EDITION || isConfigLoading) {
563
+ return "normal";
564
+ }
565
+
566
+ if (
567
+ seatTone === "breached" ||
568
+ (!licenseValid && Boolean(globalConfig?.enterpriseLicenseKey))
569
+ ) {
570
+ return "alerted";
571
+ }
572
+
573
+ if (seatTone === "approaching") {
574
+ return "warning";
575
+ }
576
+
577
+ return "normal";
578
+ }, [
579
+ isConfigLoading,
580
+ licenseValid,
581
+ seatTone,
582
+ globalConfig?.enterpriseLicenseKey,
583
+ ]);
584
+
585
+ const modalIcon: IconProp = useMemo(() => {
586
+ if (!IS_ENTERPRISE_EDITION || isConfigLoading) {
587
+ return IconProp.Cube;
588
+ }
589
+
590
+ if (!licenseValid || isUserLimitBreached) {
591
+ return IconProp.Alert;
592
+ }
593
+
594
+ return IconProp.ShieldCheck;
595
+ }, [isConfigLoading, licenseValid, isUserLimitBreached]);
596
+
597
+ const modalIconType: IconType = useMemo(() => {
598
+ if (!IS_ENTERPRISE_EDITION || isConfigLoading) {
599
+ return IconType.Info;
600
+ }
601
+
602
+ if (!licenseValid || isUserLimitBreached) {
603
+ return IconType.Danger;
604
+ }
605
+
606
+ return IconType.Success;
380
607
  }, [isConfigLoading, licenseValid, isUserLimitBreached]);
381
608
 
609
+ const modalDescription: string = useMemo(() => {
610
+ if (!IS_ENTERPRISE_EDITION) {
611
+ return "You are running the free, open-source build of OneUptime.";
612
+ }
613
+
614
+ if (isConfigLoading) {
615
+ return "Checking your license with OneUptime...";
616
+ }
617
+
618
+ if (!licenseValid) {
619
+ return "Validate your license key to activate Enterprise Edition.";
620
+ }
621
+
622
+ return "License, seat usage, and the instances covered by this key.";
623
+ }, [isConfigLoading, licenseValid]);
624
+
625
+ /*
626
+ * The per-instance counts only exceed the unique total when someone actually
627
+ * appears on more than one instance, and the component cannot know whether
628
+ * they do — it receives per-instance counts and the unique total separately.
629
+ * So the summation is stated as a possibility, and is dropped entirely when
630
+ * there is no unique total to compare against. It names the unique user count
631
+ * rather than "the total above", which would be ambiguous next to a hero
632
+ * reading "119 / 120 seats".
633
+ */
634
+ const instanceOverlapText: string = useMemo(() => {
635
+ const countingRule: string = `Seats are counted uniquely across all ${licenseInstances.length} instances that share this license — the same person on multiple instances uses one seat.`;
636
+
637
+ if (typeof currentUserCount !== "number") {
638
+ return countingRule;
639
+ }
640
+
641
+ return `${countingRule} Per-instance counts can therefore add up to more than the ${currentUserCount.toLocaleString()} unique ${
642
+ currentUserCount === 1 ? "user" : "users"
643
+ } counted above.`;
644
+ }, [licenseInstances.length, currentUserCount]);
645
+
646
+ const licenseKeyHelperText: string = useMemo(() => {
647
+ if (isChangingLicense) {
648
+ return "Enter the new enterprise license key and validate it to replace the current one. Your existing license stays active until the new key is validated.";
649
+ }
650
+
651
+ return "You have installed Enterprise Edition of OneUptime. You need to validate your license key. Need a license key? Contact our sales team at";
652
+ }, [isChangingLicense]);
653
+
382
654
  const communityFeatures: Array<string> = useMemo(() => {
383
655
  return [
384
656
  "Full OneUptime platform with incident response, status pages, and workflow automation.",
@@ -440,6 +712,38 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
440
712
  closeDialog();
441
713
  };
442
714
 
715
+ type BuildSeatExpansionMailtoFunction = () => string;
716
+
717
+ const buildSeatExpansionMailto: BuildSeatExpansionMailtoFunction =
718
+ (): string => {
719
+ const subject: string =
720
+ seatTone === "breached"
721
+ ? "Expand OneUptime license - over seat limit"
722
+ : "Expand OneUptime license - nearly out of seats";
723
+
724
+ const body: string = [
725
+ "Hi OneUptime,",
726
+ "",
727
+ "We would like to expand the seat count on our enterprise license.",
728
+ "",
729
+ `Company: ${globalConfig?.enterpriseCompanyName || "Not specified"}`,
730
+ `Seats in use: ${(currentUserCount || 0).toLocaleString()} of ${(
731
+ userLimit || 0
732
+ ).toLocaleString()}`,
733
+ `Instances on this license: ${licenseInstances.length}`,
734
+ ].join("\n");
735
+
736
+ return `${SALES_MAILTO_URL}?subject=${encodeURIComponent(
737
+ subject,
738
+ )}&body=${encodeURIComponent(body)}`;
739
+ };
740
+
741
+ const handleRequestMoreSeats: () => void = () => {
742
+ if (typeof window !== "undefined") {
743
+ window.location.href = buildSeatExpansionMailto();
744
+ }
745
+ };
746
+
443
747
  const runLicenseValidation: (
444
748
  key: string,
445
749
  setLoading: React.Dispatch<React.SetStateAction<boolean>>,
@@ -514,6 +818,27 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
514
818
 
515
819
  const shouldShowEnterpriseValidationButton: boolean = showLicenseKeyInput;
516
820
 
821
+ /*
822
+ * Collapses the !configError && !isConfigLoading && licenseValid chain that
823
+ * gates the stat strip, the seat card and the instances card.
824
+ */
825
+ const showLicenseDetails: boolean =
826
+ IS_ENTERPRISE_EDITION && !configError && !isConfigLoading && licenseValid;
827
+
828
+ const showEnterpriseFeatureList: boolean =
829
+ IS_ENTERPRISE_EDITION && !configError && !isConfigLoading && !licenseValid;
830
+
831
+ /*
832
+ * GlobalConfigAPI returns userLimit and currentUserCount to anonymous callers
833
+ * because the same route serves the signed-out login page, and gates only
834
+ * licenseKey, token, instances and instanceId on isAuthenticatedUser. Gate
835
+ * the sales ask on a field the server does redact, so a signed-out visitor is
836
+ * never shown an administrator-targeted CTA.
837
+ */
838
+ const canSeeLicenseAdmin: boolean = Boolean(
839
+ globalConfig?.enterpriseLicenseKey,
840
+ );
841
+
517
842
  const modalSubmitButtonText: string | undefined = IS_ENTERPRISE_EDITION
518
843
  ? shouldShowEnterpriseValidationButton
519
844
  ? "Validate License"
@@ -537,19 +862,56 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
537
862
  : undefined
538
863
  : false;
539
864
 
540
- const showAlertedPill: boolean =
541
- IS_ENTERPRISE_EDITION &&
542
- !isConfigLoading &&
543
- (isUserLimitBreached ||
544
- (!licenseValid && Boolean(globalConfig?.enterpriseLicenseKey)));
545
-
546
- const pillClassName: string = showAlertedPill
547
- ? "group inline-flex items-center gap-2 rounded-full border border-red-200 bg-red-50 px-3 py-1 text-xs font-medium text-red-700 shadow-sm transition hover:border-red-300 hover:bg-red-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
548
- : "group inline-flex items-center gap-2 rounded-full border border-indigo-100 bg-white px-3 py-1 text-xs font-medium text-indigo-700 shadow-sm transition hover:border-indigo-300 hover:bg-indigo-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
549
-
550
- const pillCtaTextClassName: string = showAlertedPill
551
- ? "text-[11px] text-red-500 group-hover:text-red-600"
552
- : "text-[11px] text-indigo-500 group-hover:text-indigo-600";
865
+ const modalRightElement: ReactElement | undefined = showLicenseDetails ? (
866
+ <span
867
+ className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium ${
868
+ seatTone === "breached"
869
+ ? "border-red-200 bg-red-50 text-red-700"
870
+ : seatTone === "approaching"
871
+ ? "border-amber-200 bg-amber-50 text-amber-800"
872
+ : "border-emerald-200 bg-emerald-50 text-emerald-800"
873
+ }`}
874
+ >
875
+ <span
876
+ className={`h-1.5 w-1.5 shrink-0 rounded-full ${
877
+ seatTone === "breached"
878
+ ? "bg-red-500"
879
+ : seatTone === "approaching"
880
+ ? "bg-amber-500"
881
+ : "bg-emerald-500"
882
+ }`}
883
+ />
884
+ {seatTone === "breached"
885
+ ? "Seat limit exceeded"
886
+ : seatTone === "approaching"
887
+ ? "Seats nearly full"
888
+ : "License active"}
889
+ </span>
890
+ ) : undefined;
891
+
892
+ const modalLeftFooterElement: ReactElement | undefined =
893
+ showLicenseDetails && !isChangingLicense ? (
894
+ <Button
895
+ title="Change license key"
896
+ icon={IconProp.Edit}
897
+ buttonStyle={ButtonStyleType.NORMAL}
898
+ onClick={handleStartChangingLicense}
899
+ />
900
+ ) : undefined;
901
+
902
+ const pillClassName: string =
903
+ pillTone === "alerted"
904
+ ? "group inline-flex items-center gap-2 rounded-full border border-red-200 bg-red-50 px-3 py-1 text-xs font-medium text-red-700 shadow-sm transition hover:border-red-300 hover:bg-red-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
905
+ : pillTone === "warning"
906
+ ? "group inline-flex items-center gap-2 rounded-full border border-amber-200 bg-amber-50 px-3 py-1 text-xs font-medium text-amber-800 shadow-sm transition hover:border-amber-300 hover:bg-amber-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-amber-400"
907
+ : "group inline-flex items-center gap-2 rounded-full border border-indigo-100 bg-white px-3 py-1 text-xs font-medium text-indigo-700 shadow-sm transition hover:border-indigo-300 hover:bg-indigo-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
908
+
909
+ const pillCtaTextClassName: string =
910
+ pillTone === "alerted"
911
+ ? "text-[11px] text-red-500 group-hover:text-red-600"
912
+ : pillTone === "warning"
913
+ ? "text-[11px] text-amber-600 group-hover:text-amber-700"
914
+ : "text-[11px] text-indigo-500 group-hover:text-indigo-600";
553
915
 
554
916
  return (
555
917
  <>
@@ -564,11 +926,16 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
564
926
  */
565
927
  aria-label={`${editionName}, ${ctaLabel}`}
566
928
  >
567
- {showAlertedPill && (
929
+ {pillTone !== "normal" && (
568
930
  <Icon
569
- icon={IconProp.Alert}
570
- size={SizeProp.Small}
571
- className="text-red-600"
931
+ icon={
932
+ pillTone === "alerted"
933
+ ? IconProp.Alert
934
+ : IconProp.ExclaimationCircle
935
+ }
936
+ className={`h-3 w-3 ${
937
+ pillTone === "alerted" ? "text-red-600" : "text-amber-600"
938
+ }`}
572
939
  />
573
940
  )}
574
941
  <span
@@ -581,353 +948,479 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
581
948
  {isDialogOpen && (
582
949
  <Modal
583
950
  title={editionName}
951
+ description={modalDescription}
952
+ icon={modalIcon}
953
+ iconType={modalIconType}
954
+ rightElement={modalRightElement}
584
955
  submitButtonText={modalSubmitButtonText}
585
956
  closeButtonText="Close"
586
957
  onClose={closeDialog}
587
958
  onSubmit={modalOnSubmit}
588
- modalWidth={ModalWidth.Large}
959
+ modalWidth={ModalWidth.Medium}
589
960
  isLoading={modalIsLoading}
590
961
  disableSubmitButton={modalDisableSubmitButton}
591
962
  isBodyLoading={IS_ENTERPRISE_EDITION ? isConfigLoading : false}
963
+ leftFooterElement={modalLeftFooterElement}
592
964
  >
593
- <div className="space-y-3 text-sm text-gray-600">
965
+ <div className="space-y-4 text-sm text-gray-600">
594
966
  {IS_ENTERPRISE_EDITION ? (
595
967
  <>
968
+ {!configError && successMessage && (
969
+ <Alert type={AlertType.SUCCESS} title={successMessage} />
970
+ )}
971
+
596
972
  {configError && (
597
- <div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
598
- <p className="font-semibold">
599
- Unable to load license details
600
- </p>
601
- <p className="mt-1">{configError}</p>
602
- <div className="mt-3 -ml-3">
603
- <Button
604
- title="Retry"
605
- buttonStyle={ButtonStyleType.DANGER}
606
- onClick={handleRetryFetch}
607
- isLoading={isConfigLoading}
973
+ <div className="rounded-xl border border-red-200 bg-red-50 p-4">
974
+ <div className="flex items-start gap-2.5">
975
+ <Icon
976
+ icon={IconProp.Alert}
977
+ className="mt-0.5 h-4 w-4 shrink-0 text-red-600"
608
978
  />
979
+ <div className="min-w-0 flex-1">
980
+ <p className="text-sm font-semibold text-red-900">
981
+ Unable to load license details
982
+ </p>
983
+ <p className="mt-1 text-xs leading-relaxed text-red-800">
984
+ {configError}
985
+ </p>
986
+ <div className="mt-3">
987
+ <Button
988
+ title="Try again"
989
+ buttonStyle={ButtonStyleType.DANGER}
990
+ onClick={handleRetryFetch}
991
+ isLoading={isConfigLoading}
992
+ className="!mt-0 md:!ml-0"
993
+ />
994
+ </div>
995
+ </div>
609
996
  </div>
610
997
  </div>
611
998
  )}
612
999
 
613
- {!configError && !isConfigLoading && licenseValid && (
614
- <div className="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">
615
- <p className="font-semibold">License verified</p>
616
- <p className="mt-1">
617
- <span className="font-medium">Company:</span>{" "}
618
- {globalConfig?.enterpriseCompanyName || "Not specified"}
619
- </p>
620
- {licenseExpiresAtText && (
621
- <p>
622
- <span className="font-medium">Expires:</span>{" "}
623
- {licenseExpiresAtText}
624
- </p>
625
- )}
626
- </div>
1000
+ {showLicenseDetails && (
1001
+ <dl className="grid grid-cols-1 gap-px overflow-hidden rounded-xl border border-gray-200 bg-gray-200 sm:grid-cols-2">
1002
+ <div className="min-w-0 bg-white px-4 py-3">
1003
+ <dt className="text-[11px] font-medium uppercase tracking-wide text-gray-500">
1004
+ Licensed to
1005
+ </dt>
1006
+ <dd
1007
+ className="mt-1 truncate text-sm font-medium text-gray-900"
1008
+ title={globalConfig?.enterpriseCompanyName || undefined}
1009
+ >
1010
+ {globalConfig?.enterpriseCompanyName || "Not specified"}
1011
+ </dd>
1012
+ </div>
1013
+ <div className="bg-white px-4 py-3">
1014
+ <dt className="text-[11px] font-medium uppercase tracking-wide text-gray-500">
1015
+ Expires
1016
+ </dt>
1017
+ <dd className="mt-1 text-sm font-medium tabular-nums text-gray-900">
1018
+ {licenseExpiresAtText || "—"}
1019
+ </dd>
1020
+ </div>
1021
+ </dl>
627
1022
  )}
628
1023
 
629
- {!configError && !isConfigLoading && licenseValid && (
630
- <div
631
- className={`rounded-lg border p-4 shadow-sm ${
632
- isUserLimitBreached
633
- ? "border-red-200 bg-red-50"
634
- : "border-gray-200 bg-white"
635
- }`}
1024
+ {showLicenseDetails && (
1025
+ <section
1026
+ aria-labelledby="edition-seats-heading"
1027
+ className="rounded-xl border border-gray-200 bg-white p-5"
636
1028
  >
637
- <div className="flex items-start gap-3">
638
- <div
639
- className={`flex h-9 w-9 items-center justify-center rounded-full ${
640
- isUserLimitBreached
641
- ? "bg-red-100 text-red-600"
642
- : "bg-indigo-100 text-indigo-600"
643
- }`}
644
- >
645
- <Icon
646
- icon={
647
- isUserLimitBreached ? IconProp.Alert : IconProp.User
648
- }
649
- size={SizeProp.Regular}
650
- />
1029
+ <div className="flex items-start justify-between gap-4">
1030
+ <div className="min-w-0">
1031
+ <h4
1032
+ id="edition-seats-heading"
1033
+ className="text-sm font-semibold text-gray-900"
1034
+ >
1035
+ Licensed seats
1036
+ </h4>
1037
+ <p className="mt-0.5 text-xs text-gray-500">
1038
+ Unique users across every instance on this license.
1039
+ </p>
651
1040
  </div>
652
- <div className="flex-1">
653
- <div className="flex items-center justify-between">
654
- <h3
655
- className={`text-sm font-semibold ${
656
- isUserLimitBreached
657
- ? "text-red-900"
658
- : "text-gray-900"
659
- }`}
660
- >
661
- User Usage
662
- </h3>
663
- {isUserLimitBreached && (
664
- <span className="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
665
- Limit exceeded
666
- </span>
667
- )}
668
- </div>
1041
+ {seatTone !== "healthy" && (
1042
+ <span
1043
+ className={`inline-flex shrink-0 items-center rounded-full px-2 py-0.5 text-xs font-medium ${
1044
+ seatTone === "breached"
1045
+ ? "bg-red-100 text-red-700"
1046
+ : "bg-amber-100 text-amber-800"
1047
+ }`}
1048
+ >
1049
+ {seatTone === "breached"
1050
+ ? "Limit exceeded"
1051
+ : "Nearly full"}
1052
+ </span>
1053
+ )}
1054
+ </div>
669
1055
 
670
- <div className="mt-2 flex items-baseline gap-1">
671
- <span
672
- className={`text-2xl font-semibold ${
673
- isUserLimitBreached
1056
+ <div className="mt-4 flex items-end justify-between gap-4">
1057
+ <p className="flex items-baseline gap-1.5">
1058
+ <span
1059
+ className={`text-3xl font-semibold leading-none tabular-nums ${
1060
+ typeof currentUserCount !== "number"
1061
+ ? "text-gray-300"
1062
+ : seatTone === "breached"
674
1063
  ? "text-red-700"
675
1064
  : "text-gray-900"
1065
+ }`}
1066
+ >
1067
+ {typeof currentUserCount === "number"
1068
+ ? currentUserCount.toLocaleString()
1069
+ : "—"}
1070
+ </span>
1071
+ <span className="text-sm tabular-nums text-gray-500">
1072
+ {" / "}
1073
+ {typeof userLimit === "number" && userLimit > 0
1074
+ ? `${userLimit.toLocaleString()} seats`
1075
+ : "unlimited"}
1076
+ </span>
1077
+ </p>
1078
+ {typeof seatsRemaining === "number" && (
1079
+ <p
1080
+ className={`text-xs font-medium tabular-nums ${
1081
+ seatTone === "breached"
1082
+ ? "text-red-700"
1083
+ : seatTone === "approaching"
1084
+ ? "text-amber-700"
1085
+ : "text-gray-500"
1086
+ }`}
1087
+ >
1088
+ {seatsRemainingText}
1089
+ </p>
1090
+ )}
1091
+ </div>
1092
+
1093
+ {typeof seatUsageDisplayPercent === "number" && (
1094
+ <div className="mt-2.5">
1095
+ <div
1096
+ className="h-2.5 w-full overflow-hidden rounded-full bg-gray-100"
1097
+ role="progressbar"
1098
+ aria-label="Licensed seat usage"
1099
+ aria-valuenow={seatUsageBarPercent}
1100
+ aria-valuemin={0}
1101
+ aria-valuemax={100}
1102
+ aria-valuetext={seatUsageAriaValueText}
1103
+ >
1104
+ <div
1105
+ className={`h-full rounded-full transition-all duration-300 ease-out ${
1106
+ seatTone === "breached"
1107
+ ? "bg-red-500"
1108
+ : seatTone === "approaching"
1109
+ ? "bg-amber-500"
1110
+ : "bg-emerald-500"
676
1111
  }`}
677
- >
678
- {typeof currentUserCount === "number"
679
- ? currentUserCount.toLocaleString()
680
- : "—"}
681
- </span>
682
- <span className="text-sm text-gray-500">
683
- {" / "}
684
- {typeof userLimit === "number" && userLimit > 0
685
- ? `${userLimit.toLocaleString()} users`
686
- : "unlimited"}
687
- </span>
1112
+ style={{ width: `${seatUsageBarPercent}%` }}
1113
+ />
688
1114
  </div>
1115
+ <p className="mt-1.5 text-xs tabular-nums text-gray-500">
1116
+ {seatUsageDisplayPercent}% of licensed seats in use
1117
+ </p>
1118
+ </div>
1119
+ )}
689
1120
 
690
- {typeof userUsagePercent === "number" && (
691
- <div className="mt-3">
692
- <div className="h-2 w-full overflow-hidden rounded-full bg-gray-200">
693
- <div
694
- className={`h-full rounded-full transition-all ${
695
- isUserLimitBreached
696
- ? "bg-red-500"
697
- : userUsagePercent >= 80
698
- ? "bg-amber-500"
699
- : "bg-emerald-500"
700
- }`}
701
- style={{ width: `${userUsagePercent}%` }}
1121
+ {seatTone !== "healthy" && canSeeLicenseAdmin && (
1122
+ <div
1123
+ role={seatTone === "breached" ? "alert" : "status"}
1124
+ className={`mt-4 rounded-lg border p-3.5 ${
1125
+ seatTone === "breached"
1126
+ ? "border-red-200 bg-red-50"
1127
+ : "border-amber-200 bg-amber-50"
1128
+ }`}
1129
+ >
1130
+ <div className="flex items-start gap-2.5">
1131
+ {/*
1132
+ * Colour comes from className only. Never add a type
1133
+ * prop here: Icon emits both the type colour and
1134
+ * className into one class attribute, so two colour
1135
+ * classes would land on one svg and Tailwind's
1136
+ * emission order would pick the winner.
1137
+ */}
1138
+ <Icon
1139
+ icon={
1140
+ seatTone === "breached"
1141
+ ? IconProp.Alert
1142
+ : IconProp.ExclaimationCircle
1143
+ }
1144
+ className={`mt-0.5 h-4 w-4 shrink-0 ${
1145
+ seatTone === "breached"
1146
+ ? "text-red-600"
1147
+ : "text-amber-600"
1148
+ }`}
1149
+ />
1150
+ <div className="min-w-0 flex-1">
1151
+ <h5
1152
+ className={`text-sm font-semibold ${
1153
+ seatTone === "breached"
1154
+ ? "text-red-900"
1155
+ : "text-amber-900"
1156
+ }`}
1157
+ >
1158
+ {seatAdvisoryTitle}
1159
+ </h5>
1160
+ <p
1161
+ className={`mt-1 text-xs leading-relaxed ${
1162
+ seatTone === "breached"
1163
+ ? "text-red-800"
1164
+ : "text-amber-800"
1165
+ }`}
1166
+ >
1167
+ {seatAdvisoryBody}
1168
+ </p>
1169
+ <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2">
1170
+ <Button
1171
+ title={
1172
+ seatTone === "breached"
1173
+ ? "Expand your license"
1174
+ : "Request more seats"
1175
+ }
1176
+ icon={IconProp.Email}
1177
+ buttonStyle={
1178
+ seatTone === "breached"
1179
+ ? ButtonStyleType.DANGER
1180
+ : ButtonStyleType.PRIMARY
1181
+ }
1182
+ onClick={handleRequestMoreSeats}
1183
+ className="!mt-0 md:!ml-0"
702
1184
  />
1185
+ <span
1186
+ className={`text-[11px] ${
1187
+ seatTone === "breached"
1188
+ ? "text-red-700"
1189
+ : "text-amber-700"
1190
+ }`}
1191
+ >
1192
+ Or email{" "}
1193
+ <a
1194
+ href={SALES_MAILTO_URL}
1195
+ className={`font-medium underline ${
1196
+ seatTone === "breached"
1197
+ ? "text-red-800 hover:text-red-900"
1198
+ : "text-amber-900 hover:text-amber-950"
1199
+ }`}
1200
+ >
1201
+ {SALES_EMAIL}
1202
+ </a>{" "}
1203
+ directly.
1204
+ </span>
703
1205
  </div>
704
- <p className="mt-1 text-xs text-gray-500">
705
- {userUsagePercent}% of licensed seats in use
706
- </p>
707
1206
  </div>
708
- )}
709
-
710
- {isUserLimitBreached && (
711
- <p className="mt-3 text-xs text-red-700">
712
- Your installation has more users than your license
713
- permits. Please contact{" "}
714
- <a
715
- href="mailto:sales@oneuptime.com"
716
- className="font-medium text-red-800 underline hover:text-red-900"
717
- >
718
- sales@oneuptime.com
719
- </a>{" "}
720
- to expand your license.
721
- </p>
722
- )}
723
-
724
- {licenseInstances.length > 1 && (
725
- <p className="mt-3 text-xs text-gray-500">
726
- Users are counted uniquely across all{" "}
727
- {licenseInstances.length} instances that share this
728
- license — the same user on multiple instances uses
729
- one seat.
730
- </p>
731
- )}
732
-
733
- <p className="mt-3 text-xs text-gray-500">
734
- {userCountUpdatedAtText
735
- ? `Last reported to OneUptime on ${userCountUpdatedAtText}.`
736
- : "User count has not been reported to OneUptime yet. The first report will be sent within 24 hours."}
737
- </p>
1207
+ </div>
738
1208
  </div>
1209
+ )}
1210
+
1211
+ <div className="mt-4 border-t border-gray-100 pt-3">
1212
+ <p className="text-xs text-gray-500">
1213
+ {userCountUpdatedAtText
1214
+ ? `Last reported to OneUptime on ${userCountUpdatedAtText}.`
1215
+ : "User count has not been reported to OneUptime yet. The first report will be sent within 24 hours."}
1216
+ </p>
739
1217
  </div>
740
- </div>
1218
+ </section>
741
1219
  )}
742
1220
 
743
- {!configError &&
744
- !isConfigLoading &&
745
- licenseValid &&
746
- licenseInstances.length > 0 && (
747
- <div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
748
- <div className="flex items-center justify-between">
749
- <h3 className="text-sm font-semibold text-gray-900">
750
- Instances using this license
751
- </h3>
752
- <span className="text-xs text-gray-500">
753
- {licenseInstances.length}{" "}
754
- {licenseInstances.length === 1
755
- ? "instance"
756
- : "instances"}
757
- </span>
758
- </div>
759
- <p className="mt-1 text-xs text-gray-500">
760
- Use the same license key on every instance you deploy
761
- (staging, production, and so on). Each instance reports
762
- its usage daily.
1221
+ {showLicenseDetails && licenseInstances.length > 0 && (
1222
+ <section
1223
+ aria-labelledby="edition-instances-heading"
1224
+ className="rounded-xl border border-gray-200 bg-white p-5"
1225
+ >
1226
+ <div className="flex items-center justify-between gap-3">
1227
+ <h4
1228
+ id="edition-instances-heading"
1229
+ className="text-sm font-semibold text-gray-900"
1230
+ >
1231
+ Instances on this license
1232
+ </h4>
1233
+ <span className="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium tabular-nums text-gray-600">
1234
+ {licenseInstances.length}{" "}
1235
+ {licenseInstances.length === 1
1236
+ ? "instance"
1237
+ : "instances"}
1238
+ </span>
1239
+ </div>
1240
+ <p className="mt-1 text-xs leading-relaxed text-gray-500">
1241
+ Use the same license key on every instance you deploy
1242
+ (staging, production, and so on). Each instance reports
1243
+ its usage daily.
1244
+ </p>
1245
+ {licenseInstances.length > 1 && (
1246
+ <p className="mt-1.5 text-xs leading-relaxed text-gray-500">
1247
+ {instanceOverlapText}
763
1248
  </p>
764
- <ul className="mt-3 divide-y divide-gray-100">
765
- {licenseInstances.map(
766
- (
767
- instance: EnterpriseLicenseInstanceSummary,
768
- index: number,
769
- ) => {
770
- const isThisInstance: boolean =
771
- Boolean(thisInstanceId) &&
772
- instance.instanceId === thisInstanceId;
773
-
774
- return (
775
- <li
776
- key={instance.instanceId || index}
777
- className="flex items-center justify-between gap-3 py-2"
778
- >
779
- <div className="min-w-0">
780
- <p className="truncate text-sm font-medium text-gray-900">
1249
+ )}
1250
+ <ul className="mt-3 divide-y divide-gray-100 overflow-hidden rounded-lg border border-gray-200">
1251
+ {licenseInstances.map(
1252
+ (
1253
+ instance: EnterpriseLicenseInstanceSummary,
1254
+ index: number,
1255
+ ) => {
1256
+ const isThisInstance: boolean =
1257
+ Boolean(thisInstanceId) &&
1258
+ instance.instanceId === thisInstanceId;
1259
+
1260
+ return (
1261
+ <li
1262
+ key={instance.instanceId || index}
1263
+ className="flex items-center justify-between gap-4 bg-white px-3 py-2.5 transition-colors hover:bg-gray-50"
1264
+ >
1265
+ <div className="min-w-0 flex-1">
1266
+ <div className="flex min-w-0 items-center gap-2">
1267
+ <span
1268
+ className="truncate text-sm font-medium text-gray-900"
1269
+ title={instance.host || "Unknown host"}
1270
+ >
781
1271
  {instance.host || "Unknown host"}
782
- {isThisInstance && (
783
- <span className="ml-2 inline-flex items-center rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700">
784
- This instance
785
- </span>
786
- )}
787
- </p>
788
- <p className="text-xs text-gray-500">
789
- {formatInstanceReportedAt(
790
- instance.lastReportedAt,
791
- )}
792
- </p>
793
- </div>
794
- <div className="shrink-0 text-right text-sm text-gray-700">
795
- {typeof instance.userCount === "number"
796
- ? `${instance.userCount.toLocaleString()} ${
797
- instance.userCount === 1
798
- ? "user"
799
- : "users"
800
- }`
801
- : "—"}
1272
+ </span>
1273
+ {isThisInstance && (
1274
+ <span className="shrink-0 rounded-full bg-indigo-50 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-indigo-700">
1275
+ This instance
1276
+ </span>
1277
+ )}
802
1278
  </div>
803
- </li>
804
- );
805
- },
806
- )}
807
- </ul>
808
- </div>
809
- )}
810
-
811
- {!configError &&
812
- !isConfigLoading &&
813
- licenseValid &&
814
- !isChangingLicense && (
815
- <div className="-ml-3">
816
- <Button
817
- title="Change license key"
818
- icon={IconProp.Edit}
819
- buttonStyle={ButtonStyleType.NORMAL}
820
- onClick={handleStartChangingLicense}
821
- />
822
- </div>
823
- )}
1279
+ <p className="mt-0.5 truncate text-xs text-gray-500">
1280
+ {formatInstanceReportedAt(
1281
+ instance.lastReportedAt,
1282
+ )}
1283
+ </p>
1284
+ </div>
1285
+ <div className="flex shrink-0 items-baseline justify-end gap-1">
1286
+ {typeof instance.userCount === "number" ? (
1287
+ <>
1288
+ <span className="text-sm font-medium tabular-nums text-gray-900">
1289
+ {instance.userCount.toLocaleString()}
1290
+ </span>
1291
+ <span className="text-xs text-gray-500">
1292
+ {instance.userCount === 1
1293
+ ? "user"
1294
+ : "users"}
1295
+ </span>
1296
+ </>
1297
+ ) : (
1298
+ <span className="text-sm text-gray-400">
1299
+
1300
+ </span>
1301
+ )}
1302
+ </div>
1303
+ </li>
1304
+ );
1305
+ },
1306
+ )}
1307
+ </ul>
1308
+ </section>
1309
+ )}
824
1310
 
825
1311
  {!configError &&
826
1312
  !isConfigLoading &&
827
1313
  !licenseValid &&
828
1314
  globalConfig?.enterpriseLicenseKey && (
829
- <div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm text-red-700">
830
- <p className="font-semibold">
1315
+ <div className="rounded-xl border border-red-200 bg-red-50 p-4">
1316
+ <p className="text-sm font-semibold text-red-900">
831
1317
  License validation required
832
1318
  </p>
833
- <p className="mt-1">
1319
+ <p className="mt-1 text-xs leading-relaxed text-red-800">
834
1320
  The stored license information could not be verified.
835
1321
  Please validate the license key again.
836
1322
  </p>
837
1323
  </div>
838
1324
  )}
839
1325
 
840
- {!configError && (
841
- <>
842
- {successMessage && (
843
- <Alert type={AlertType.SUCCESS} title={successMessage} />
1326
+ {!configError && showLicenseKeyInput && (
1327
+ <section className="rounded-xl border border-gray-200 bg-white p-5">
1328
+ <div className="flex items-start justify-between gap-4">
1329
+ <div className="min-w-0">
1330
+ <label
1331
+ htmlFor="enterprise-license-key"
1332
+ className="text-sm font-semibold text-gray-900"
1333
+ >
1334
+ {isChangingLicense
1335
+ ? "New license key"
1336
+ : "License key"}
1337
+ </label>
1338
+ <p className="mt-0.5 text-xs leading-relaxed text-gray-500">
1339
+ {licenseKeyHelperText}
1340
+ {!isChangingLicense && (
1341
+ <>
1342
+ {" "}
1343
+ <a
1344
+ href={SALES_MAILTO_URL}
1345
+ className="font-medium text-indigo-600 hover:text-indigo-700"
1346
+ >
1347
+ {SALES_EMAIL}
1348
+ </a>
1349
+ .
1350
+ </>
1351
+ )}
1352
+ </p>
1353
+ </div>
1354
+ {isChangingLicense && (
1355
+ <Button
1356
+ title="Cancel"
1357
+ buttonStyle={ButtonStyleType.NORMAL}
1358
+ onClick={handleCancelChangingLicense}
1359
+ className="!mt-0 shrink-0 md:!ml-0"
1360
+ />
1361
+ )}
1362
+ </div>
1363
+ <div className="mt-3">
1364
+ <Input
1365
+ id="enterprise-license-key"
1366
+ value={licenseKeyInput}
1367
+ onChange={(value: string) => {
1368
+ setLicenseKeyInput(value);
1369
+ licenseInputEditedRef.current = true;
1370
+ }}
1371
+ placeholder="Enter your enterprise license key"
1372
+ disableSpellCheck={true}
1373
+ />
1374
+ </div>
1375
+ {validationError && (
1376
+ <div className="mt-3">
1377
+ <Alert
1378
+ type={AlertType.DANGER}
1379
+ title={validationError}
1380
+ />
1381
+ </div>
844
1382
  )}
1383
+ </section>
1384
+ )}
845
1385
 
846
- {showLicenseKeyInput && (
847
- <>
848
- <div>
849
- <label className="text-sm font-medium text-gray-700">
850
- {isChangingLicense
851
- ? "New License Key"
852
- : "License Key"}
853
- </label>
854
- <Input
855
- value={licenseKeyInput}
856
- onChange={(value: string) => {
857
- setLicenseKeyInput(value);
858
- licenseInputEditedRef.current = true;
859
- }}
860
- placeholder="Enter your enterprise license key"
861
- disableSpellCheck={true}
862
- />
863
- </div>
864
-
865
- {validationError && (
866
- <Alert
867
- type={AlertType.DANGER}
868
- title={validationError}
869
- />
870
- )}
871
-
872
- {isChangingLicense ? (
873
- <div className="flex items-center justify-between gap-3">
874
- <p className="text-xs text-gray-500">
875
- Enter the new enterprise license key and validate
876
- it to replace the current one. Your existing
877
- license stays active until the new key is
878
- validated.
879
- </p>
880
- <Button
881
- title="Cancel"
882
- buttonStyle={ButtonStyleType.NORMAL}
883
- onClick={handleCancelChangingLicense}
884
- />
885
- </div>
886
- ) : (
887
- <p className="text-xs text-gray-500">
888
- You have installed Enterprise Edition of OneUptime.
889
- You need to validate your license key. Need a
890
- license key? Contact our sales team at{" "}
891
- <a
892
- href="mailto:sales@oneuptime.com"
893
- className="font-medium text-indigo-600 hover:text-indigo-700"
1386
+ {showEnterpriseFeatureList && (
1387
+ <section
1388
+ aria-labelledby="edition-features-heading"
1389
+ className="rounded-xl border border-indigo-100 bg-gradient-to-br from-indigo-50 via-white to-white p-5"
1390
+ >
1391
+ <h4
1392
+ id="edition-features-heading"
1393
+ className="text-sm font-semibold text-indigo-900"
1394
+ >
1395
+ What your license unlocks
1396
+ </h4>
1397
+ <p className="mt-0.5 text-xs text-indigo-700">
1398
+ Validate your key above to turn these on immediately.
1399
+ </p>
1400
+ <ul className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2">
1401
+ {enterpriseFeatures.map(
1402
+ (feature: string, index: number) => {
1403
+ return (
1404
+ <li
1405
+ key={index}
1406
+ className="flex items-start gap-2 rounded-lg border border-gray-100 bg-white px-3 py-2.5"
894
1407
  >
895
- sales@oneuptime.com
896
- </a>
897
- .
898
- </p>
899
- )}
900
- </>
901
- )}
902
- </>
1408
+ <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-indigo-50">
1409
+ <Icon
1410
+ icon={IconProp.Check}
1411
+ className="h-3 w-3 text-indigo-600"
1412
+ />
1413
+ </span>
1414
+ <span className="text-xs leading-snug text-gray-700">
1415
+ {feature}
1416
+ </span>
1417
+ </li>
1418
+ );
1419
+ },
1420
+ )}
1421
+ </ul>
1422
+ </section>
903
1423
  )}
904
-
905
- <div className="rounded-lg border border-indigo-200 bg-indigo-50 p-4 shadow-sm">
906
- <h3 className="text-sm font-semibold text-indigo-900">
907
- Enterprise Edition Features
908
- </h3>
909
- <ul className="mt-3 space-y-2 text-sm text-indigo-900">
910
- {enterpriseFeatures.map(
911
- (feature: string, index: number) => {
912
- return (
913
- <li key={index} className="flex items-start gap-2">
914
- <Icon
915
- icon={IconProp.Check}
916
- type={IconType.Success}
917
- size={SizeProp.Small}
918
- className="mt-0.5"
919
- />
920
- <span className="leading-snug">{feature}</span>
921
- </li>
922
- );
923
- },
924
- )}
925
- </ul>
926
- <p className="mt-3 text-xs text-indigo-700">
927
- Already have a license? Validate it above to unlock these
928
- premium capabilities immediately.
929
- </p>
930
- </div>
931
1424
  </>
932
1425
  ) : (
933
1426
  <>
@@ -937,10 +1430,10 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
937
1430
  fit for your team.
938
1431
  </p>
939
1432
  <div className="grid gap-4 md:grid-cols-2">
940
- <div className="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
941
- <h3 className="text-sm font-semibold text-gray-900">
1433
+ <div className="rounded-xl border border-gray-200 bg-white p-4">
1434
+ <h4 className="text-sm font-semibold text-gray-900">
942
1435
  Community Edition
943
- </h3>
1436
+ </h4>
944
1437
  <ul className="mt-3 space-y-2 text-sm text-gray-600">
945
1438
  {communityFeatures.map(
946
1439
  (feature: string, index: number) => {
@@ -948,8 +1441,7 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
948
1441
  <li key={index} className="flex items-start gap-2">
949
1442
  <Icon
950
1443
  icon={IconProp.Check}
951
- size={SizeProp.Small}
952
- className="mt-0.5 text-gray-400"
1444
+ className="mt-0.5 h-3 w-3 shrink-0 text-gray-400"
953
1445
  />
954
1446
  <span className="leading-snug">{feature}</span>
955
1447
  </li>
@@ -962,10 +1454,10 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
962
1454
  workflows.
963
1455
  </p>
964
1456
  </div>
965
- <div className="rounded-lg border border-indigo-200 bg-indigo-50 p-4 shadow-sm">
966
- <h3 className="text-sm font-semibold text-indigo-900">
1457
+ <div className="rounded-xl border border-indigo-100 bg-gradient-to-br from-indigo-50 via-white to-white p-4">
1458
+ <h4 className="text-sm font-semibold text-indigo-900">
967
1459
  Enterprise Edition
968
- </h3>
1460
+ </h4>
969
1461
  <ul className="mt-3 space-y-2 text-sm text-indigo-900">
970
1462
  {enterpriseFeatures.map(
971
1463
  (feature: string, index: number) => {
@@ -973,9 +1465,7 @@ const EditionLabel: FunctionComponent<ComponentProps> = (
973
1465
  <li key={index} className="flex items-start gap-2">
974
1466
  <Icon
975
1467
  icon={IconProp.Check}
976
- type={IconType.Success}
977
- size={SizeProp.Small}
978
- className="mt-0.5"
1468
+ className="mt-0.5 h-3 w-3 shrink-0 text-indigo-600"
979
1469
  />
980
1470
  <span className="leading-snug">{feature}</span>
981
1471
  </li>