Skip to content

Commit bef278b

Browse files
Layout the account valid period form
1 parent 4e96cf9 commit bef278b

File tree

2 files changed

+274
-2
lines changed

2 files changed

+274
-2
lines changed

portal/src/graphql/adminapi/UserDetailsAccountStatus.tsx

Lines changed: 264 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ interface DisableUserCellProps {
8181

8282
interface AccountValidPeriodCellProps {
8383
data: AccountStatus;
84+
onClickSetAccountValidPeriod: () => void;
85+
onClickEditAccountValidPeriod: () => void;
8486
}
8587

8688
interface AnonymizeUserCellProps {
@@ -450,7 +452,11 @@ const AccountValidPeriodCell: React.VFC<AccountValidPeriodCellProps> =
450452
function AccountValidPeriodCell(props) {
451453
const { locale } = useContext(Context);
452454
const { themes } = useSystemConfig();
453-
const { data } = props;
455+
const {
456+
data,
457+
onClickSetAccountValidPeriod,
458+
onClickEditAccountValidPeriod,
459+
} = props;
454460
const buttonStates = getButtonStates(data);
455461
return (
456462
<ListCellLayout className={styles.actionCell}>
@@ -523,6 +529,12 @@ const AccountValidPeriodCell: React.VFC<AccountValidPeriodCellProps> =
523529
<FormattedMessage id="UserDetailsAccountStatus.account-valid-period.action.edit" />
524530
)
525531
}
532+
onClick={
533+
buttonStates.setAccountValidPeriod.accountValidFrom == null &&
534+
buttonStates.setAccountValidPeriod.accountValidUntil == null
535+
? onClickSetAccountValidPeriod
536+
: onClickEditAccountValidPeriod
537+
}
526538
/>
527539
</ListCellLayout>
528540
);
@@ -683,6 +695,16 @@ const UserDetailsAccountStatus: React.VFC<UserDetailsAccountStatusProps> =
683695
setDialogKey((prev) => prev + 1);
684696
setDialogHidden(false);
685697
}, []);
698+
const onClickSetAccountValidPeriod = useCallback(() => {
699+
setMode("set-account-valid-period");
700+
setDialogKey((prev) => prev + 1);
701+
setDialogHidden(false);
702+
}, []);
703+
const onClickEditAccountValidPeriod = useCallback(() => {
704+
setMode("edit-account-valid-period");
705+
setDialogKey((prev) => prev + 1);
706+
setDialogHidden(false);
707+
}, []);
686708
const onClickAnonymizeOrSchedule = useCallback(() => {
687709
setMode("anonymize-or-schedule");
688710
setDialogKey((prev) => prev + 1);
@@ -737,7 +759,11 @@ const UserDetailsAccountStatus: React.VFC<UserDetailsAccountStatusProps> =
737759
onClickDisable={onClickDisable}
738760
onClickReenable={onClickReenable}
739761
/>
740-
<AccountValidPeriodCell data={data} />
762+
<AccountValidPeriodCell
763+
data={data}
764+
onClickSetAccountValidPeriod={onClickSetAccountValidPeriod}
765+
onClickEditAccountValidPeriod={onClickEditAccountValidPeriod}
766+
/>
741767
<AnonymizeUserCell
742768
data={data}
743769
onClickAnonymizeImmediately={onClickAnonymizeImmediately}
@@ -780,6 +806,114 @@ export interface AccountStatusDialogProps {
780806
accountStatus: AccountStatus;
781807
}
782808

809+
interface AccountValidDateTimeControlProps {
810+
label: React.ReactElement;
811+
pickedDateTime: Date | null;
812+
onPickDateTime: (datetime: Date | null) => void;
813+
}
814+
815+
function AccountValidDateTimeControl(props: AccountValidDateTimeControlProps) {
816+
const { label, pickedDateTime, onPickDateTime } = props;
817+
const { themes } = useSystemConfig();
818+
819+
// TimePicker has some problem with its controlled component behavior.
820+
//
821+
// 1. When we clear the field, value=undefined does not cause it to render empty.
822+
// 2. Changing the date picker and thus value=something does not cause it to render.
823+
//
824+
// So we always remount it in these two cases.
825+
const [timePickerKey, setTimePickerKey] = useState(0);
826+
827+
const onSelectDate = useCallback(
828+
(date: Date | null | undefined) => {
829+
if (date == null) {
830+
onPickDateTime(null);
831+
} else {
832+
const datetime =
833+
pickedDateTime != null ? DateTime.fromJSDate(pickedDateTime) : null;
834+
onPickDateTime(
835+
DateTime.fromJSDate(date)
836+
.set({
837+
hour: datetime?.hour ?? 0,
838+
minute: datetime?.minute ?? 0,
839+
second: 0,
840+
millisecond: 0,
841+
})
842+
.toJSDate()
843+
);
844+
}
845+
setTimePickerKey((prev) => prev + 1);
846+
},
847+
[onPickDateTime, pickedDateTime]
848+
);
849+
850+
const onChange = useCallback(
851+
(_e: React.FormEvent<IComboBox>, time: Date) => {
852+
if (pickedDateTime == null) {
853+
return;
854+
}
855+
const datetime = DateTime.fromJSDate(time);
856+
onPickDateTime(
857+
DateTime.fromJSDate(pickedDateTime)
858+
.set({
859+
hour: datetime.hour,
860+
minute: datetime.minute,
861+
second: 0,
862+
millisecond: 0,
863+
})
864+
.toJSDate()
865+
);
866+
},
867+
[onPickDateTime, pickedDateTime]
868+
);
869+
870+
const onClickClear = useCallback(() => {
871+
onPickDateTime(null);
872+
setTimePickerKey((prev) => prev + 1);
873+
}, [onPickDateTime]);
874+
875+
return (
876+
<div className="flex flex-col">
877+
<Label>{label}</Label>
878+
<div className="flex flex-row gap-2">
879+
<DatePicker
880+
className="flex-1"
881+
value={pickedDateTime ?? undefined}
882+
onSelectDate={onSelectDate}
883+
/>
884+
<TimePicker
885+
key={String(timePickerKey)}
886+
className="flex-1"
887+
increments={60}
888+
allowFreeform={false}
889+
showSeconds={false}
890+
useHour12={false}
891+
dateAnchor={pickedDateTime ?? undefined}
892+
value={pickedDateTime ?? undefined}
893+
onChange={onChange}
894+
/>
895+
<DefaultButton
896+
className="self-start"
897+
text={
898+
<FormattedMessage id="AccountStatusDialog.account-valid-period.action.clear" />
899+
}
900+
onClick={onClickClear}
901+
/>
902+
</div>
903+
<Text
904+
variant="small"
905+
styles={{
906+
root: {
907+
color: themes.main.semanticColors.bodySubtext,
908+
},
909+
}}
910+
>
911+
<FormattedMessage id="AccountStatusDialog.account-valid-period.clear.hint" />
912+
</Text>
913+
</div>
914+
);
915+
}
916+
783917
export function AccountStatusDialog(
784918
props: AccountStatusDialogProps
785919
): React.ReactElement {
@@ -828,6 +962,52 @@ export function AccountStatusDialog(
828962
return trimmed;
829963
}, [disableReason]);
830964

965+
const [accountValidFrom, setAccountValidFrom] = useState<Date | null>(() => {
966+
if (accountStatus.accountValidFrom != null) {
967+
return new Date(accountStatus.accountValidFrom);
968+
}
969+
return null;
970+
});
971+
const [accountValidUntil, setAccountValidUntil] = useState<Date | null>(
972+
() => {
973+
if (accountStatus.accountValidUntil != null) {
974+
return new Date(accountStatus.accountValidUntil);
975+
}
976+
return null;
977+
}
978+
);
979+
980+
const onPickAccountValidFrom = useCallback(
981+
(date: Date | null) => {
982+
if (date == null) {
983+
setAccountValidFrom(null);
984+
} else if (accountValidUntil == null) {
985+
setAccountValidFrom(date);
986+
} else if (date.getTime() > accountValidUntil.getTime()) {
987+
setAccountValidFrom(accountValidUntil);
988+
setAccountValidUntil(date);
989+
} else {
990+
setAccountValidFrom(date);
991+
}
992+
},
993+
[accountValidUntil]
994+
);
995+
const onPickAccountValidUntil = useCallback(
996+
(date: Date | null) => {
997+
if (date == null) {
998+
setAccountValidUntil(null);
999+
} else if (accountValidFrom == null) {
1000+
setAccountValidUntil(date);
1001+
} else if (date.getTime() < accountValidFrom.getTime()) {
1002+
setAccountValidFrom(date);
1003+
setAccountValidUntil(accountValidFrom);
1004+
} else {
1005+
setAccountValidUntil(date);
1006+
}
1007+
},
1008+
[accountValidFrom]
1009+
);
1010+
8311011
const onRenderTemporarilyDisableFormField = useCallback(
8321012
(
8331013
props?: IChoiceGroupOption & IChoiceGroupOptionProps,
@@ -958,6 +1138,49 @@ export function AccountStatusDialog(
9581138
renderToString,
9591139
]);
9601140

1141+
const accountValidPeriodForm = useMemo(() => {
1142+
const formattedZone = formatSystemZone(new Date(), locale);
1143+
return (
1144+
<div className="flex flex-col gap-2">
1145+
<MessageBar
1146+
messageBarType={MessageBarType.info}
1147+
styles={{
1148+
iconContainer: {
1149+
display: "none",
1150+
},
1151+
}}
1152+
>
1153+
<FormattedMessage
1154+
id="AccountStatusDialog.disable-user.timezone-description"
1155+
values={{
1156+
timezone: formattedZone,
1157+
}}
1158+
/>
1159+
</MessageBar>
1160+
<AccountValidDateTimeControl
1161+
label={
1162+
<FormattedMessage id="AccountStatusDialog.account-valid-period.start-at.label" />
1163+
}
1164+
pickedDateTime={accountValidFrom}
1165+
onPickDateTime={onPickAccountValidFrom}
1166+
/>
1167+
<AccountValidDateTimeControl
1168+
label={
1169+
<FormattedMessage id="AccountStatusDialog.account-valid-period.end-at.label" />
1170+
}
1171+
pickedDateTime={accountValidUntil}
1172+
onPickDateTime={onPickAccountValidUntil}
1173+
/>
1174+
</div>
1175+
);
1176+
}, [
1177+
accountValidFrom,
1178+
accountValidUntil,
1179+
locale,
1180+
onPickAccountValidFrom,
1181+
onPickAccountValidUntil,
1182+
]);
1183+
9611184
const {
9621185
setDisabledStatus,
9631186
loading: setDisabledStatusLoading,
@@ -1239,8 +1462,46 @@ export function AccountStatusDialog(
12391462
prepareReenable();
12401463
break;
12411464
case "set-account-valid-period":
1465+
title = (
1466+
<FormattedMessage id="AccountStatusDialog.account-valid-period.title--set" />
1467+
);
1468+
subText = (
1469+
<FormattedMessage
1470+
id="AccountStatusDialog.account-valid-period.description--set"
1471+
values={args}
1472+
/>
1473+
);
1474+
body = accountValidPeriodForm;
1475+
button1 = (
1476+
<PrimaryButton
1477+
theme={themes.main}
1478+
disabled={loading}
1479+
text={
1480+
<FormattedMessage id="AccountStatusDialog.account-valid-period.action.save" />
1481+
}
1482+
/>
1483+
);
12421484
break;
12431485
case "edit-account-valid-period":
1486+
title = (
1487+
<FormattedMessage id="AccountStatusDialog.account-valid-period.title--edit" />
1488+
);
1489+
subText = (
1490+
<FormattedMessage
1491+
id="AccountStatusDialog.account-valid-period.description--edit"
1492+
values={args}
1493+
/>
1494+
);
1495+
body = accountValidPeriodForm;
1496+
button1 = (
1497+
<PrimaryButton
1498+
theme={themes.main}
1499+
disabled={loading}
1500+
text={
1501+
<FormattedMessage id="AccountStatusDialog.account-valid-period.action.edit" />
1502+
}
1503+
/>
1504+
);
12441505
break;
12451506
case "anonymize-or-schedule":
12461507
title = (
@@ -1374,6 +1635,7 @@ export function AccountStatusDialog(
13741635
return { dialogContentProps: { title, subText }, body, button1, button2 };
13751636
}, [
13761637
accountStatus,
1638+
accountValidPeriodForm,
13771639
disableForm,
13781640
loading,
13791641
mode,

portal/src/locale-data/en.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,16 @@
482482
"AccountStatusDialog.cancel-anonymization.title": "Cancel Scheduled Anonymization",
483483
"AccountStatusDialog.cancel-anonymization.description": "Do you really want to cancel scheduled anonymization on user {username}? If the removal is canceled, they will be re-enabled and can login again.",
484484
"AccountStatusDialog.cancel-anonymization.action.cancel-anonymization": "Cancel Anonymization",
485+
"AccountStatusDialog.account-valid-period.title--set": "Set Account Valid Period",
486+
"AccountStatusDialog.account-valid-period.description--set": "Set a valid period during which this account can log in. Outside this period, the user cannot log in. Useful for temporary access, such as contractors or external partners.",
487+
"AccountStatusDialog.account-valid-period.title--edit": "Edit Account Valid Period",
488+
"AccountStatusDialog.account-valid-period.description--edit": "Edit the valid period for this account. Outside this period, the user cannot log in.",
489+
"AccountStatusDialog.account-valid-period.start-at.label": "Start at",
490+
"AccountStatusDialog.account-valid-period.end-at.label": "End at",
491+
"AccountStatusDialog.account-valid-period.clear.hint": "Leave blank for no limit",
492+
"AccountStatusDialog.account-valid-period.action.clear": "Clear",
493+
"AccountStatusDialog.account-valid-period.action.save": "Save",
494+
"AccountStatusDialog.account-valid-period.action.edit": "Update valid period",
485495

486496
"Add2FAScreen.title.phone": "Add 2FA Phone",
487497
"Add2FAScreen.title.email": "Add 2FA Email",

0 commit comments

Comments
 (0)