feat: improve UI for timespan inputs on admin page (#726)

* Define Timestamp type

* Implement Timestamp utils

* Implement Timespan input

* Use timestamp input on config page

* Add timespan type to config services

* Refactor maxExpiration to use timespan type across services and components

* Update sessionDuration to use timespan type in config and adjust token expiration logic

* Update localized strings
This commit is contained in:
Aaron
2025-01-02 17:35:50 +01:00
committed by GitHub
parent df1ffaa2bc
commit 36afbf91b7
14 changed files with 158 additions and 20 deletions

View File

@@ -30,8 +30,8 @@ const configVariables: ConfigVariables = {
secret: false, secret: false,
}, },
sessionDuration: { sessionDuration: {
type: "number", type: "timespan",
defaultValue: "2160", defaultValue: "3 months",
secret: false, secret: false,
}, },
}, },
@@ -47,8 +47,8 @@ const configVariables: ConfigVariables = {
secret: false, secret: false,
}, },
maxExpiration: { maxExpiration: {
type: "number", type: "timespan",
defaultValue: "0", defaultValue: "0 days",
secret: false, secret: false,
}, },
shareIdLength: { shareIdLength: {

View File

@@ -306,11 +306,12 @@ export class AuthService {
} }
async createRefreshToken(userId: string, idToken?: string) { async createRefreshToken(userId: string, idToken?: string) {
const sessionDuration = this.config.get("general.sessionDuration");
const { id, token } = await this.prisma.refreshToken.create({ const { id, token } = await this.prisma.refreshToken.create({
data: { data: {
userId, userId,
expiresAt: moment() expiresAt: moment()
.add(this.config.get("general.sessionDuration"), "hours") .add(sessionDuration.value, sessionDuration.unit)
.toDate(), .toDate(),
oauthIDToken: idToken, oauthIDToken: idToken,
}, },
@@ -341,15 +342,19 @@ export class AuthService {
secure: isSecure, secure: isSecure,
maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months maxAge: 1000 * 60 * 60 * 24 * 30 * 3, // 3 months
}); });
if (refreshToken) if (refreshToken) {
const now = moment();
const sessionDuration = this.config.get("general.sessionDuration");
const maxAge = moment(now).add(sessionDuration.value, sessionDuration.unit).diff(now);
response.cookie("refresh_token", refreshToken, { response.cookie("refresh_token", refreshToken, {
path: "/api/auth/token", path: "/api/auth/token",
httpOnly: true, httpOnly: true,
sameSite: "strict", sameSite: "strict",
secure: isSecure, secure: isSecure,
maxAge: 1000 * 60 * 60 * this.config.get("general.sessionDuration"), maxAge,
}); });
} }
}
/** /**
* Returns the user id if the user is logged in, null otherwise * Returns the user id if the user is logged in, null otherwise

View File

@@ -7,6 +7,7 @@ import {
import { Config } from "@prisma/client"; import { Config } from "@prisma/client";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { stringToTimespan } from "src/utils/date.util";
/** /**
* ConfigService extends EventEmitter to allow listening for config updates, * ConfigService extends EventEmitter to allow listening for config updates,
@@ -35,6 +36,8 @@ export class ConfigService extends EventEmitter {
if (configVariable.type == "boolean") return value == "true"; if (configVariable.type == "boolean") return value == "true";
if (configVariable.type == "string" || configVariable.type == "text") if (configVariable.type == "string" || configVariable.type == "text")
return value; return value;
if (configVariable.type == "timespan")
return stringToTimespan(value);
} }
async getByCategory(category: string) { async getByCategory(category: string) {
@@ -94,7 +97,8 @@ export class ConfigService extends EventEmitter {
} else if ( } else if (
typeof value != configVariable.type && typeof value != configVariable.type &&
typeof value == "string" && typeof value == "string" &&
configVariable.type != "text" configVariable.type != "text" &&
configVariable.type != "timespan"
) { ) {
throw new BadRequestException( throw new BadRequestException(
`Config variable must be of type ${configVariable.type}`, `Config variable must be of type ${configVariable.type}`,
@@ -132,6 +136,7 @@ export class ConfigService extends EventEmitter {
condition: (value: number) => value >= 0 && value <= 9, condition: (value: number) => value >= 0 && value <= 9,
message: "Zip compression level must be between 0 and 9", message: "Zip compression level must be between 0 and 9",
}, },
// TODO add validation for timespan type
]; ];
const validation = validations.find((validation) => validation.key == key); const validation = validations.find((validation) => validation.key == key);

View File

@@ -56,12 +56,13 @@ export class ShareService {
const expiresNever = moment(0).toDate() == parsedExpiration; const expiresNever = moment(0).toDate() == parsedExpiration;
const maxExpiration = this.config.get("share.maxExpiration");
if ( if (
this.config.get("share.maxExpiration") !== 0 && maxExpiration.value !== 0 &&
(expiresNever || (expiresNever ||
parsedExpiration > parsedExpiration >
moment() moment()
.add(this.config.get("share.maxExpiration"), "hours") .add(maxExpiration.value, maxExpiration.unit)
.toDate()) .toDate())
) { ) {
throw new BadRequestException( throw new BadRequestException(

View File

@@ -10,3 +10,20 @@ export function parseRelativeDateToAbsolute(relativeDate: string) {
) )
.toDate(); .toDate();
} }
type Timespan = {
value: number;
unit: "minutes" | "hours" | "days" | "weeks" | "months" | "years";
};
export function stringToTimespan(value: string): Timespan {
const [time, unit] = value.split(" ");
return {
value: parseInt(time),
unit: unit as Timespan["unit"],
};
}
export function timespanToString(timespan: Timespan) {
return `${timespan.value} ${timespan.unit}`;
}

View File

@@ -8,6 +8,8 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useForm } from "@mantine/form"; import { useForm } from "@mantine/form";
import { AdminConfig, UpdateConfig } from "../../../types/config.type"; import { AdminConfig, UpdateConfig } from "../../../types/config.type";
import TimespanInput from "../../core/TimespanInput";
import { stringToTimespan, timespanToString } from "../../../utils/date.util";
import FileSizeInput from "../../core/FileSizeInput"; import FileSizeInput from "../../core/FileSizeInput";
const AdminConfigInput = ({ const AdminConfigInput = ({
@@ -91,6 +93,13 @@ const AdminConfigInput = ({
/> />
</> </>
)} )}
{configVariable.type == "timespan" && (
<TimespanInput
value={stringToTimespan(configVariable.value)}
onChange={(timespan) => onValueChange(configVariable, timespanToString(timespan))}
w={201}
/>
)}
</Stack> </Stack>
); );
}; };

View File

@@ -0,0 +1,83 @@
import { useState } from "react";
import { Timespan } from "../../types/timespan.type";
import { NativeSelect, NumberInput } from "@mantine/core";
import useTranslate from "../../hooks/useTranslate.hook";
const TimespanInput = ({ label, value, onChange, ...restProps }: {
label?: string,
value: Timespan,
onChange: (timespan: Timespan) => void,
[key: string]: any,
}) => {
const [unit, setUnit] = useState(value.unit);
const [inputValue, setInputValue] = useState(value.value);
const t = useTranslate();
const version = inputValue == 1 ? "singular" : "plural";
const unitSelect = (
<NativeSelect
data={[
{
value: "minutes",
label: t(`upload.modal.expires.minute-${version}`),
},
{
value: "hours",
label: t(`upload.modal.expires.hour-${version}`),
},
{
value: "days",
label: t(`upload.modal.expires.day-${version}`),
},
{
value: "weeks",
label: t(`upload.modal.expires.week-${version}`),
},
{
value: "months",
label: t(`upload.modal.expires.month-${version}`),
},
{
value: "years",
label: t(`upload.modal.expires.year-${version}`),
},
]}
value={unit}
rightSectionWidth={28}
styles={{
input: {
fontWeight: 500,
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
width: 120,
marginRight: -2,
},
}}
onChange={event => {
const unit = event.currentTarget.value as Timespan["unit"];
setUnit(unit);
onChange({ value: inputValue, unit });
}}
/>
);
return (
<NumberInput
label={label}
value={inputValue}
min={0}
max={999999}
precision={0}
rightSection={unitSelect}
rightSectionWidth={120}
onChange={value => {
const inputVal = value || 0;
setInputValue(inputVal);
onChange({ value: inputVal, unit });
}}
{...restProps}
/>
);
};
export default TimespanInput;

View File

@@ -31,6 +31,7 @@ import { FileUpload } from "../../../types/File.type";
import { CreateShare } from "../../../types/share.type"; import { CreateShare } from "../../../types/share.type";
import { getExpirationPreview } from "../../../utils/date.util"; import { getExpirationPreview } from "../../../utils/date.util";
import toast from "../../../utils/toast.util"; import toast from "../../../utils/toast.util";
import { Timespan } from "../../../types/timespan.type";
const showCreateUploadModal = ( const showCreateUploadModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
@@ -39,7 +40,7 @@ const showCreateUploadModal = (
isReverseShare: boolean; isReverseShare: boolean;
allowUnauthenticatedShares: boolean; allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean; enableEmailRecepients: boolean;
maxExpirationInHours: number; maxExpiration: Timespan;
shareIdLength: number; shareIdLength: number;
simplified: boolean; simplified: boolean;
}, },
@@ -112,7 +113,7 @@ const CreateUploadModalBody = ({
isReverseShare: boolean; isReverseShare: boolean;
allowUnauthenticatedShares: boolean; allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean; enableEmailRecepients: boolean;
maxExpirationInHours: number; maxExpiration: Timespan;
shareIdLength: number; shareIdLength: number;
}; };
}) => { }) => {
@@ -180,17 +181,17 @@ const CreateUploadModalBody = ({
); );
if ( if (
options.maxExpirationInHours != 0 && options.maxExpiration.value != 0 &&
(form.values.never_expires || (form.values.never_expires ||
expirationDate.isAfter( expirationDate.isAfter(
moment().add(options.maxExpirationInHours, "hours"), moment().add(options.maxExpiration.value, options.maxExpiration.unit),
)) ))
) { ) {
form.setFieldError( form.setFieldError(
"expiration_num", "expiration_num",
t("upload.modal.expires.error.too-long", { t("upload.modal.expires.error.too-long", {
max: moment max: moment
.duration(options.maxExpirationInHours, "hours") .duration(options.maxExpiration.value, options.maxExpiration.unit)
.humanize(), .humanize(),
}), }),
); );
@@ -327,7 +328,7 @@ const CreateUploadModalBody = ({
/> />
</Col> </Col>
</Grid> </Grid>
{options.maxExpirationInHours == 0 && ( {options.maxExpiration.value == 0 && (
<Checkbox <Checkbox
label={t("upload.modal.expires.never-long")} label={t("upload.modal.expires.never-long")}
{...form.getInputProps("never_expires")} {...form.getInputProps("never_expires")}
@@ -478,7 +479,7 @@ const SimplifiedCreateUploadModalModal = ({
isReverseShare: boolean; isReverseShare: boolean;
allowUnauthenticatedShares: boolean; allowUnauthenticatedShares: boolean;
enableEmailRecepients: boolean; enableEmailRecepients: boolean;
maxExpirationInHours: number; maxExpiration: Timespan;
shareIdLength: number; shareIdLength: number;
}; };
}) => { }) => {

View File

@@ -344,7 +344,7 @@ export default {
"admin.config.share.allow-unauthenticated-shares": "Nicht authentifizierte Freigaben erlauben", "admin.config.share.allow-unauthenticated-shares": "Nicht authentifizierte Freigaben erlauben",
"admin.config.share.allow-unauthenticated-shares.description": "Gibt an, ob nicht authentifizierte Benutzer Freigaben erstellen können", "admin.config.share.allow-unauthenticated-shares.description": "Gibt an, ob nicht authentifizierte Benutzer Freigaben erstellen können",
"admin.config.share.max-expiration": "Max. Ablaufdatum", "admin.config.share.max-expiration": "Max. Ablaufdatum",
"admin.config.share.max-expiration.description": "Maximale Ablaufzeit in Stunden. Auf 0 setzen, um kein Ablaufdatum zu definieren.", "admin.config.share.max-expiration.description": "Maximale Ablaufzeit. Auf 0 setzen, um kein Ablaufdatum zu definieren.",
"admin.config.share.share-id-length": "Default share ID length", "admin.config.share.share-id-length": "Default share ID length",
"admin.config.share.share-id-length.description": "Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.", "admin.config.share.share-id-length.description": "Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.",
"admin.config.share.max-size": "Maximale Größe", "admin.config.share.max-size": "Maximale Größe",

View File

@@ -478,7 +478,7 @@ export default {
"Whether unauthenticated users can create shares", "Whether unauthenticated users can create shares",
"admin.config.share.max-expiration": "Max expiration", "admin.config.share.max-expiration": "Max expiration",
"admin.config.share.max-expiration.description": "admin.config.share.max-expiration.description":
"Maximum share expiration in hours. Set to 0 to allow unlimited expiration.", "Maximum share expiration. Set to 0 to allow unlimited expiration.",
"admin.config.share.share-id-length": "Default share ID length", "admin.config.share.share-id-length": "Default share ID length",
"admin.config.share.share-id-length.description": "admin.config.share.share-id-length.description":
"Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.", "Default length for the generated ID of a share. This value is also used to generate links for reverse shares. A value below 8 is not considered secure.",

View File

@@ -139,7 +139,7 @@ const Upload = ({
"share.allowUnauthenticatedShares", "share.allowUnauthenticatedShares",
), ),
enableEmailRecepients: config.get("email.enableShareEmailRecipients"), enableEmailRecepients: config.get("email.enableShareEmailRecipients"),
maxExpirationInHours: config.get("share.maxExpiration"), maxExpiration: config.get("share.maxExpiration"),
shareIdLength: config.get("share.shareIdLength"), shareIdLength: config.get("share.shareIdLength"),
simplified, simplified,
}, },

View File

@@ -1,6 +1,7 @@
import axios from "axios"; import axios from "axios";
import Config, { AdminConfig, UpdateConfig } from "../types/config.type"; import Config, { AdminConfig, UpdateConfig } from "../types/config.type";
import api from "./api.service"; import api from "./api.service";
import { stringToTimespan } from "../utils/date.util";
const list = async (): Promise<Config[]> => { const list = async (): Promise<Config[]> => {
return (await api.get("/configs")).data; return (await api.get("/configs")).data;
@@ -30,6 +31,8 @@ const get = (key: string, configVariables: Config[]): any => {
if (configVariable.type == "boolean") return value == "true"; if (configVariable.type == "boolean") return value == "true";
if (configVariable.type == "string" || configVariable.type == "text") if (configVariable.type == "string" || configVariable.type == "text")
return value; return value;
if (configVariable.type == "timespan")
return stringToTimespan(value);
}; };
const finishSetup = async (): Promise<AdminConfig[]> => { const finishSetup = async (): Promise<AdminConfig[]> => {

View File

@@ -0,0 +1,2 @@
export type TimeUnit = "minutes" | "hours" | "days" | "weeks" | "months" | "years";
export type Timespan = { value: number; unit: TimeUnit };

View File

@@ -1,4 +1,5 @@
import moment from "moment"; import moment from "moment";
import { Timespan } from "../types/timespan.type";
export const getExpirationPreview = ( export const getExpirationPreview = (
messages: { messages: {
@@ -30,3 +31,14 @@ export const getExpirationPreview = (
moment(expirationDate).format("LLL"), moment(expirationDate).format("LLL"),
); );
}; };
export const timespanToString = (timespan: Timespan) => {
return `${timespan.value} ${timespan.unit}`;
}
export const stringToTimespan = (value: string): Timespan => {
return {
value: parseInt(value.split(" ")[0]),
unit: value.split(" ")[1],
} as Timespan;
};