Compare commits

...

23 Commits

Author SHA1 Message Date
jc21
2578105f86 Merge pull request #4907 from NginxProxyManager/develop
v2.13.3
2025-11-11 16:54:38 +10:00
jc21
39c9bbb167 Merge branch 'master' into develop
All checks were successful
Close stale issues and PRs / stale (push) Successful in 18s
2025-11-11 16:06:05 +10:00
jc21
30c2781a02 Merge pull request #4765 from mamasch19/develop
add MC-HOST24 DNS plugin
2025-11-11 16:05:32 +10:00
Jamie Curnow
53e78dcc17 Bump version 2025-11-11 16:01:06 +10:00
jc21
62092b2ddc Merge pull request #4859 from 7heMech/develop
Fix hamburger menu on mobile
2025-11-11 15:37:12 +10:00
Jamie Curnow
2c26ed8b11 Revert "Fix #4831 mobile header menu not working"
This reverts commit 4bd545c88e.
2025-11-11 15:36:46 +10:00
jc21
e3f5cd9a58 Merge pull request #4871 from prospo/develop
chore: Bump certbot-dns-leaseweb to 1.0.3
2025-11-11 15:24:11 +10:00
jc21
fba14817e7 Merge pull request #4894 from eduardpaul/feat-fix-pass_auth-template
Update _access.conf to fix access_list.pass_auth logic
2025-11-11 15:23:22 +10:00
Jamie Curnow
6825a9773b Fix #4854 Added missing forward http code for redirections 2025-11-11 15:17:43 +10:00
Jamie Curnow
8bc3078d87 Fix initial setup user bug, taking the fix from #4836 2025-11-11 14:52:39 +10:00
Jamie Curnow
8aeb2fa661 Fix #4692, #4856 - stick with auto for scheme in db, change it to $scheme when rendering 2025-11-11 14:46:25 +10:00
Jamie Curnow
4bd545c88e Fix #4831 mobile header menu not working 2025-11-11 14:05:26 +10:00
Jamie Curnow
7f0cce944d Relax the email validation in frontend 2025-11-11 08:54:48 +10:00
Jamie Curnow
311d6a1541 Tweaks to CI stack for postgres
All checks were successful
Close stale issues and PRs / stale (push) Successful in 20s
2025-11-10 10:30:16 +10:00
mamasch19
5e7276e65b Add MC-HOST24 DNS plugin configuration
added the MC-HOST24 configuration to the new plugin file
2025-11-09 22:31:48 +01:00
Eduard Paul
2bcb942f93 Update _access.conf to ensure Authorization header remove when pass_auth = false or 0
Fixing prev commit as it's negative logic.
2025-11-09 21:02:18 +01:00
Eduard Paul
b3dac3df08 Update _access.conf to fix access_list.pass_auth logic
Wrong logic to pass auth as header: when disabled (pass_auth=0) credentials are included in Authorization header. However as soon as you enable (pass_auth=1) they are not.
2025-11-09 20:11:33 +01:00
jc21
64c5a863f8 Merge pull request #4878 from NginxProxyManager/develop
v2.13.2
2025-11-09 21:16:26 +10:00
Jamie Curnow
cd94863850 Bump version
All checks were successful
Close stale issues and PRs / stale (push) Successful in 25s
2025-11-09 20:25:10 +10:00
Emil
fd1d33444a chore: Bump certbot-dns-leaseweb to 1.0.3 2025-11-08 14:39:23 +01:00
7heMech
6fa2d6a98a Fix hamburger menu on mobile 2025-11-07 19:34:43 +00:00
Jamie Curnow
3c252db46f Fixes #4844 with more defensive date parsing
All checks were successful
Close stale issues and PRs / stale (push) Successful in 23s
2025-11-07 21:37:22 +10:00
jc21
e88d55f1d2 Merge pull request #4839 from NginxProxyManager/develop
v2.13.1
2025-11-05 15:40:32 +10:00
25 changed files with 201 additions and 89 deletions

View File

@@ -1 +1 @@
2.13.1 2.13.3

View File

@@ -1,7 +1,7 @@
<p align="center"> <p align="center">
<img src="https://nginxproxymanager.com/github.png"> <img src="https://nginxproxymanager.com/github.png">
<br><br> <br><br>
<img src="https://img.shields.io/badge/version-2.13.1-green.svg?style=for-the-badge"> <img src="https://img.shields.io/badge/version-2.13.3-green.svg?style=for-the-badge">
<a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager"> <a href="https://hub.docker.com/repository/docker/jc21/nginx-proxy-manager">
<img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge"> <img src="https://img.shields.io/docker/stars/jc21/nginx-proxy-manager.svg?style=for-the-badge">
</a> </a>

View File

@@ -370,7 +370,7 @@
"leaseweb": { "leaseweb": {
"name": "LeaseWeb", "name": "LeaseWeb",
"package_name": "certbot-dns-leaseweb", "package_name": "certbot-dns-leaseweb",
"version": "~=1.0.1", "version": "~=1.0.3",
"dependencies": "", "dependencies": "",
"credentials": "dns_leaseweb_api_token = 01234556789", "credentials": "dns_leaseweb_api_token = 01234556789",
"full_plugin_name": "dns-leaseweb" "full_plugin_name": "dns-leaseweb"
@@ -399,6 +399,14 @@
"credentials": "dns_luadns_email = user@example.com\ndns_luadns_token = 0123456789abcdef0123456789abcdef", "credentials": "dns_luadns_email = user@example.com\ndns_luadns_token = 0123456789abcdef0123456789abcdef",
"full_plugin_name": "dns-luadns" "full_plugin_name": "dns-luadns"
}, },
"mchost24": {
"name": "MC-HOST24",
"package_name": "certbot-dns-mchost24",
"version": "",
"dependencies": "",
"credentials": "# Obtain API token using https://github.com/JoeJoeTV/mchost24-api-python\ndns_mchost24_api_token=<insert obtained API token here>",
"full_plugin_name": "dns-mchost24"
},
"mijnhost": { "mijnhost": {
"name": "mijn.host", "name": "mijn.host",
"package_name": "certbot-dns-mijn-host", "package_name": "certbot-dns-mijn-host",

View File

@@ -216,6 +216,11 @@ const internalNginx = {
} }
} }
// For redirection hosts, if the scheme is not http or https, set it to $scheme
if (nice_host_type === "redirection_host" && ['http', 'https'].indexOf(host.forward_scheme.toLowerCase()) === -1) {
host.forward_scheme = "$scheme";
}
if (host.locations) { if (host.locations) {
//logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2)); //logger.info ('host.locations = ' + JSON.stringify(host.locations, null, 2));
origLocations = [].concat(host.locations); origLocations = [].concat(host.locations);

View File

@@ -0,0 +1,50 @@
import { migrate as logger } from "../logger.js";
const migrateName = "redirect_auto_scheme";
/**
* Migrate
*
* @see http://knexjs.org/#Schema
*
* @param {Object} knex
* @returns {Promise}
*/
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);
return knex.schema
.table("redirection_host", async (table) => {
// change the column default from $scheme to auto
await table.string("forward_scheme").notNull().defaultTo("auto").alter();
await knex('redirection_host')
.where('forward_scheme', '$scheme')
.update({ forward_scheme: 'auto' });
})
.then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`);
});
};
/**
* Undo Migrate
*
* @param {Object} knex
* @returns {Promise}
*/
const down = (knex) => {
logger.info(`[${migrateName}] Migrating Down...`);
return knex.schema
.table("redirection_host", async (table) => {
await table.string("forward_scheme").notNull().defaultTo("$scheme").alter();
await knex('redirection_host')
.where('forward_scheme', 'auto')
.update({ forward_scheme: '$scheme' });
})
.then(() => {
logger.info(`[${migrateName}] redirection_host Table altered`);
});
};
export { up, down };

View File

@@ -37,7 +37,7 @@ const setupDefaultUser = async () => {
const data = { const data = {
is_deleted: 0, is_deleted: 0,
email: email, email: initialAdminEmail,
name: "Administrator", name: "Administrator",
nickname: "Admin", nickname: "Admin",
avatar: "", avatar: "",
@@ -53,7 +53,7 @@ const setupDefaultUser = async () => {
.insert({ .insert({
user_id: user.id, user_id: user.id,
type: "password", type: "password",
secret: password, secret: initialAdminPassword,
meta: {}, meta: {},
}); });

View File

@@ -4,7 +4,7 @@
auth_basic "Authorization required"; auth_basic "Authorization required";
auth_basic_user_file /data/access/{{ access_list_id }}; auth_basic_user_file /data/access/{{ access_list_id }};
{% if access_list.pass_auth == 0 or access_list.pass_auth == true %} {% if access_list.pass_auth == 0 or access_list.pass_auth == false %}
proxy_set_header Authorization ""; proxy_set_header Authorization "";
{% endif %} {% endif %}

View File

@@ -1,6 +1,6 @@
AUTHENTIK_SECRET_KEY=gl8woZe8L6IIX8SC0c5Ocsj0xPkX5uJo5DVZCFl+L/QGbzuplfutYuua2ODNLEiDD3aFd9H2ylJmrke0 AUTHENTIK_SECRET_KEY=gl8woZe8L6IIX8SC0c5Ocsj0xPkX5uJo5DVZCFl+L/QGbzuplfutYuua2ODNLEiDD3aFd9H2ylJmrke0
AUTHENTIK_REDIS__HOST=authentik-redis AUTHENTIK_REDIS__HOST=authentik-redis
AUTHENTIK_POSTGRESQL__HOST=db-postgres AUTHENTIK_POSTGRESQL__HOST=pgdb.internal
AUTHENTIK_POSTGRESQL__USER=authentik AUTHENTIK_POSTGRESQL__USER=authentik
AUTHENTIK_POSTGRESQL__NAME=authentik AUTHENTIK_POSTGRESQL__NAME=authentik
AUTHENTIK_POSTGRESQL__PASSWORD=07EKS5NLI6Tpv68tbdvrxfvj AUTHENTIK_POSTGRESQL__PASSWORD=07EKS5NLI6Tpv68tbdvrxfvj

View File

@@ -6,7 +6,7 @@ services:
fullstack: fullstack:
environment: environment:
DB_POSTGRES_HOST: "db-postgres" DB_POSTGRES_HOST: "pgdb.internal"
DB_POSTGRES_PORT: "5432" DB_POSTGRES_PORT: "5432"
DB_POSTGRES_USER: "npm" DB_POSTGRES_USER: "npm"
DB_POSTGRES_PASSWORD: "npmpass" DB_POSTGRES_PASSWORD: "npmpass"
@@ -27,7 +27,9 @@ services:
- psql_vol:/var/lib/postgresql/data - psql_vol:/var/lib/postgresql/data
- ./ci/postgres:/docker-entrypoint-initdb.d - ./ci/postgres:/docker-entrypoint-initdb.d
networks: networks:
- fulltest fulltest:
aliases:
- pgdb.internal
authentik-redis: authentik-redis:
image: "redis:alpine" image: "redis:alpine"
@@ -41,6 +43,8 @@ services:
timeout: 3s timeout: 3s
volumes: volumes:
- redis_vol:/data - redis_vol:/data
networks:
- fulltest
authentik: authentik:
image: ghcr.io/goauthentik/server:2024.10.1 image: ghcr.io/goauthentik/server:2024.10.1
@@ -51,6 +55,8 @@ services:
depends_on: depends_on:
- authentik-redis - authentik-redis
- db-postgres - db-postgres
networks:
- fulltest
authentik-worker: authentik-worker:
image: ghcr.io/goauthentik/server:2024.10.1 image: ghcr.io/goauthentik/server:2024.10.1
@@ -61,6 +67,8 @@ services:
depends_on: depends_on:
- authentik-redis - authentik-redis
- db-postgres - db-postgres
networks:
- fulltest
authentik-ldap: authentik-ldap:
image: ghcr.io/goauthentik/ldap:2024.10.1 image: ghcr.io/goauthentik/ldap:2024.10.1
@@ -71,6 +79,8 @@ services:
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- authentik - authentik
networks:
- fulltest
volumes: volumes:
psql_vol: psql_vol:

View File

@@ -3,31 +3,30 @@
# This is a base compose file, it should be extended with a # This is a base compose file, it should be extended with a
# docker-compose.ci.*.yml file # docker-compose.ci.*.yml file
services: services:
fullstack: fullstack:
image: "${IMAGE}:${BRANCH_LOWER}-ci-${BUILD_NUMBER}" image: "${IMAGE}:${BRANCH_LOWER}-ci-${BUILD_NUMBER}"
environment: environment:
TZ: "${TZ:-Australia/Brisbane}" TZ: "${TZ:-Australia/Brisbane}"
DEBUG: 'true' DEBUG: "true"
CI: 'true' CI: "true"
FORCE_COLOR: 1 FORCE_COLOR: 1
# Required for DNS Certificate provisioning in CI # Required for DNS Certificate provisioning in CI
LE_SERVER: 'https://ca.internal/acme/acme/directory' LE_SERVER: "https://ca.internal/acme/acme/directory"
REQUESTS_CA_BUNDLE: '/etc/ssl/certs/NginxProxyManager.crt' REQUESTS_CA_BUNDLE: "/etc/ssl/certs/NginxProxyManager.crt"
volumes: volumes:
- 'npm_data_ci:/data' - "npm_data_ci:/data"
- 'npm_le_ci:/etc/letsencrypt' - "npm_le_ci:/etc/letsencrypt"
- './dev/letsencrypt.ini:/etc/letsencrypt.ini:ro' - "./dev/letsencrypt.ini:/etc/letsencrypt.ini:ro"
- './dev/resolv.conf:/etc/resolv.conf:ro' - "./dev/resolv.conf:/etc/resolv.conf:ro"
- '/etc/localtime:/etc/localtime:ro' - "/etc/localtime:/etc/localtime:ro"
healthcheck: healthcheck:
test: ["CMD", "/usr/bin/check-health"] test: ["CMD", "/usr/bin/check-health"]
interval: 10s interval: 10s
timeout: 3s timeout: 3s
expose: expose:
- '80-81/tcp' - "80-81/tcp"
- '443/tcp' - "443/tcp"
- '1500-1503/tcp' - "1500-1503/tcp"
networks: networks:
fulltest: fulltest:
aliases: aliases:
@@ -38,8 +37,8 @@ services:
stepca: stepca:
image: jc21/testca image: jc21/testca
volumes: volumes:
- './dev/resolv.conf:/etc/resolv.conf:ro' - "./dev/resolv.conf:/etc/resolv.conf:ro"
- '/etc/localtime:/etc/localtime:ro' - "/etc/localtime:/etc/localtime:ro"
networks: networks:
fulltest: fulltest:
aliases: aliases:
@@ -48,18 +47,18 @@ services:
pdns: pdns:
image: pschiffe/pdns-mysql:4.8 image: pschiffe/pdns-mysql:4.8
volumes: volumes:
- '/etc/localtime:/etc/localtime:ro' - "/etc/localtime:/etc/localtime:ro"
environment: environment:
PDNS_master: 'yes' PDNS_master: "yes"
PDNS_api: 'yes' PDNS_api: "yes"
PDNS_api_key: 'npm' PDNS_api_key: "npm"
PDNS_webserver: 'yes' PDNS_webserver: "yes"
PDNS_webserver_address: '0.0.0.0' PDNS_webserver_address: "0.0.0.0"
PDNS_webserver_password: 'npm' PDNS_webserver_password: "npm"
PDNS_webserver-allow-from: '127.0.0.0/8,192.0.0.0/8,10.0.0.0/8,172.0.0.0/8' PDNS_webserver-allow-from: "127.0.0.0/8,192.0.0.0/8,10.0.0.0/8,172.0.0.0/8"
PDNS_version_string: 'anonymous' PDNS_version_string: "anonymous"
PDNS_default_ttl: 1500 PDNS_default_ttl: 1500
PDNS_allow_axfr_ips: '127.0.0.0/8,192.0.0.0/8,10.0.0.0/8,172.0.0.0/8' PDNS_allow_axfr_ips: "127.0.0.0/8,192.0.0.0/8,10.0.0.0/8,172.0.0.0/8"
PDNS_gmysql_host: pdns-db PDNS_gmysql_host: pdns-db
PDNS_gmysql_port: 3306 PDNS_gmysql_port: 3306
PDNS_gmysql_user: pdns PDNS_gmysql_user: pdns
@@ -76,14 +75,14 @@ services:
pdns-db: pdns-db:
image: mariadb image: mariadb
environment: environment:
MYSQL_ROOT_PASSWORD: 'pdns' MYSQL_ROOT_PASSWORD: "pdns"
MYSQL_DATABASE: 'pdns' MYSQL_DATABASE: "pdns"
MYSQL_USER: 'pdns' MYSQL_USER: "pdns"
MYSQL_PASSWORD: 'pdns' MYSQL_PASSWORD: "pdns"
volumes: volumes:
- 'pdns_mysql_vol:/var/lib/mysql' - "pdns_mysql_vol:/var/lib/mysql"
- '/etc/localtime:/etc/localtime:ro' - "/etc/localtime:/etc/localtime:ro"
- './dev/pdns-db.sql:/docker-entrypoint-initdb.d/01_init.sql:ro' - "./dev/pdns-db.sql:/docker-entrypoint-initdb.d/01_init.sql:ro"
networks: networks:
- fulltest - fulltest
@@ -100,12 +99,12 @@ services:
context: ../ context: ../
dockerfile: test/cypress/Dockerfile dockerfile: test/cypress/Dockerfile
environment: environment:
HTTP_PROXY: 'squid:3128' HTTP_PROXY: "squid:3128"
HTTPS_PROXY: 'squid:3128' HTTPS_PROXY: "squid:3128"
volumes: volumes:
- 'cypress_logs:/test/results' - "cypress_logs:/test/results"
- './dev/resolv.conf:/etc/resolv.conf:ro' - "./dev/resolv.conf:/etc/resolv.conf:ro"
- '/etc/localtime:/etc/localtime:ro' - "/etc/localtime:/etc/localtime:ro"
command: cypress run --browser chrome --config-file=cypress/config/ci.js command: cypress run --browser chrome --config-file=cypress/config/ci.js
networks: networks:
- fulltest - fulltest
@@ -113,9 +112,9 @@ services:
squid: squid:
image: ubuntu/squid image: ubuntu/squid
volumes: volumes:
- './dev/squid.conf:/etc/squid/squid.conf:ro' - "./dev/squid.conf:/etc/squid/squid.conf:ro"
- './dev/resolv.conf:/etc/resolv.conf:ro' - "./dev/resolv.conf:/etc/resolv.conf:ro"
- '/etc/localtime:/etc/localtime:ro' - "/etc/localtime:/etc/localtime:ro"
networks: networks:
- fulltest - fulltest

View File

@@ -32,7 +32,7 @@ services:
# DB_MYSQL_PASSWORD: 'npm' # DB_MYSQL_PASSWORD: 'npm'
# DB_MYSQL_NAME: 'npm' # DB_MYSQL_NAME: 'npm'
# db-postgres: # db-postgres:
DB_POSTGRES_HOST: "db-postgres" DB_POSTGRES_HOST: "pgdb.internal"
DB_POSTGRES_PORT: "5432" DB_POSTGRES_PORT: "5432"
DB_POSTGRES_USER: "npm" DB_POSTGRES_USER: "npm"
DB_POSTGRES_PASSWORD: "npmpass" DB_POSTGRES_PASSWORD: "npmpass"
@@ -81,8 +81,6 @@ services:
db-postgres: db-postgres:
image: postgres:17 image: postgres:17
container_name: npm2dev.db-postgres container_name: npm2dev.db-postgres
networks:
- nginx_proxy_manager
environment: environment:
POSTGRES_USER: "npm" POSTGRES_USER: "npm"
POSTGRES_PASSWORD: "npmpass" POSTGRES_PASSWORD: "npmpass"
@@ -90,6 +88,10 @@ services:
volumes: volumes:
- psql_data:/var/lib/postgresql/data - psql_data:/var/lib/postgresql/data
- ./ci/postgres:/docker-entrypoint-initdb.d - ./ci/postgres:/docker-entrypoint-initdb.d
networks:
nginx_proxy_manager:
aliases:
- pgdb.internal
stepca: stepca:
image: jc21/testca image: jc21/testca

View File

@@ -4,7 +4,7 @@ import type { ReactNode } from "react";
import Select, { type ActionMeta, components, type OptionProps } from "react-select"; import Select, { type ActionMeta, components, type OptionProps } from "react-select";
import type { AccessList } from "src/api/backend"; import type { AccessList } from "src/api/backend";
import { useAccessLists } from "src/hooks"; import { useAccessLists } from "src/hooks";
import { DateTimeFormat, intl, T } from "src/locale"; import { formatDateTime, intl, T } from "src/locale";
interface AccessOption { interface AccessOption {
readonly value: number; readonly value: number;
@@ -48,7 +48,7 @@ export function AccessField({ name = "accessListId", label = "access-list", id =
{ {
users: item?.items?.length, users: item?.items?.length,
rules: item?.clients?.length, rules: item?.clients?.length,
date: item?.createdOn ? DateTimeFormat(item?.createdOn) : "N/A", date: item?.createdOn ? formatDateTime(item?.createdOn) : "N/A",
}, },
), ),
icon: <IconLock size={14} className="text-lime" />, icon: <IconLock size={14} className="text-lime" />,

View File

@@ -3,7 +3,7 @@ import { Field, useFormikContext } from "formik";
import Select, { type ActionMeta, components, type OptionProps } from "react-select"; import Select, { type ActionMeta, components, type OptionProps } from "react-select";
import type { Certificate } from "src/api/backend"; import type { Certificate } from "src/api/backend";
import { useCertificates } from "src/hooks"; import { useCertificates } from "src/hooks";
import { DateTimeFormat, intl, T } from "src/locale"; import { formatDateTime, intl, T } from "src/locale";
interface CertOption { interface CertOption {
readonly value: number | "new"; readonly value: number | "new";
@@ -75,7 +75,7 @@ export function SSLCertificateField({
data?.map((cert: Certificate) => ({ data?.map((cert: Certificate) => ({
value: cert.id, value: cert.id,
label: cert.niceName, label: cert.niceName,
subLabel: `${cert.provider === "letsencrypt" ? intl.formatMessage({ id: "lets-encrypt" }) : cert.provider} &mdash; ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? DateTimeFormat(cert.expiresOn) : "N/A" })}`, subLabel: `${cert.provider === "letsencrypt" ? intl.formatMessage({ id: "lets-encrypt" }) : cert.provider} ${intl.formatMessage({ id: "expires.on" }, { date: cert.expiresOn ? formatDateTime(cert.expiresOn) : "N/A" })}`,
icon: <IconShield size={14} className="text-pink" />, icon: <IconShield size={14} className="text-pink" />,
})) || []; })) || [];

View File

@@ -190,7 +190,7 @@ export function SiteMenu() {
return ( return (
<header className="navbar-expand-md"> <header className="navbar-expand-md">
<div className="collapse navbar-collapse"> <div className="collapse navbar-collapse" id="navbar-menu">
<div className="navbar"> <div className="navbar">
<div className="container-xl"> <div className="container-xl">
<div className="row flex-column flex-md-row flex-fill align-items-center"> <div className="row flex-column flex-md-row flex-fill align-items-center">

View File

@@ -1,6 +1,6 @@
import cn from "classnames"; import cn from "classnames";
import { differenceInDays, isPast, parseISO } from "date-fns"; import { differenceInDays, isPast } from "date-fns";
import { DateTimeFormat } from "src/locale"; import { formatDateTime, parseDate } from "src/locale";
interface Props { interface Props {
value: string; value: string;
@@ -8,11 +8,12 @@ interface Props {
highlistNearlyExpired?: boolean; highlistNearlyExpired?: boolean;
} }
export function DateFormatter({ value, highlightPast, highlistNearlyExpired }: Props) { export function DateFormatter({ value, highlightPast, highlistNearlyExpired }: Props) {
const dateIsPast = isPast(parseISO(value)); const d = parseDate(value);
const days = differenceInDays(parseISO(value), new Date()); const dateIsPast = d ? isPast(d) : false;
const days = d ? differenceInDays(d, new Date()) : 0;
const cl = cn({ const cl = cn({
"text-danger": highlightPast && dateIsPast, "text-danger": highlightPast && dateIsPast,
"text-warning": highlistNearlyExpired && !dateIsPast && days <= 30 && days >= 0, "text-warning": highlistNearlyExpired && !dateIsPast && days <= 30 && days >= 0,
}); });
return <span className={cl}>{DateTimeFormat(value)}</span>; return <span className={cl}>{formatDateTime(value)}</span>;
} }

View File

@@ -1,6 +1,6 @@
import cn from "classnames"; import cn from "classnames";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { DateTimeFormat, T } from "src/locale"; import { formatDateTime, T } from "src/locale";
interface Props { interface Props {
domains: string[]; domains: string[];
@@ -53,7 +53,7 @@ export function DomainsFormatter({ domains, createdOn, niceName, provider, color
<div className="font-weight-medium">{...elms}</div> <div className="font-weight-medium">{...elms}</div>
{createdOn ? ( {createdOn ? (
<div className="text-secondary mt-1"> <div className="text-secondary mt-1">
<T id="created-on" data={{ date: DateTimeFormat(createdOn) }} /> <T id="created-on" data={{ date: formatDateTime(createdOn) }} />
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,7 +1,7 @@
import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconShield, IconUser } from "@tabler/icons-react"; import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconLock, IconShield, IconUser } from "@tabler/icons-react";
import cn from "classnames"; import cn from "classnames";
import type { AuditLog } from "src/api/backend"; import type { AuditLog } from "src/api/backend";
import { DateTimeFormat, T } from "src/locale"; import { formatDateTime, T } from "src/locale";
const getEventValue = (event: AuditLog) => { const getEventValue = (event: AuditLog) => {
switch (event.objectType) { switch (event.objectType) {
@@ -73,7 +73,7 @@ export function EventFormatter({ row }: Props) {
<T id={`object.event.${row.action}`} tData={{ object: row.objectType }} /> <T id={`object.event.${row.action}`} tData={{ object: row.objectType }} />
&nbsp; &mdash; <span className="badge">{getEventValue(row)}</span> &nbsp; &mdash; <span className="badge">{getEventValue(row)}</span>
</div> </div>
<div className="text-secondary mt-1">{DateTimeFormat(row.createdOn)}</div> <div className="text-secondary mt-1">{formatDateTime(row.createdOn)}</div>
</div> </div>
); );
} }

View File

@@ -1,4 +1,4 @@
import { DateTimeFormat, T } from "src/locale"; import { formatDateTime, T } from "src/locale";
interface Props { interface Props {
value: string; value: string;
@@ -13,7 +13,7 @@ export function ValueWithDateFormatter({ value, createdOn, disabled }: Props) {
</div> </div>
{createdOn ? ( {createdOn ? (
<div className={`text-secondary mt-1 ${disabled ? "text-red" : ""}`}> <div className={`text-secondary mt-1 ${disabled ? "text-red" : ""}`}>
<T id={disabled ? "disabled" : "created-on"} data={{ date: DateTimeFormat(createdOn) }} /> <T id={disabled ? "disabled" : "created-on"} data={{ date: formatDateTime(createdOn) }} />
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,4 +1,4 @@
import { DateTimeFormat } from "src/locale"; import { formatDateTime } from "src/locale";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
describe("DateFormatter", () => { describe("DateFormatter", () => {
@@ -17,10 +17,7 @@ describe("DateFormatter", () => {
// Mock Intl.DateTimeFormat so formatting is stable regardless of host // Mock Intl.DateTimeFormat so formatting is stable regardless of host
const MockedDateTimeFormat = class extends RealIntl.DateTimeFormat { const MockedDateTimeFormat = class extends RealIntl.DateTimeFormat {
constructor( constructor(_locales?: string | string[], options?: Intl.DateTimeFormatOptions) {
_locales?: string | string[],
options?: Intl.DateTimeFormatOptions,
) {
super(desiredLocale, { super(desiredLocale, {
...options, ...options,
timeZone: desiredTimeZone, timeZone: desiredTimeZone,
@@ -41,37 +38,37 @@ describe("DateFormatter", () => {
it("format date from iso date", () => { it("format date from iso date", () => {
const value = "2024-01-01T00:00:00.000Z"; const value = "2024-01-01T00:00:00.000Z";
const text = DateTimeFormat(value); const text = formatDateTime(value);
expect(text).toBe("Monday, 01/01/2024, 12:00:00 am"); expect(text).toBe("Monday, 01/01/2024, 12:00:00 am");
}); });
it("format date from unix timestamp number", () => { it("format date from unix timestamp number", () => {
const value = 1762476112; const value = 1762476112;
const text = DateTimeFormat(value); const text = formatDateTime(value);
expect(text).toBe("Friday, 07/11/2025, 12:41:52 am"); expect(text).toBe("Friday, 07/11/2025, 12:41:52 am");
}); });
it("format date from unix timestamp string", () => { it("format date from unix timestamp string", () => {
const value = "1762476112"; const value = "1762476112";
const text = DateTimeFormat(value); const text = formatDateTime(value);
expect(text).toBe("Friday, 07/11/2025, 12:41:52 am"); expect(text).toBe("Friday, 07/11/2025, 12:41:52 am");
}); });
it("catch bad format from string", () => { it("catch bad format from string", () => {
const value = "this is not a good date"; const value = "this is not a good date";
const text = DateTimeFormat(value); const text = formatDateTime(value);
expect(text).toBe("this is not a good date"); expect(text).toBe("this is not a good date");
}); });
it("catch bad format from number", () => { it("catch bad format from number", () => {
const value = -100; const value = -100;
const text = DateTimeFormat(value); const text = formatDateTime(value);
expect(text).toBe("-100"); expect(text).toBe("-100");
}); });
it("catch bad format from number as string", () => { it("catch bad format from number as string", () => {
const value = "-100"; const value = "-100";
const text = DateTimeFormat(value); const text = formatDateTime(value);
expect(text).toBe("-100"); expect(text).toBe("-100");
}); });
}); });

View File

@@ -11,13 +11,19 @@ const isUnixTimestamp = (value: unknown): boolean => {
return false; return false;
}; };
const DateTimeFormat = (value: string | number): string => { const parseDate = (value: string | number): Date | null => {
if (typeof value !== "number" && typeof value !== "string") return `${value}`; if (typeof value !== "number" && typeof value !== "string") return null;
try {
return isUnixTimestamp(value) ? fromUnixTime(+value) : parseISO(`${value}`);
} catch {
return null;
}
};
const formatDateTime = (value: string | number): string => {
const d = parseDate(value);
if (!d) return `${value}`;
try { try {
const d = isUnixTimestamp(value)
? fromUnixTime(+value)
: parseISO(`${value}`);
return intlFormat(d, { return intlFormat(d, {
weekday: "long", weekday: "long",
year: "numeric", year: "numeric",
@@ -33,4 +39,4 @@ const DateTimeFormat = (value: string | number): string => {
} }
}; };
export { DateTimeFormat }; export { formatDateTime, parseDate, isUnixTimestamp };

View File

@@ -1,2 +1,2 @@
export * from "./DateTimeFormat";
export * from "./IntlProvider"; export * from "./IntlProvider";
export * from "./Utils";

View File

@@ -169,6 +169,7 @@
"public": "Public", "public": "Public",
"redirection-host": "Redirection Host", "redirection-host": "Redirection Host",
"redirection-host.forward-domain": "Forward Domain", "redirection-host.forward-domain": "Forward Domain",
"redirection-host.forward-http-code": "HTTP Code",
"redirection-hosts": "Redirection Hosts", "redirection-hosts": "Redirection Hosts",
"redirection-hosts.count": "{count} {count, plural, one {Redirection Host} other {Redirection Hosts}}", "redirection-hosts.count": "{count} {count, plural, one {Redirection Host} other {Redirection Hosts}}",
"role.admin": "Administrator", "role.admin": "Administrator",

View File

@@ -509,6 +509,9 @@
"redirection-host.forward-domain": { "redirection-host.forward-domain": {
"defaultMessage": "Forward Domain" "defaultMessage": "Forward Domain"
}, },
"redirection-host.forward-http-code": {
"defaultMessage": "HTTP Code"
},
"redirection-hosts": { "redirection-hosts": {
"defaultMessage": "Redirection Hosts" "defaultMessage": "Redirection Hosts"
}, },

View File

@@ -162,7 +162,7 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
required required
{...field} {...field}
> >
<option value="$scheme">Auto</option> <option value="auto">Auto</option>
<option value="http">http</option> <option value="http">http</option>
<option value="https">https</option> <option value="https">https</option>
</select> </select>
@@ -212,6 +212,36 @@ const RedirectionHostModal = EasyModal.create(({ id, visible, remove }: Props) =
</Field> </Field>
</div> </div>
</div> </div>
<Field name="forwardHttpCode">
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor="forwardHttpCode">
<T id="redirection-host.forward-http-code" />
</label>
<select
id="forwardHttpCode"
className={`form-control ${form.errors.forwardHttpCode && form.touched.forwardHttpCode ? "is-invalid" : ""}`}
required
{...field}
>
<option value="300">300 Multiple choices</option>
<option value="301">301 Moved permanently</option>
<option value="302">302 Moved temporarily</option>
<option value="303">303 See other</option>
<option value="307">307 Temporary redirect</option>
<option value="308">308 Permanent redirect</option>
</select>
{form.errors.forwardHttpCode ? (
<div className="invalid-feedback">
{form.errors.forwardHttpCode &&
form.touched.forwardHttpCode
? form.errors.forwardHttpCode
: null}
</div>
) : null}
</div>
)}
</Field>
<div className="my-3"> <div className="my-3">
<h4 className="py-2"> <h4 className="py-2">
<T id="options" /> <T id="options" />

View File

@@ -44,7 +44,7 @@ const validateEmail = () => {
if (!value.length) { if (!value.length) {
return intl.formatMessage({ id: "error.required" }); return intl.formatMessage({ id: "error.required" });
} }
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) { if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+$/i.test(value)) {
return intl.formatMessage({ id: "error.invalid-email" }); return intl.formatMessage({ id: "error.invalid-email" });
} }
}; };