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:
@@ -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: {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
83
frontend/src/components/core/TimespanInput.tsx
Normal file
83
frontend/src/components/core/TimespanInput.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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[]> => {
|
||||||
|
|||||||
2
frontend/src/types/timespan.type.ts
Normal file
2
frontend/src/types/timespan.type.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export type TimeUnit = "minutes" | "hours" | "days" | "weeks" | "months" | "years";
|
||||||
|
export type Timespan = { value: number; unit: TimeUnit };
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user