accounts: add "extend from previous expiry"

If the expiry time of an expired user is still in the activity log,
extending and re-enabling a user with this option checked will extend
the expiry from this time, rather than the current time. For #379, i
think this is basically what they wanted.
This commit is contained in:
Harvey Tindall
2025-11-26 15:30:45 +00:00
parent 875387166e
commit 5e653c51f3
7 changed files with 124 additions and 63 deletions

View File

@@ -525,6 +525,24 @@ func (app *appContext) ExtendExpiry(gc *gin.Context) {
base := time.Now()
if expiry, ok := app.storage.GetUserExpiryKey(id); ok {
base = expiry.Expiry
app.debug.Printf(lm.FoundExistingExpiry)
} else if req.TryExtendFromPreviousExpiry {
var acts []Activity
app.storage.db.Find(&acts, badgerhold.Where("Type").Eq(ActivityDisabled).And("UserID").Eq(id).SortBy("Time").Reverse().Limit(1))
if len(acts) != 0 {
// Only do it if the most recent reason for disabling was expiry
if acts[0].SourceType == ActivityDaemon {
app.debug.Printf(lm.FoundPreviousExpiryLog, acts[0].Time)
newExpiry := acts[0].Time.AddDate(0, req.Months, req.Days).Add(time.Duration(((60 * req.Hours) + req.Minutes)) * time.Minute)
if newExpiry.After(base) {
base = acts[0].Time
} else {
app.debug.Printf(lm.ExpiryWouldBeInPast)
}
} else {
app.debug.Printf(lm.PreviousExpiryNotExpiry)
}
}
}
app.debug.Printf(lm.ExtendCreateExpiry, id)
expiry := UserExpiry{}

View File

@@ -6,7 +6,7 @@
.tooltip .content {
visibility: hidden;
opacity: 0;
max-width: 10rem;
max-width: 16rem;
min-width: 6rem;
background-color: rgba(0, 0, 0, 0.6);
color: #fff;
@@ -29,6 +29,7 @@
}
.tooltip.above .content {
top: unset;
bottom: calc(100% + 0.125rem);
left: 50%;
right: 0;

View File

@@ -186,56 +186,60 @@
</form>
</div>
<div id="modal-extend-expiry" class="modal">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3" id="form-extend-expiry" href="">
<form class="card relative mx-auto my-[10%] w-11/12 sm:w-4/5 lg:w-1/3 flex flex-col gap-2" id="form-extend-expiry" href="">
<span class="heading"><span id="header-extend-expiry"></span> <span class="modal-close">&times;</span></span>
<div class="content mt-8">
<aside class="aside sm ~urge dark:~d_info mb-2 @low row unfocused" id="extend-expiry-date"></aside>
<div>
<span class="text-xl supra row py-1">{{ .strings.setExpiry }}</span>
<div class="row">
<input type="text" id="extend-expiry-text" class="input ~neutral @low mb-2 mt-4" placeholder="{{ .strings.enterExpiry }}">
</div>
<div class="flex flex-col gap-3">
<aside class="aside sm ~urge dark:~d_info @low unfocused" id="extend-expiry-date"></aside>
<div class="flex flex-col gap-2">
<span class="text-xl supra">{{ .strings.setExpiry }}</span>
<input type="text" id="extend-expiry-text" class="input ~neutral @low" placeholder="{{ .strings.enterExpiry }}">
</div>
<div id="extend-expiry-field-inputs">
<span class="text-xl supra row py-1">{{ .strings.extendExpiry }}</span>
<div class="row">
<div class="col">
<div id="extend-expiry-field-inputs" class="flex flex-col gap-2">
<span class="text-xl supra">{{ .strings.extendExpiry }}</span>
<div class="grid grid-cols-2 grid-rows-2 gap-2">
<div class="flex flex-col gap-2">
<label class="label supra" for="extend-expiry-months">{{ .strings.inviteMonths }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="extend-expiry-months">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="flex flex-col gap-2">
<label class="label supra" for="extend-expiry-days">{{ .strings.inviteDays }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="extend-expiry-days">
<option>0</option>
</select>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="flex flex-col gap-2">
<label class="label supra" for="extend-expiry-hours">{{ .strings.inviteHours }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="extend-expiry-hours">
<option>0</option>
</select>
</div>
</div>
<div class="col">
<div class="flex flex-col gap-2">
<label class="label supra" for="extend-expiry-minutes">{{ .strings.inviteMinutes }}</label>
<div class="select ~neutral @low mb-2 mt-4">
<div class="select ~neutral @low">
<select id="extend-expiry-minutes">
<option>0</option>
</select>
</div>
</div>
</div>
<label class="switch">
<input type="checkbox" id="expiry-use-previous">
<span>{{ .strings.extendFromPreviousExpiry }}</span>
<div class="tooltip left">
<i class="icon ri-information-line align-middle"></i>
<div class="content sm w-max">{{ .strings.extendFromPreviousExpiryDescription }}</div>
</div>
</label>
</div>
<label class="switch mb-4">
<label class="switch">
<input type="checkbox" id="expiry-extend-enable" checked>
<span>{{ .strings.sendDeleteNotificationEmail }}</span>
</label>

View File

@@ -66,6 +66,8 @@
"setExpiry": "Set expiry",
"removeExpiry": "Remove expiry",
"enterExpiry": "Enter an expiry",
"extendFromPreviousExpiry": "Extend from previous expiry date (if possible)",
"extendFromPreviousExpiryDescription": "If a record of an expired user's expiry time is found in the activity log, expiry will be extended from then, rather than the current time, unless the new expiry date would have already passed.",
"sendPWRManual": "User {n} has no method of contact, press copy to get a link to send to them.",
"sendPWRSuccess": "Password reset link sent.",
"sendPWRSuccessManual": "If the user hasn't received it, press copy to get a link to manually send to them.",

View File

@@ -186,7 +186,11 @@ const (
IncorrectCaptcha = "captcha incorrect"
ExtendCreateExpiry = "Extended or created expiry for user \"%s\""
ExtendCreateExpiry = "Extended or created expiry for user \"%s\""
FoundExistingExpiry = "Found existing expiry key"
FoundPreviousExpiryLog = "Found most recent previous expiry in activity log @ %v"
ExpiryWouldBeInPast = "Expiry would've been in the past, using current time base"
PreviousExpiryNotExpiry = "Last user disable was not an expiry, using current time base"
UserEmailAdjusted = "Email for user \"%s\" adjusted"
UserAdminAdjusted = "Admin state for user \"%s\" set to %t"

View File

@@ -252,14 +252,15 @@ type customEmailDTO struct {
}
type extendExpiryDTO struct {
Users []string `json:"users"` // List of user IDs to apply to.
Months int `json:"months" example:"1"` // Number of months to add.
Days int `json:"days" example:"1"` // Number of days to add.
Hours int `json:"hours" example:"2"` // Number of hours to add.
Minutes int `json:"minutes" example:"3"` // Number of minutes to add.
Timestamp int64 `json:"timestamp"` // Optional, exact time to expire at. Overrides other fields.
Notify bool `json:"notify"` // Whether to message the user(s) about the change.
Reason string `json:"reason" example:"i felt like it"` // Reason for adjustment.
Users []string `json:"users"` // List of user IDs to apply to.
Months int `json:"months,omitempty" example:"1"` // Number of months to add.
Days int `json:"days,omityempty" example:"1"` // Number of days to add.
Hours int `json:"hours,omitempty" example:"2"` // Number of hours to add.
Minutes int `json:"minutes,omitempty" example:"3"` // Number of minutes to add.
Timestamp int64 `json:"timestamp,omitempty"` // Optional, exact time to expire at. Overrides other fields.
Notify bool `json:"notify"` // Whether to message the user(s) about the change.
Reason string `json:"reason,omitempty" example:"i felt like it"` // Optional, reason for adjustment.
TryExtendFromPreviousExpiry bool `json:"try_extend_from_previous_expiry,omitempty"` // If an activity log of the expiry of a disabled user is available, extend the expiry from that instead of the current time.
}
type checkUpdateDTO struct {

View File

@@ -836,6 +836,18 @@ interface UsersDTO extends paginatedDTO {
users: User[];
}
declare interface ExtendExpiryDTO {
users: string[];
months?: number;
days?: number;
hours?: number;
minutes?: number;
timestamp?: number;
notify: boolean;
reason?: string;
try_extend_from_previous_expiry?: boolean;
}
export class accountsList extends PaginatedList {
protected _container = document.getElementById("accounts-list") as HTMLTableSectionElement;
@@ -856,6 +868,7 @@ export class accountsList extends PaginatedList {
private _extendExpiryForm = document.getElementById("form-extend-expiry") as HTMLFormElement;
private _extendExpiryTextInput = document.getElementById("extend-expiry-text") as HTMLInputElement;
private _extendExpiryFieldInputs = document.getElementById("extend-expiry-field-inputs") as HTMLElement;
private _extendExpiryFromPreviousExpiry = document.getElementById("expiry-use-previous") as HTMLInputElement;
private _usingExtendExpiryTextInput = true;
private _extendExpiryDate = document.getElementById("extend-expiry-date") as HTMLElement;
@@ -1117,14 +1130,14 @@ export class accountsList extends PaginatedList {
this._extendExpiryDate.classList.add("unfocused");
this._extendExpiryTextInput.onkeyup = () => {
this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60");
this._extendExpiryTextInput.parentElement.classList.remove("opacity-60");
this._extendExpiryFieldInputs.classList.add("opacity-60");
this._usingExtendExpiryTextInput = true;
this._displayExpiryDate();
}
this._extendExpiryTextInput.onclick = () => {
this._extendExpiryTextInput.parentElement.parentElement.classList.remove("opacity-60");
this._extendExpiryTextInput.parentElement.classList.remove("opacity-60");
this._extendExpiryFieldInputs.classList.add("opacity-60");
this._usingExtendExpiryTextInput = true;
this._displayExpiryDate();
@@ -1132,15 +1145,17 @@ export class accountsList extends PaginatedList {
this._extendExpiryFieldInputs.onclick = () => {
this._extendExpiryFieldInputs.classList.remove("opacity-60");
this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60");
this._extendExpiryTextInput.parentElement.classList.add("opacity-60");
this._usingExtendExpiryTextInput = false;
this._displayExpiryDate();
};
this._extendExpiryFromPreviousExpiry.onclick = this._displayExpiryDate;
for (let field of ["months", "days", "hours", "minutes"]) {
(document.getElementById("extend-expiry-"+field) as HTMLSelectElement).onchange = () => {
this._extendExpiryFieldInputs.classList.remove("opacity-60");
this._extendExpiryTextInput.parentElement.parentElement.classList.add("opacity-60");
this._extendExpiryTextInput.parentElement.classList.add("opacity-60");
this._usingExtendExpiryTextInput = false;
this._displayExpiryDate();
};
@@ -2008,45 +2023,54 @@ export class accountsList extends PaginatedList {
_displayExpiryDate = () => {
let date: Date;
let invalid = false;
let cantShow = false;
let users = this._collectUsers();
if (this._usingExtendExpiryTextInput) {
date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
invalid = "invalid" in (date as any);
} else {
let fields: Array<HTMLSelectElement> = [
document.getElementById("extend-expiry-months") as HTMLSelectElement,
document.getElementById("extend-expiry-days") as HTMLSelectElement,
document.getElementById("extend-expiry-hours") as HTMLSelectElement,
document.getElementById("extend-expiry-minutes") as HTMLSelectElement
];
invalid = fields[0].value == "0" && fields[1].value == "0" && fields[2].value == "0" && fields[3].value == "0";
let id = users.length > 0 ? users[0] : "";
if (!id) invalid = true;
else {
date = new Date(this.users[id].expiry*1000);
if (this.users[id].expiry == 0) date = new Date();
date.setMonth(date.getMonth() + (+fields[0].value))
date.setDate(date.getDate() + (+fields[1].value));
date.setHours(date.getHours() + (+fields[2].value));
date.setMinutes(date.getMinutes() + (+fields[3].value));
if (this._extendExpiryFromPreviousExpiry.checked) {
cantShow = true;
} else {
let fields: Array<HTMLSelectElement> = [
document.getElementById("extend-expiry-months") as HTMLSelectElement,
document.getElementById("extend-expiry-days") as HTMLSelectElement,
document.getElementById("extend-expiry-hours") as HTMLSelectElement,
document.getElementById("extend-expiry-minutes") as HTMLSelectElement
];
invalid = fields[0].value == "0" && fields[1].value == "0" && fields[2].value == "0" && fields[3].value == "0";
let id = users.length > 0 ? users[0] : "";
if (!id) invalid = true;
else {
date = new Date(this.users[id].expiry*1000);
if (this.users[id].expiry == 0) date = new Date();
date.setMonth(date.getMonth() + (+fields[0].value))
date.setDate(date.getDate() + (+fields[1].value));
date.setHours(date.getHours() + (+fields[2].value));
date.setMinutes(date.getMinutes() + (+fields[3].value));
}
}
}
const submit = this._extendExpiryForm.querySelector(`input[type="submit"]`) as HTMLInputElement;
const submitSpan = submit.nextElementSibling;
if (invalid || cantShow) {
this._extendExpiryDate.classList.add("unfocused");
}
if (invalid) {
submit.disabled = true;
submitSpan.classList.add("opacity-60");
this._extendExpiryDate.classList.add("unfocused");
} else {
submit.disabled = false;
submitSpan.classList.remove("opacity-60");
this._extendExpiryDate.innerHTML = `
<div class="flex flex-col">
<span>${window.lang.strings("accountWillExpire").replace("{date}", toDateString(date))}</span>
${users.length > 1 ? "<span>"+window.lang.strings("expirationBasedOn")+"</span>" : ""}
</div>
`;
this._extendExpiryDate.classList.remove("unfocused");
if (!cantShow) {
this._extendExpiryDate.innerHTML = `
<div class="flex flex-col">
<span>${window.lang.strings("accountWillExpire").replace("{date}", toDateString(date))}</span>
${users.length > 1 ? "<span>"+window.lang.strings("expirationBasedOn")+"</span>" : ""}
</div>
`;
this._extendExpiryDate.classList.remove("unfocused");
}
}
}
@@ -2072,18 +2096,25 @@ export class accountsList extends PaginatedList {
}
document.getElementById("header-extend-expiry").textContent = header;
const extend = () => {
let send = { "users": applyList, "timestamp": 0, "notify": this._enableExpiryNotify.checked }
let send: ExtendExpiryDTO = {
users: applyList,
timestamp: 0,
notify: this._enableExpiryNotify.checked
}
if (this._enableExpiryNotify.checked) {
send["reason"] = this._enableExpiryReason.value;
send.reason = this._enableExpiryReason.value;
}
if (this._usingExtendExpiryTextInput) {
let date = (Date as any).fromString(this._extendExpiryTextInput.value) as Date;
send["timestamp"] = Math.floor(date.getTime() / 1000);
send.timestamp = Math.floor(date.getTime() / 1000);
if ("invalid" in (date as any)) {
window.notifications.customError("extendExpiryError", window.lang.notif("errorInvalidDate"));
return;
}
} else {
if (this._extendExpiryFromPreviousExpiry.checked) {
send.try_extend_from_previous_expiry = true;
}
for (let field of ["months", "days", "hours", "minutes"]) {
send[field] = +(document.getElementById("extend-expiry-"+field) as HTMLSelectElement).value;
}