Compare commits

..

18 Commits

Author SHA1 Message Date
Jamie Curnow
4240e00a46 404 hosts add update complete, fix certbot renewals
and remove the need for email and agreement on cert requests
2025-09-24 10:27:40 +10:00
Jamie Curnow
f39efb3e63 Dark UI for react-select 2025-09-23 10:14:01 +10:00
Jamie Curnow
53507f88b3 DNS Provider configuration 2025-09-22 22:19:18 +10:00
Jamie Curnow
553178aa6b API lib cleanup, 404 hosts WIP 2025-09-21 17:16:46 +10:00
Jamie Curnow
17f40dd8b2 Certificates react table basis 2025-09-18 14:18:49 +10:00
Jamie Curnow
68b23938a8 Fix custom cert writes, fix schema 2025-09-16 14:10:21 +10:00
Jamie Curnow
2b88f56d22 Audit log table and modal 2025-09-15 15:56:24 +10:00
Jamie Curnow
e44748e46f Set password for users 2025-09-15 14:06:43 +10:00
Jamie Curnow
538d28d32d Refactor from Promises to async/await 2025-09-11 14:13:54 +10:00
Jamie Curnow
a7d4fd55d9 Fix proxy hosts routes throwing errors 2025-09-11 08:16:11 +10:00
Jamie Curnow
9682de1830 Biome update 2025-09-10 21:38:02 +10:00
Jamie Curnow
cde7460b5e Fix cypress tests following user wizard changes 2025-09-10 21:32:16 +10:00
Jamie Curnow
ca84e3a146 User Permissions Modal 2025-09-09 15:13:34 +10:00
Jamie Curnow
fa11945235 Introducing the Setup Wizard for creating the first user
- no longer setup a default
- still able to do that with env vars however
2025-09-09 13:44:35 +10:00
Jamie Curnow
432afe73ad User table polishing, user delete modal 2025-09-04 14:59:01 +10:00
Jamie Curnow
5a01da2916 Notification toasts, nicer loading, add new user support 2025-09-04 12:11:39 +10:00
Jamie Curnow
ebd9148813 React 2025-09-03 14:02:14 +10:00
Jamie Curnow
a12553fec7 Convert backend to ESM
- About 5 years overdue
- Remove eslint, use bomejs instead
2025-09-03 13:59:40 +10:00
96 changed files with 756 additions and 3087 deletions

View File

@@ -1 +1 @@
2.13.0 2.12.6

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.0-green.svg?style=for-the-badge"> <img src="https://img.shields.io/badge/version-2.12.6-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>
@@ -88,6 +88,14 @@ Sometimes this can take a little bit because of the entropy of keys.
[http://127.0.0.1:81](http://127.0.0.1:81) [http://127.0.0.1:81](http://127.0.0.1:81)
Default Admin User:
```
Email: admin@example.com
Password: changeme
```
Immediately after logging in with this default user you will be asked to modify your details and change your password.
## Contributing ## Contributing

View File

@@ -63,7 +63,7 @@ const internalDeadHost = {
action: "created", action: "created",
object_type: "dead-host", object_type: "dead-host",
object_id: row.id, object_id: row.id,
meta: thisData, meta: _.assign({}, data.meta || {}, row.meta),
}); });
if (createCertificate) { if (createCertificate) {
@@ -240,7 +240,6 @@ const internalDeadHost = {
// Delete Nginx Config // Delete Nginx Config
await internalNginx.deleteConfig("dead_host", row); await internalNginx.deleteConfig("dead_host", row);
await internalNginx.reload(); await internalNginx.reload();
// Add to audit log // Add to audit log
await internalAuditLog.add(access, { await internalAuditLog.add(access, {
action: "deleted", action: "deleted",
@@ -248,7 +247,6 @@ const internalDeadHost = {
object_id: row.id, object_id: row.id,
meta: _.omit(row, omissions()), meta: _.omit(row, omissions()),
}); });
return true;
}, },
/** /**

View File

@@ -301,11 +301,8 @@ const internalNginx = {
* @param {String} filename * @param {String} filename
*/ */
deleteFile: (filename) => { deleteFile: (filename) => {
if (!fs.existsSync(filename)) {
return;
}
try {
logger.debug(`Deleting file: ${filename}`); logger.debug(`Deleting file: ${filename}`);
try {
fs.unlinkSync(filename); fs.unlinkSync(filename);
} catch (err) { } catch (err) {
logger.debug("Could not delete file:", JSON.stringify(err, null, 2)); logger.debug("Could not delete file:", JSON.stringify(err, null, 2));

View File

@@ -422,6 +422,7 @@ const internalProxyHost = {
*/ */
getAll: async (access, expand, searchQuery) => { getAll: async (access, expand, searchQuery) => {
const accessData = await access.can("proxy_hosts:list"); const accessData = await access.can("proxy_hosts:list");
const query = proxyHostModel const query = proxyHostModel
.query() .query()
.where("is_deleted", 0) .where("is_deleted", 0)
@@ -445,9 +446,11 @@ const internalProxyHost = {
} }
const rows = await query.then(utils.omitRows(omissions())); const rows = await query.then(utils.omitRows(omissions()));
if (typeof expand !== "undefined" && expand !== null && expand.indexOf("certificate") !== -1) { if (typeof expand !== "undefined" && expand !== null && expand.indexOf("certificate") !== -1) {
return internalHost.cleanAllRowsCertificateMeta(rows); return internalHost.cleanAllRowsCertificateMeta(rows);
} }
return rows; return rows;
}, },

View File

@@ -348,7 +348,7 @@ const internalStream = {
// Add to audit log // Add to audit log
return internalAuditLog.add(access, { return internalAuditLog.add(access, {
action: "disabled", action: "disabled",
object_type: "stream", object_type: "stream-host",
object_id: row.id, object_id: row.id,
meta: _.omit(row, omissions()), meta: _.omit(row, omissions()),
}); });

View File

@@ -131,7 +131,7 @@ const internalUser = {
action: "updated", action: "updated",
object_type: "user", object_type: "user",
object_id: user.id, object_id: user.id,
meta: { ...data, id: user.id, name: user.name }, meta: data,
}) })
.then(() => { .then(() => {
return user; return user;

View File

@@ -131,7 +131,7 @@ export default function (tokenString) {
const rows = await query; const rows = await query;
objects = []; objects = [];
_.forEach(rows, (ruleRow) => { _.forEach(rows, (ruleRow) => {
objects.push(ruleRow.id); result.push(ruleRow.id);
}); });
// enum should not have less than 1 item // enum should not have less than 1 item

View File

@@ -121,7 +121,7 @@ router
/** /**
* PUT /api/nginx/dead-hosts/123 * PUT /api/nginx/dead-hosts/123
* *
* Update an existing dead-host * Update and existing dead-host
*/ */
.put(async (req, res, next) => { .put(async (req, res, next) => {
try { try {
@@ -138,7 +138,7 @@ router
/** /**
* DELETE /api/nginx/dead-hosts/123 * DELETE /api/nginx/dead-hosts/123
* *
* Delete a dead-host * Update and existing dead-host
*/ */
.delete(async (req, res, next) => { .delete(async (req, res, next) => {
try { try {

View File

@@ -37,9 +37,6 @@
}, },
"meta": { "meta": {
"$ref": "../../../components/stream-object.json#/properties/meta" "$ref": "../../../components/stream-object.json#/properties/meta"
},
"domain_names": {
"$ref": "../../../components/dead-host-object.json#/properties/domain_names"
} }
} }
} }

View File

@@ -15,7 +15,7 @@ ENV SUPPRESS_NO_CONFIG_WARNING=1 \
RUN echo "fs.file-max = 65535" > /etc/sysctl.conf \ RUN echo "fs.file-max = 65535" > /etc/sysctl.conf \
&& apt-get update \ && apt-get update \
&& apt-get install -y jq python3-pip logrotate moreutils \ && apt-get install -y jq python3-pip logrotate \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View File

@@ -52,7 +52,7 @@ services:
- ../global:/app/global - ../global:/app/global
- '/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
depends_on: depends_on:
@@ -71,14 +71,12 @@ services:
networks: networks:
- nginx_proxy_manager - nginx_proxy_manager
environment: environment:
TZ: "${TZ:-Australia/Brisbane}"
MYSQL_ROOT_PASSWORD: 'npm' MYSQL_ROOT_PASSWORD: 'npm'
MYSQL_DATABASE: 'npm' MYSQL_DATABASE: 'npm'
MYSQL_USER: 'npm' MYSQL_USER: 'npm'
MYSQL_PASSWORD: 'npm' MYSQL_PASSWORD: 'npm'
volumes: volumes:
- db_data:/var/lib/mysql - db_data:/var/lib/mysql
- '/etc/localtime:/etc/localtime:ro'
db-postgres: db-postgres:
image: postgres:latest image: postgres:latest
@@ -204,7 +202,7 @@ services:
- nginx_proxy_manager - nginx_proxy_manager
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: [ 'CMD-SHELL', 'redis-cli ping | grep PONG' ] test: ['CMD-SHELL', 'redis-cli ping | grep PONG']
start_period: 20s start_period: 20s
interval: 30s interval: 30s
retries: 5 retries: 5

View File

@@ -64,8 +64,7 @@
"useUniqueElementIds": "off" "useUniqueElementIds": "off"
}, },
"suspicious": { "suspicious": {
"noExplicitAny": "off", "noExplicitAny": "off"
"noArrayIndexKey": "off"
}, },
"performance": { "performance": {
"noDelete": "off" "noDelete": "off"

View File

@@ -12,7 +12,6 @@
"prettier": "biome format --write ./src", "prettier": "biome format --write ./src",
"locale-extract": "formatjs extract 'src/**/*.tsx'", "locale-extract": "formatjs extract 'src/**/*.tsx'",
"locale-compile": "formatjs compile-folder src/locale/src src/locale/lang", "locale-compile": "formatjs compile-folder src/locale/src src/locale/lang",
"locale-sort": "./src/locale/scripts/locale-sort.sh",
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {

View File

@@ -70,7 +70,3 @@
font-family: 'Courier New', Courier, monospace !important; font-family: 'Courier New', Courier, monospace !important;
resize: vertical; resize: vertical;
} }
label.row {
cursor: pointer;
}

View File

@@ -1,8 +1,8 @@
import * as api from "./base"; import * as api from "./base";
import type { HostExpansion } from "./expansions"; import type { HostExpansion } from "./expansions";
import type { RedirectionHost } from "./models"; import type { ProxyHost } from "./models";
export async function getRedirectionHost(id: number, expand?: HostExpansion[], params = {}): Promise<RedirectionHost> { export async function getRedirectionHost(id: number, expand?: HostExpansion[], params = {}): Promise<ProxyHost> {
return await api.get({ return await api.get({
url: `/nginx/redirection-hosts/${id}`, url: `/nginx/redirection-hosts/${id}`,
params: { params: {

View File

@@ -47,7 +47,6 @@ export * from "./toggleDeadHost";
export * from "./toggleProxyHost"; export * from "./toggleProxyHost";
export * from "./toggleRedirectionHost"; export * from "./toggleRedirectionHost";
export * from "./toggleStream"; export * from "./toggleStream";
export * from "./toggleUser";
export * from "./updateAccessList"; export * from "./updateAccessList";
export * from "./updateAuth"; export * from "./updateAuth";
export * from "./updateDeadHost"; export * from "./updateDeadHost";

View File

@@ -53,7 +53,7 @@ export interface AccessList {
meta: Record<string, any>; meta: Record<string, any>;
satisfyAny: boolean; satisfyAny: boolean;
passAuth: boolean; passAuth: boolean;
proxyHostCount?: number; proxyHostCount: number;
// Expansions: // Expansions:
owner?: User; owner?: User;
items?: AccessListItem[]; items?: AccessListItem[];
@@ -103,7 +103,6 @@ export interface ProxyHost {
modifiedOn: string; modifiedOn: string;
ownerUserId: number; ownerUserId: number;
domainNames: string[]; domainNames: string[];
forwardScheme: string;
forwardHost: string; forwardHost: string;
forwardPort: number; forwardPort: number;
accessListId: number; accessListId: number;
@@ -115,8 +114,9 @@ export interface ProxyHost {
meta: Record<string, any>; meta: Record<string, any>;
allowWebsocketUpgrade: boolean; allowWebsocketUpgrade: boolean;
http2Support: boolean; http2Support: boolean;
forwardScheme: string;
enabled: boolean; enabled: boolean;
locations?: string[]; // todo: string or object? locations: string[]; // todo: string or object?
hstsEnabled: boolean; hstsEnabled: boolean;
hstsSubdomains: boolean; hstsSubdomains: boolean;
// Expansions: // Expansions:

View File

@@ -1,10 +0,0 @@
import type { User } from "./models";
import { updateUser } from "./updateUser";
export async function toggleUser(id: number, enabled: boolean): Promise<boolean> {
await updateUser({
id,
isDisabled: !enabled,
} as User);
return true;
}

View File

@@ -1,6 +1,6 @@
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { Button } from "src/components"; import { Button } from "src/components";
import { T } from "src/locale"; import { intl } from "src/locale";
export function ErrorNotFound() { export function ErrorNotFound() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -8,15 +8,11 @@ export function ErrorNotFound() {
return ( return (
<div className="container-tight py-4"> <div className="container-tight py-4">
<div className="empty"> <div className="empty">
<p className="empty-title"> <p className="empty-title">{intl.formatMessage({ id: "notfound.title" })}</p>
<T id="notfound.title" /> <p className="empty-subtitle text-secondary">{intl.formatMessage({ id: "notfound.text" })}</p>
</p>
<p className="empty-subtitle text-secondary">
<T id="notfound.text" />
</p>
<div className="empty-action"> <div className="empty-action">
<Button type="button" size="md" onClick={() => navigate("/")}> <Button type="button" size="md" onClick={() => navigate("/")}>
<T id="notfound.action" /> {intl.formatMessage({ id: "notfound.action" })}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -1,99 +0,0 @@
import { IconLock, IconLockOpen2 } from "@tabler/icons-react";
import { Field, useFormikContext } from "formik";
import type { ReactNode } from "react";
import Select, { type ActionMeta, components, type OptionProps } from "react-select";
import type { AccessList } from "src/api/backend";
import { useAccessLists } from "src/hooks";
import { DateTimeFormat, intl, T } from "src/locale";
interface AccessOption {
readonly value: number;
readonly label: string;
readonly subLabel: string;
readonly icon: ReactNode;
}
const Option = (props: OptionProps<AccessOption>) => {
return (
<components.Option {...props}>
<div className="flex-fill">
<div className="font-weight-medium">
{props.data.icon} <strong>{props.data.label}</strong>
</div>
<div className="text-secondary mt-1 ps-3">{props.data.subLabel}</div>
</div>
</components.Option>
);
};
interface Props {
id?: string;
name?: string;
label?: string;
}
export function AccessField({ name = "accessListId", label = "access.title", id = "accessListId" }: Props) {
const { isLoading, isError, error, data } = useAccessLists();
const { setFieldValue } = useFormikContext();
const handleChange = (newValue: any, _actionMeta: ActionMeta<AccessOption>) => {
setFieldValue(name, newValue?.value);
};
const options: AccessOption[] =
data?.map((item: AccessList) => ({
value: item.id || 0,
label: item.name,
subLabel: intl.formatMessage(
{ id: "access.subtitle" },
{
users: item?.items?.length,
rules: item?.clients?.length,
date: item?.createdOn ? DateTimeFormat(item?.createdOn) : "N/A",
},
),
icon: <IconLock size={14} className="text-lime" />,
})) || [];
// Public option
options?.unshift({
value: 0,
label: intl.formatMessage({ id: "access.public" }),
subLabel: "No basic auth required",
icon: <IconLockOpen2 size={14} className="text-red" />,
});
return (
<Field name={name}>
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor={id}>
<T id={label} />
</label>
{isLoading ? <div className="placeholder placeholder-lg col-12 my-3 placeholder-glow" /> : null}
{isError ? <div className="invalid-feedback">{`${error}`}</div> : null}
{!isLoading && !isError ? (
<Select
className="react-select-container"
classNamePrefix="react-select"
defaultValue={options.find((o) => o.value === field.value) || options[0]}
options={options}
components={{ Option }}
styles={{
option: (base) => ({
...base,
height: "100%",
}),
}}
onChange={handleChange}
/>
) : null}
{form.errors[field.name] ? (
<div className="invalid-feedback">
{form.errors[field.name] && form.touched[field.name] ? form.errors[field.name] : null}
</div>
) : null}
</div>
)}
</Field>
);
}

View File

@@ -1,36 +0,0 @@
import { useFormikContext } from "formik";
import { T } from "src/locale";
interface Props {
id?: string;
name?: string;
}
export function BasicAuthField({ name = "items", id = "items" }: Props) {
const { setFieldValue } = useFormikContext();
return (
<>
<div className="row">
<div className="col-6">
<label className="form-label" htmlFor="...">
<T id="username" />
</label>
</div>
<div className="col-6">
<label className="form-label" htmlFor="...">
<T id="password" />
</label>
</div>
</div>
<div className="row mb-3">
<div className="col-6">
<input id="name" type="text" required autoComplete="off" className="form-control" />
</div>
<div className="col-6">
<input id="pw" type="password" required autoComplete="off" className="form-control" />
</div>
</div>
<button className="btn">+</button>
</>
);
}

View File

@@ -10,6 +10,7 @@ interface DNSProviderOption {
readonly label: string; readonly label: string;
readonly credentials: string; readonly credentials: string;
} }
export function DNSProviderFields() { export function DNSProviderFields() {
const { values, setFieldValue } = useFormikContext(); const { values, setFieldValue } = useFormikContext();
const { data: dnsProviders, isLoading } = useDnsProviders(); const { data: dnsProviders, isLoading } = useDnsProviders();
@@ -99,7 +100,6 @@ export function DNSProviderFields() {
<input <input
id="propagationSeconds" id="propagationSeconds"
type="number" type="number"
x
className="form-control" className="form-control"
min={0} min={0}
max={600} max={600}

View File

@@ -1,11 +1,10 @@
import { Field, useFormikContext } from "formik"; import { Field, useFormikContext } from "formik";
import type { ReactNode } from "react";
import type { ActionMeta, MultiValue } from "react-select"; import type { ActionMeta, MultiValue } from "react-select";
import CreatableSelect from "react-select/creatable"; import CreatableSelect from "react-select/creatable";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { validateDomain, validateDomains } from "src/modules/Validations"; import { validateDomain, validateDomains } from "src/modules/Validations";
type SelectOption = { export type SelectOption = {
label: string; label: string;
value: string; value: string;
color?: string; color?: string;
@@ -36,14 +35,14 @@ export function DomainNamesField({
setFieldValue(name, doms); setFieldValue(name, doms);
}; };
const helperTexts: ReactNode[] = []; const helperTexts: string[] = [];
if (maxDomains) { if (maxDomains) {
helperTexts.push(<T id="domain-names.max" data={{ count: maxDomains }} />); helperTexts.push(intl.formatMessage({ id: "domain-names.max" }, { count: maxDomains }));
} }
if (!isWildcardPermitted) { if (!isWildcardPermitted) {
helperTexts.push(<T id="domain-names.wildcards-not-permitted" />); helperTexts.push(intl.formatMessage({ id: "domain-names.wildcards-not-permitted" }));
} else if (!dnsProviderWildcardSupported) { } else if (!dnsProviderWildcardSupported) {
helperTexts.push(<T id="domain-names.wildcards-not-supported" />); helperTexts.push(intl.formatMessage({ id: "domain-names.wildcards-not-supported" }));
} }
return ( return (
@@ -51,7 +50,7 @@ export function DomainNamesField({
{({ field, form }: any) => ( {({ field, form }: any) => (
<div className="mb-3"> <div className="mb-3">
<label className="form-label" htmlFor={id}> <label className="form-label" htmlFor={id}>
<T id={label} /> {intl.formatMessage({ id: label })}
</label> </label>
<CreatableSelect <CreatableSelect
className="react-select-container" className="react-select-container"
@@ -69,8 +68,8 @@ export function DomainNamesField({
{form.errors[field.name] && form.touched[field.name] ? ( {form.errors[field.name] && form.touched[field.name] ? (
<small className="text-danger">{form.errors[field.name]}</small> <small className="text-danger">{form.errors[field.name]}</small>
) : helperTexts.length ? ( ) : helperTexts.length ? (
helperTexts.map((i, idx) => ( helperTexts.map((i) => (
<small key={idx} className="text-info"> <small key={i} className="text-info">
{i} {i}
</small> </small>
)) ))

View File

@@ -1,6 +1,6 @@
import CodeEditor from "@uiw/react-textarea-code-editor"; import CodeEditor from "@uiw/react-textarea-code-editor";
import { Field } from "formik"; import { Field } from "formik";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
id?: string; id?: string;
@@ -17,7 +17,7 @@ export function NginxConfigField({
{({ field }: any) => ( {({ field }: any) => (
<div className="mt-3"> <div className="mt-3">
<label htmlFor={id} className="form-label"> <label htmlFor={id} className="form-label">
<T id={label} /> {intl.formatMessage({ id: label })}
</label> </label>
<CodeEditor <CodeEditor
language="nginx" language="nginx"

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, T } from "src/locale"; import { DateTimeFormat, intl } from "src/locale";
interface CertOption { interface CertOption {
readonly value: number | "new"; readonly value: number | "new";
@@ -31,7 +31,6 @@ interface Props {
label?: string; label?: string;
required?: boolean; required?: boolean;
allowNew?: boolean; allowNew?: boolean;
forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields
} }
export function SSLCertificateField({ export function SSLCertificateField({
name = "certificateId", name = "certificateId",
@@ -39,7 +38,6 @@ export function SSLCertificateField({
id = "certificateId", id = "certificateId",
required, required,
allowNew, allowNew,
forHttp = true,
}: Props) { }: Props) {
const { isLoading, isError, error, data } = useCertificates(); const { isLoading, isError, error, data } = useCertificates();
const { values, setFieldValue } = useFormikContext(); const { values, setFieldValue } = useFormikContext();
@@ -57,7 +55,7 @@ export function SSLCertificateField({
dnsProviderCredentials, dnsProviderCredentials,
propagationSeconds, propagationSeconds,
} = v; } = v;
if (forHttp && !newValue?.value) { if (!newValue?.value) {
sslForced && setFieldValue("sslForced", false); sslForced && setFieldValue("sslForced", false);
http2Support && setFieldValue("http2Support", false); http2Support && setFieldValue("http2Support", false);
hstsEnabled && setFieldValue("hstsEnabled", false); hstsEnabled && setFieldValue("hstsEnabled", false);
@@ -96,7 +94,7 @@ export function SSLCertificateField({
options?.unshift({ options?.unshift({
value: 0, value: 0,
label: "None", label: "None",
subLabel: forHttp ? "This host will not use HTTPS" : "No certificate assigned", subLabel: "This host will not use HTTPS",
icon: <IconShield size={14} className="text-red" />, icon: <IconShield size={14} className="text-red" />,
}); });
} }
@@ -106,7 +104,7 @@ export function SSLCertificateField({
{({ field, form }: any) => ( {({ field, form }: any) => (
<div className="mb-3"> <div className="mb-3">
<label className="form-label" htmlFor={id}> <label className="form-label" htmlFor={id}>
<T id={label} /> {intl.formatMessage({ id: label })}
</label> </label>
{isLoading ? <div className="placeholder placeholder-lg col-12 my-3 placeholder-glow" /> : null} {isLoading ? <div className="placeholder placeholder-lg col-12 my-3 placeholder-glow" /> : null}
{isError ? <div className="invalid-feedback">{`${error}`}</div> : null} {isError ? <div className="invalid-feedback">{`${error}`}</div> : null}

View File

@@ -1,15 +1,9 @@
import cn from "classnames"; import cn from "classnames";
import { Field, useFormikContext } from "formik"; import { Field, useFormikContext } from "formik";
import { DNSProviderFields, DomainNamesField } from "src/components"; import { DNSProviderFields } from "src/components";
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { export function SSLOptionsFields() {
forHttp?: boolean; // the sslForced, http2Support, hstsEnabled, hstsSubdomains fields
forceDNSForNew?: boolean;
requireDomainNames?: boolean; // used for streams
color?: string;
}
export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomainNames, color = "bg-cyan" }: Props) {
const { values, setFieldValue } = useFormikContext(); const { values, setFieldValue } = useFormikContext();
const v: any = values || {}; const v: any = values || {};
@@ -18,10 +12,6 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, meta } = v; const { sslForced, http2Support, hstsEnabled, hstsSubdomains, meta } = v;
const { dnsChallenge } = meta || {}; const { dnsChallenge } = meta || {};
if (forceDNSForNew && newCertificate && !dnsChallenge) {
setFieldValue("meta.dnsChallenge", true);
}
const handleToggleChange = (e: any, fieldName: string) => { const handleToggleChange = (e: any, fieldName: string) => {
setFieldValue(fieldName, e.target.checked); setFieldValue(fieldName, e.target.checked);
if (fieldName === "meta.dnsChallenge" && !e.target.checked) { if (fieldName === "meta.dnsChallenge" && !e.target.checked) {
@@ -32,10 +22,10 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
}; };
const toggleClasses = "form-check-input"; const toggleClasses = "form-check-input";
const toggleEnabled = cn(toggleClasses, color); const toggleEnabled = cn(toggleClasses, "bg-cyan");
const getHttpOptions = () => ( return (
<div> <>
<div className="row"> <div className="row">
<div className="col-6"> <div className="col-6">
<Field name="sslForced"> <Field name="sslForced">
@@ -49,7 +39,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
disabled={!hasCertificate} disabled={!hasCertificate}
/> />
<span className="form-check-label"> <span className="form-check-label">
<T id="domains.force-ssl" /> {intl.formatMessage({ id: "domains.force-ssl" })}
</span> </span>
</label> </label>
)} )}
@@ -67,7 +57,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
disabled={!hasCertificate} disabled={!hasCertificate}
/> />
<span className="form-check-label"> <span className="form-check-label">
<T id="domains.http2-support" /> {intl.formatMessage({ id: "domains.http2-support" })}
</span> </span>
</label> </label>
)} )}
@@ -87,7 +77,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
disabled={!hasCertificate || !sslForced} disabled={!hasCertificate || !sslForced}
/> />
<span className="form-check-label"> <span className="form-check-label">
<T id="domains.hsts-enabled" /> {intl.formatMessage({ id: "domains.hsts-enabled" })}
</span> </span>
</label> </label>
)} )}
@@ -105,19 +95,13 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
disabled={!hasCertificate || !hstsEnabled} disabled={!hasCertificate || !hstsEnabled}
/> />
<span className="form-check-label"> <span className="form-check-label">
<T id="domains.hsts-subdomains" /> {intl.formatMessage({ id: "domains.hsts-subdomains" })}
</span> </span>
</label> </label>
)} )}
</Field> </Field>
</div> </div>
</div> </div>
</div>
);
return (
<div>
{forHttp ? getHttpOptions() : null}
{newCertificate ? ( {newCertificate ? (
<> <>
<Field name="meta.dnsChallenge"> <Field name="meta.dnsChallenge">
@@ -126,20 +110,19 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
<input <input
className={dnsChallenge ? toggleEnabled : toggleClasses} className={dnsChallenge ? toggleEnabled : toggleClasses}
type="checkbox" type="checkbox"
checked={forceDNSForNew ? true : !!dnsChallenge} checked={!!dnsChallenge}
disabled={forceDNSForNew}
onChange={(e) => handleToggleChange(e, field.name)} onChange={(e) => handleToggleChange(e, field.name)}
/> />
<span className="form-check-label"> <span className="form-check-label">
<T id="domains.use-dns" /> {intl.formatMessage({ id: "domains.use-dns" })}
</span> </span>
</label> </label>
)} )}
</Field> </Field>
{requireDomainNames ? <DomainNamesField /> : null}
{dnsChallenge ? <DNSProviderFields /> : null} {dnsChallenge ? <DNSProviderFields /> : null}
</> </>
) : null} ) : null}
</div> </>
); );
} }

View File

@@ -1,5 +1,3 @@
export * from "./AccessField";
export * from "./BasicAuthField";
export * from "./DNSProviderFields"; export * from "./DNSProviderFields";
export * from "./DomainNamesField"; export * from "./DomainNamesField";
export * from "./NginxConfigField"; export * from "./NginxConfigField";

View File

@@ -2,7 +2,7 @@ import type { ReactNode } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { Loading, LoadingPage } from "src/components"; import { Loading, LoadingPage } from "src/components";
import { useUser } from "src/hooks"; import { useUser } from "src/hooks";
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
permission: string; permission: string;
@@ -64,11 +64,7 @@ function HasPermission({
return <>{children}</>; return <>{children}</>;
} }
return !hideError ? ( return !hideError ? <Alert variant="danger">{intl.formatMessage({ id: "no-permission-error" })}</Alert> : null;
<Alert variant="danger">
<T id="no-permission-error" />
</Alert>
) : null;
} }
export { HasPermission }; export { HasPermission };

View File

@@ -1,9 +1,8 @@
import type { ReactNode } from "react"; import { intl } from "src/locale";
import { T } from "src/locale";
import styles from "./Loading.module.css"; import styles from "./Loading.module.css";
interface Props { interface Props {
label?: string | ReactNode; label?: string;
noLogo?: boolean; noLogo?: boolean;
} }
export function Loading({ label, noLogo }: Props) { export function Loading({ label, noLogo }: Props) {
@@ -14,7 +13,7 @@ export function Loading({ label, noLogo }: Props) {
<img className={styles.logo} src="/images/logo-no-text.svg" alt="" /> <img className={styles.logo} src="/images/logo-no-text.svg" alt="" />
</div> </div>
)} )}
<div className="text-secondary mb-3">{label || <T id="loading" />}</div> <div className="text-secondary mb-3">{label || intl.formatMessage({ id: "loading" })}</div>
<div className="progress progress-sm"> <div className="progress progress-sm">
<div className="progress-bar progress-bar-indeterminate" /> <div className="progress-bar progress-bar-indeterminate" />
</div> </div>

View File

@@ -2,7 +2,7 @@ import cn from "classnames";
import { Flag } from "src/components"; import { Flag } from "src/components";
import { useLocaleState } from "src/context"; import { useLocaleState } from "src/context";
import { useTheme } from "src/hooks"; import { useTheme } from "src/hooks";
import { changeLocale, getFlagCodeForLocale, localeOptions, T } from "src/locale"; import { changeLocale, getFlagCodeForLocale, intl, localeOptions } from "src/locale";
import styles from "./LocalePicker.module.css"; import styles from "./LocalePicker.module.css";
function LocalePicker() { function LocalePicker() {
@@ -35,13 +35,34 @@ function LocalePicker() {
changeTo(item[0]); changeTo(item[0]);
}} }}
> >
<Flag countryCode={getFlagCodeForLocale(item[0])} /> <T id={`locale-${item[1]}`} /> <Flag countryCode={getFlagCodeForLocale(item[0])} />{" "}
{intl.formatMessage({ id: `locale-${item[1]}` })}
</a> </a>
); );
})} })}
</div> </div>
</div> </div>
); );
// <div className={className}>
// <Menu>
// <MenuButton as={Button} {...additionalProps}>
// <Flag countryCode={getFlagCodeForLocale(locale)} />
// </MenuButton>
// <MenuList>
// {localeOptions.map((item) => {
// return (
// <MenuItem
// icon={<Flag countryCode={getFlagCodeForLocale(item[0])} />}
// onClick={() => changeTo(item[0])}
// key={`locale-${item[0]}`}>
// <span>{intl.formatMessage({ id: `locale-${item[1]}` })}</span>
// </MenuItem>
// );
// })}
// </MenuList>
// </Menu>
// </Box>
} }
export { LocalePicker }; export { LocalePicker };

View File

@@ -1,5 +1,5 @@
import { useHealth } from "src/hooks"; import { useHealth } from "src/hooks";
import { T } from "src/locale"; import { intl } from "src/locale";
export function SiteFooter() { export function SiteFooter() {
const health = useHealth(); const health = useHealth();
@@ -25,7 +25,7 @@ export function SiteFooter() {
className="link-secondary" className="link-secondary"
rel="noopener" rel="noopener"
> >
<T id="footer.github-fork" /> {intl.formatMessage({ id: "footer.github-fork" })}
</a> </a>
</li> </li>
</ul> </ul>

View File

@@ -3,7 +3,7 @@ import { useState } from "react";
import { LocalePicker, ThemeSwitcher } from "src/components"; import { LocalePicker, ThemeSwitcher } from "src/components";
import { useAuthState } from "src/context"; import { useAuthState } from "src/context";
import { useUser } from "src/hooks"; import { useUser } from "src/hooks";
import { T } from "src/locale"; import { intl } from "src/locale";
import { ChangePasswordModal, UserModal } from "src/modals"; import { ChangePasswordModal, UserModal } from "src/modals";
import styles from "./SiteHeader.module.css"; import styles from "./SiteHeader.module.css";
@@ -66,7 +66,9 @@ export function SiteHeader() {
<div className="d-none d-xl-block ps-2"> <div className="d-none d-xl-block ps-2">
<div>{currentUser?.nickname}</div> <div>{currentUser?.nickname}</div>
<div className="mt-1 small text-secondary"> <div className="mt-1 small text-secondary">
<T id={isAdmin ? "role.admin" : "role.standard-user"} /> {intl.formatMessage({
id: isAdmin ? "role.admin" : "role.standard-user",
})}
</div> </div>
</div> </div>
</a> </a>
@@ -80,7 +82,7 @@ export function SiteHeader() {
}} }}
> >
<IconUser width={18} /> <IconUser width={18} />
<T id="user.edit-profile" /> {intl.formatMessage({ id: "user.edit-profile" })}
</a> </a>
<a <a
href="?" href="?"
@@ -91,7 +93,7 @@ export function SiteHeader() {
}} }}
> >
<IconLock width={18} /> <IconLock width={18} />
<T id="user.change-password" /> {intl.formatMessage({ id: "user.change-password" })}
</a> </a>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a <a
@@ -103,7 +105,7 @@ export function SiteHeader() {
}} }}
> >
<IconLogout width={18} /> <IconLogout width={18} />
<T id="user.logout" /> {intl.formatMessage({ id: "user.logout" })}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -10,7 +10,7 @@ import {
import cn from "classnames"; import cn from "classnames";
import React from "react"; import React from "react";
import { HasPermission, NavLink } from "src/components"; import { HasPermission, NavLink } from "src/components";
import { T } from "src/locale"; import { intl } from "src/locale";
interface MenuItem { interface MenuItem {
label: string; label: string;
@@ -108,9 +108,7 @@ const getMenuItem = (item: MenuItem, onClick?: () => void) => {
<span className="nav-link-icon d-md-none d-lg-inline-block"> <span className="nav-link-icon d-md-none d-lg-inline-block">
{item.icon && React.createElement(item.icon, { height: 24, width: 24 })} {item.icon && React.createElement(item.icon, { height: 24, width: 24 })}
</span> </span>
<span className="nav-link-title"> <span className="nav-link-title">{intl.formatMessage({ id: item.label })}</span>
<T id={item.label} />
</span>
</NavLink> </NavLink>
</li> </li>
</HasPermission> </HasPermission>
@@ -138,9 +136,7 @@ const getMenuDropown = (item: MenuItem, onClick?: () => void) => {
<span className="nav-link-icon d-md-none d-lg-inline-block"> <span className="nav-link-icon d-md-none d-lg-inline-block">
<IconDeviceDesktop height={24} width={24} /> <IconDeviceDesktop height={24} width={24} />
</span> </span>
<span className="nav-link-title"> <span className="nav-link-title">{intl.formatMessage({ id: item.label })}</span>
<T id={item.label} />
</span>
</a> </a>
<div className="dropdown-menu"> <div className="dropdown-menu">
{item.items?.map((subitem, idx) => { {item.items?.map((subitem, idx) => {
@@ -152,7 +148,7 @@ const getMenuDropown = (item: MenuItem, onClick?: () => void) => {
hideError hideError
> >
<NavLink to={subitem.to} isDropdownItem onClick={onClick}> <NavLink to={subitem.to} isDropdownItem onClick={onClick}>
<T id={subitem.label} /> {intl.formatMessage({ id: subitem.label })}
</NavLink> </NavLink>
</HasPermission> </HasPermission>
); );

View File

@@ -1,9 +1,13 @@
import type { Certificate } from "src/api/backend"; import type { Certificate } from "src/api/backend";
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
certificate?: Certificate; certificate?: Certificate;
} }
export function CertificateFormatter({ certificate }: Props) { export function CertificateFormatter({ certificate }: Props) {
return <T id={certificate ? "lets-encrypt" : "http-only"} />; if (certificate) {
return intl.formatMessage({ id: "lets-encrypt" });
}
return intl.formatMessage({ id: "http-only" });
} }

View File

@@ -1,40 +1,22 @@
import { DateTimeFormat, T } from "src/locale"; import { DateTimeFormat, intl } from "src/locale";
interface Props { interface Props {
domains: string[]; domains: string[];
createdOn?: string; createdOn?: string;
} }
const DomainLink = ({ domain }: { domain: string }) => {
// when domain contains a wildcard, make the link go nowhere.
let onClick: ((e: React.MouseEvent) => void) | undefined;
if (domain.includes("*")) {
onClick = (e: React.MouseEvent) => e.preventDefault();
}
return (
<a
key={domain}
href={`http://${domain}`}
target="_blank"
onClick={onClick}
className="badge bg-yellow-lt domain-name me-2"
>
{domain}
</a>
);
};
export function DomainsFormatter({ domains, createdOn }: Props) { export function DomainsFormatter({ domains, createdOn }: Props) {
return ( return (
<div className="flex-fill"> <div className="flex-fill">
<div className="font-weight-medium"> <div className="font-weight-medium">
{domains.map((domain: string) => ( {domains.map((domain: string) => (
<DomainLink key={domain} domain={domain} /> <a key={domain} href={`http://${domain}`} className="badge bg-yellow-lt domain-name">
{domain}
</a>
))} ))}
</div> </div>
{createdOn ? ( {createdOn ? (
<div className="text-secondary mt-1"> <div className="text-secondary mt-1">
<T id="created-on" data={{ date: DateTimeFormat(createdOn) }} /> {intl.formatMessage({ id: "created-on" }, { date: DateTimeFormat(createdOn) })}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,13 +0,0 @@
import cn from "classnames";
import { T } from "src/locale";
interface Props {
enabled: boolean;
}
export function EnabledFormatter({ enabled }: Props) {
return (
<span className={cn("badge", enabled ? "bg-lime-lt" : "bg-red-lt")}>
<T id={enabled ? "enabled" : "disabled"} />
</span>
);
}

View File

@@ -1,17 +1,15 @@
import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc, IconUser } from "@tabler/icons-react"; import { IconUser } from "@tabler/icons-react";
import type { AuditLog } from "src/api/backend"; import type { AuditLog } from "src/api/backend";
import { DateTimeFormat, T } from "src/locale"; import { DateTimeFormat, intl } from "src/locale";
const getEventTitle = (event: AuditLog) => (
<span>{intl.formatMessage({ id: `event.${event.action}-${event.objectType}` })}</span>
);
const getEventValue = (event: AuditLog) => { const getEventValue = (event: AuditLog) => {
switch (event.objectType) { switch (event.objectType) {
case "user": case "user":
return event.meta?.name; return event.meta?.name;
case "proxy-host":
case "redirection-host":
case "dead-host":
return event.meta?.domainNames?.join(", ") || "N/A";
case "stream":
return event.meta?.incomingPort || "N/A";
default: default:
return `UNKNOWN EVENT TYPE: ${event.objectType}`; return `UNKNOWN EVENT TYPE: ${event.objectType}`;
} }
@@ -35,18 +33,6 @@ const getIcon = (row: AuditLog) => {
case "user": case "user":
ico = <IconUser size={16} className={c} />; ico = <IconUser size={16} className={c} />;
break; break;
case "proxy-host":
ico = <IconBolt size={16} className={c} />;
break;
case "redirection-host":
ico = <IconArrowsCross size={16} className={c} />;
break;
case "dead-host":
ico = <IconBoltOff size={16} className={c} />;
break;
case "stream":
ico = <IconDisc size={16} className={c} />;
break;
} }
return ico; return ico;
@@ -59,9 +45,7 @@ export function EventFormatter({ row }: Props) {
return ( return (
<div className="flex-fill"> <div className="flex-fill">
<div className="font-weight-medium"> <div className="font-weight-medium">
{getIcon(row)} {getIcon(row)} {getEventTitle(row)} &mdash; <span className="badge">{getEventValue(row)}</span>
<T id={`event.${row.action}-${row.objectType}`} />
&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">{DateTimeFormat(row.createdOn)}</div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
roles: string[]; roles: string[];
@@ -12,7 +12,7 @@ export function RolesFormatter({ roles }: Props) {
<> <>
{r.map((role: string) => ( {r.map((role: string) => (
<span key={role} className="badge bg-yellow-lt me-1"> <span key={role} className="badge bg-yellow-lt me-1">
<T id={`role.${role}`} /> {intl.formatMessage({ id: `role.${role}` })}
</span> </span>
))} ))}
</> </>

View File

@@ -1,13 +1,11 @@
import cn from "classnames"; import { intl } from "src/locale";
import { T } from "src/locale";
interface Props { interface Props {
enabled: boolean; enabled: boolean;
} }
export function StatusFormatter({ enabled }: Props) { export function StatusFormatter({ enabled }: Props) {
return ( if (enabled) {
<span className={cn("badge", enabled ? "bg-lime-lt" : "bg-red-lt")}> return <span className="badge bg-lime-lt">{intl.formatMessage({ id: "online" })}</span>;
<T id={enabled ? "online" : "offline"} /> }
</span> return <span className="badge bg-red-lt">{intl.formatMessage({ id: "offline" })}</span>;
);
} }

View File

@@ -1,4 +1,4 @@
import { DateTimeFormat, T } from "src/locale"; import { DateTimeFormat, intl } from "src/locale";
interface Props { interface Props {
value: string; value: string;
@@ -13,7 +13,9 @@ 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) }} /> {disabled
? intl.formatMessage({ id: "disabled" })
: intl.formatMessage({ id: "created-on" }, { date: DateTimeFormat(createdOn) })}
</div> </div>
) : null} ) : null}
</div> </div>

View File

@@ -1,7 +1,6 @@
export * from "./CertificateFormatter"; export * from "./CertificateFormatter";
export * from "./DomainsFormatter"; export * from "./DomainsFormatter";
export * from "./EmailFormatter"; export * from "./EmailFormatter";
export * from "./EnabledFormatter";
export * from "./EventFormatter"; export * from "./EventFormatter";
export * from "./GravatarFormatter"; export * from "./GravatarFormatter";
export * from "./RolesFormatter"; export * from "./RolesFormatter";

View File

@@ -1,4 +1,3 @@
export * from "./useAccessList";
export * from "./useAccessLists"; export * from "./useAccessLists";
export * from "./useAuditLog"; export * from "./useAuditLog";
export * from "./useAuditLogs"; export * from "./useAuditLogs";
@@ -8,11 +7,8 @@ export * from "./useDeadHosts";
export * from "./useDnsProviders"; export * from "./useDnsProviders";
export * from "./useHealth"; export * from "./useHealth";
export * from "./useHostReport"; export * from "./useHostReport";
export * from "./useProxyHost";
export * from "./useProxyHosts"; export * from "./useProxyHosts";
export * from "./useRedirectionHost";
export * from "./useRedirectionHosts"; export * from "./useRedirectionHosts";
export * from "./useStream";
export * from "./useStreams"; export * from "./useStreams";
export * from "./useTheme"; export * from "./useTheme";
export * from "./useUser"; export * from "./useUser";

View File

@@ -1,53 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { type AccessList, createAccessList, getAccessList, updateAccessList } from "src/api/backend";
const fetchAccessList = (id: number | "new") => {
if (id === "new") {
return Promise.resolve({
id: 0,
createdOn: "",
modifiedOn: "",
ownerUserId: 0,
name: "",
satisfyAny: false,
passAuth: false,
meta: {},
} as AccessList);
}
return getAccessList(id, ["owner"]);
};
const useAccessList = (id: number | "new", options = {}) => {
return useQuery<AccessList, Error>({
queryKey: ["access-list", id],
queryFn: () => fetchAccessList(id),
staleTime: 60 * 1000, // 1 minute
...options,
});
};
const useSetAccessList = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: AccessList) => (values.id ? updateAccessList(values) : createAccessList(values)),
onMutate: (values: AccessList) => {
if (!values.id) {
return;
}
const previousObject = queryClient.getQueryData(["access-list", values.id]);
queryClient.setQueryData(["access-list", values.id], (old: AccessList) => ({
...old,
...values,
}));
return () => queryClient.setQueryData(["access-list", values.id], previousObject);
},
onError: (_, __, rollback: any) => rollback(),
onSuccess: async ({ id }: AccessList) => {
queryClient.invalidateQueries({ queryKey: ["access-list", id] });
queryClient.invalidateQueries({ queryKey: ["access-list"] });
queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
},
});
};
export { useAccessList, useSetAccessList };

View File

@@ -50,7 +50,6 @@ const useSetDeadHost = () => {
onSuccess: async ({ id }: DeadHost) => { onSuccess: async ({ id }: DeadHost) => {
queryClient.invalidateQueries({ queryKey: ["dead-host", id] }); queryClient.invalidateQueries({ queryKey: ["dead-host", id] });
queryClient.invalidateQueries({ queryKey: ["dead-hosts"] }); queryClient.invalidateQueries({ queryKey: ["dead-hosts"] });
queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
}, },
}); });
}; };

View File

@@ -1,65 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createProxyHost, getProxyHost, type ProxyHost, updateProxyHost } from "src/api/backend";
const fetchProxyHost = (id: number | "new") => {
if (id === "new") {
return Promise.resolve({
id: 0,
createdOn: "",
modifiedOn: "",
ownerUserId: 0,
domainNames: [],
forwardHost: "",
forwardPort: 0,
accessListId: 0,
certificateId: 0,
sslForced: false,
cachingEnabled: false,
blockExploits: false,
advancedConfig: "",
meta: {},
allowWebsocketUpgrade: false,
http2Support: false,
forwardScheme: "",
enabled: true,
hstsEnabled: false,
hstsSubdomains: false,
} as ProxyHost);
}
return getProxyHost(id, ["owner"]);
};
const useProxyHost = (id: number | "new", options = {}) => {
return useQuery<ProxyHost, Error>({
queryKey: ["proxy-host", id],
queryFn: () => fetchProxyHost(id),
staleTime: 60 * 1000, // 1 minute
...options,
});
};
const useSetProxyHost = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: ProxyHost) => (values.id ? updateProxyHost(values) : createProxyHost(values)),
onMutate: (values: ProxyHost) => {
if (!values.id) {
return;
}
const previousObject = queryClient.getQueryData(["proxy-host", values.id]);
queryClient.setQueryData(["proxy-host", values.id], (old: ProxyHost) => ({
...old,
...values,
}));
return () => queryClient.setQueryData(["proxy-host", values.id], previousObject);
},
onError: (_, __, rollback: any) => rollback(),
onSuccess: async ({ id }: ProxyHost) => {
queryClient.invalidateQueries({ queryKey: ["proxy-host", id] });
queryClient.invalidateQueries({ queryKey: ["proxy-hosts"] });
queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
},
});
};
export { useProxyHost, useSetProxyHost };

View File

@@ -1,69 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createRedirectionHost,
getRedirectionHost,
type RedirectionHost,
updateRedirectionHost,
} from "src/api/backend";
const fetchRedirectionHost = (id: number | "new") => {
if (id === "new") {
return Promise.resolve({
id: 0,
createdOn: "",
modifiedOn: "",
ownerUserId: 0,
domainNames: [],
forwardDomainName: "",
preservePath: false,
certificateId: 0,
sslForced: false,
advancedConfig: "",
meta: {},
http2Support: false,
forwardScheme: "auto",
forwardHttpCode: 301,
blockExploits: false,
enabled: true,
hstsEnabled: false,
hstsSubdomains: false,
} as RedirectionHost);
}
return getRedirectionHost(id, ["owner"]);
};
const useRedirectionHost = (id: number | "new", options = {}) => {
return useQuery<RedirectionHost, Error>({
queryKey: ["redirection-host", id],
queryFn: () => fetchRedirectionHost(id),
staleTime: 60 * 1000, // 1 minute
...options,
});
};
const useSetRedirectionHost = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: RedirectionHost) =>
values.id ? updateRedirectionHost(values) : createRedirectionHost(values),
onMutate: (values: RedirectionHost) => {
if (!values.id) {
return;
}
const previousObject = queryClient.getQueryData(["redirection-host", values.id]);
queryClient.setQueryData(["redirection-host", values.id], (old: RedirectionHost) => ({
...old,
...values,
}));
return () => queryClient.setQueryData(["redirection-host", values.id], previousObject);
},
onError: (_, __, rollback: any) => rollback(),
onSuccess: async ({ id }: RedirectionHost) => {
queryClient.invalidateQueries({ queryKey: ["redirection-host", id] });
queryClient.invalidateQueries({ queryKey: ["redirection-hosts"] });
queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
},
});
};
export { useRedirectionHost, useSetRedirectionHost };

View File

@@ -1,54 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createStream, getStream, type Stream, updateStream } from "src/api/backend";
const fetchStream = (id: number | "new") => {
if (id === "new") {
return Promise.resolve({
id: 0,
createdOn: "",
modifiedOn: "",
ownerUserId: 0,
tcpForwarding: true,
udpForwarding: false,
meta: {},
enabled: true,
certificateId: 0,
} as Stream);
}
return getStream(id, ["owner"]);
};
const useStream = (id: number | "new", options = {}) => {
return useQuery<Stream, Error>({
queryKey: ["stream", id],
queryFn: () => fetchStream(id),
staleTime: 60 * 1000, // 1 minute
...options,
});
};
const useSetStream = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: Stream) => (values.id ? updateStream(values) : createStream(values)),
onMutate: (values: Stream) => {
if (!values.id) {
return;
}
const previousObject = queryClient.getQueryData(["stream", values.id]);
queryClient.setQueryData(["stream", values.id], (old: Stream) => ({
...old,
...values,
}));
return () => queryClient.setQueryData(["stream", values.id], previousObject);
},
onError: (_, __, rollback: any) => rollback(),
onSuccess: async ({ id }: Stream) => {
queryClient.invalidateQueries({ queryKey: ["stream", id] });
queryClient.invalidateQueries({ queryKey: ["streams"] });
queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
},
});
};
export { useStream, useSetStream };

View File

@@ -46,7 +46,6 @@ const useSetUser = () => {
onSuccess: async ({ id }: User) => { onSuccess: async ({ id }: User) => {
queryClient.invalidateQueries({ queryKey: ["user", id] }); queryClient.invalidateQueries({ queryKey: ["user", id] });
queryClient.invalidateQueries({ queryKey: ["users"] }); queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["audit-logs"] });
}, },
}); });
}; };

View File

@@ -61,10 +61,4 @@ const changeLocale = (locale: string): void => {
document.documentElement.lang = locale; document.documentElement.lang = locale;
}; };
// This is a translation component that wraps the translation in a span with a data export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl };
// attribute so devs can inspect the element to see the translation ID
const T = ({ id, data }: { id: string; data?: any }) => {
return <span data-translation-id={id}>{intl.formatMessage({ id }, data)}</span>;
};
export { localeOptions, getFlagCodeForLocale, getLocale, createIntl, changeLocale, intl, T };

View File

@@ -3,13 +3,9 @@
"access.actions-title": "Access List #{id}", "access.actions-title": "Access List #{id}",
"access.add": "Add Access List", "access.add": "Add Access List",
"access.auth-count": "{count} Users", "access.auth-count": "{count} Users",
"access.edit": "Edit Access",
"access.empty": "There are no Access Lists", "access.empty": "There are no Access Lists",
"access.new": "New Access", "access.satisfy-all": "All",
"access.pass-auth": "Pass Auth to Upstream", "access.satisfy-any": "Any",
"access.public": "Publicly Accessible",
"access.satisfy-any": "Satisfy Any",
"access.subtitle": "{users} User, {rules} Rules - Created: {date}",
"access.title": "Access", "access.title": "Access",
"action.delete": "Delete", "action.delete": "Delete",
"action.disable": "Disable", "action.disable": "Disable",
@@ -27,8 +23,6 @@
"close": "Close", "close": "Close",
"column.access": "Access", "column.access": "Access",
"column.authorization": "Authorization", "column.authorization": "Authorization",
"column.authorizations": "Authorizations",
"column.custom-locations": "Custom Locations",
"column.destination": "Destination", "column.destination": "Destination",
"column.details": "Details", "column.details": "Details",
"column.email": "Email", "column.email": "Email",
@@ -40,18 +34,13 @@
"column.protocol": "Protocol", "column.protocol": "Protocol",
"column.provider": "Provider", "column.provider": "Provider",
"column.roles": "Roles", "column.roles": "Roles",
"column.rules": "Rules",
"column.satisfy": "Satisfy", "column.satisfy": "Satisfy",
"column.satisfy-all": "All",
"column.satisfy-any": "Any",
"column.scheme": "Scheme", "column.scheme": "Scheme",
"column.source": "Source", "column.source": "Source",
"column.ssl": "SSL", "column.ssl": "SSL",
"column.status": "Status", "column.status": "Status",
"created-on": "Created: {date}", "created-on": "Created: {date}",
"dashboard.title": "Dashboard", "dashboard.title": "Dashboard",
"dead-host.delete.content": "Are you sure you want to delete this 404 host?",
"dead-host.delete.title": "Delete 404 Host",
"dead-host.edit": "Edit 404 Host", "dead-host.edit": "Edit 404 Host",
"dead-host.new": "New 404 Host", "dead-host.new": "New 404 Host",
"dead-hosts.actions-title": "404 Host #{id}", "dead-hosts.actions-title": "404 Host #{id}",
@@ -71,39 +60,17 @@
"domains.http2-support": "HTTP/2 Support", "domains.http2-support": "HTTP/2 Support",
"domains.use-dns": "Use DNS Challenge", "domains.use-dns": "Use DNS Challenge",
"email-address": "Email address", "email-address": "Email address",
"empty-search": "No results found",
"empty-subtitle": "Why don't you create one?", "empty-subtitle": "Why don't you create one?",
"enabled": "Enabled",
"error.invalid-auth": "Invalid email or password", "error.invalid-auth": "Invalid email or password",
"error.invalid-domain": "Invalid domain: {domain}", "error.invalid-domain": "Invalid domain: {domain}",
"error.invalid-email": "Invalid email address", "error.invalid-email": "Invalid email address",
"error.max-domains": "Too many domains, max is {max}", "error.max-domains": "Too many domains, max is {max}",
"error.passwords-must-match": "Passwords must match", "error.passwords-must-match": "Passwords must match",
"error.required": "This is required", "error.required": "This is required",
"event.created-dead-host": "Created 404 Host",
"event.created-redirection-host": "Created Redirection Host",
"event.created-stream": "Created Stream",
"event.created-user": "Created User", "event.created-user": "Created User",
"event.deleted-dead-host": "Deleted 404 Host",
"event.deleted-stream": "Deleted Stream",
"event.deleted-user": "Deleted User", "event.deleted-user": "Deleted User",
"event.disabled-dead-host": "Disabled 404 Host",
"event.disabled-redirection-host": "Disabled Redirection Host",
"event.disabled-stream": "Disabled Stream",
"event.enabled-dead-host": "Enabled 404 Host",
"event.enabled-redirection-host": "Enabled Redirection Host",
"event.enabled-stream": "Enabled Stream",
"event.updated-redirection-host": "Updated Redirection Host",
"event.updated-user": "Updated User", "event.updated-user": "Updated User",
"footer.github-fork": "Fork me on Github", "footer.github-fork": "Fork me on Github",
"generic.flags.title": "Options",
"host.flags.block-exploits": "Block Common Exploits",
"host.flags.cache-assets": "Cache Assets",
"host.flags.preserve-path": "Preserve Path",
"host.flags.protocols": "Protocols",
"host.flags.websockets-upgrade": "Websockets Support",
"host.forward-port": "Forward Port",
"host.forward-scheme": "Scheme",
"hosts.title": "Hosts", "hosts.title": "Hosts",
"http-only": "HTTP Only", "http-only": "HTTP Only",
"lets-encrypt": "Let's Encrypt", "lets-encrypt": "Let's Encrypt",
@@ -115,20 +82,10 @@
"notfound.action": "Take me home", "notfound.action": "Take me home",
"notfound.text": "We are sorry but the page you are looking for was not found", "notfound.text": "We are sorry but the page you are looking for was not found",
"notfound.title": "Oops… You just found an error page", "notfound.title": "Oops… You just found an error page",
"notification.access-saved": "Access has been saved",
"notification.dead-host-saved": "404 Host has been saved", "notification.dead-host-saved": "404 Host has been saved",
"notification.error": "Error", "notification.error": "Error",
"notification.host-deleted": "Host has been deleted",
"notification.host-disabled": "Host has been disabled",
"notification.host-enabled": "Host has been enabled",
"notification.redirection-host-saved": "Redirection Host has been saved",
"notification.stream-deleted": "Stream has been deleted",
"notification.stream-disabled": "Stream has been disabled",
"notification.stream-enabled": "Stream has been enabled",
"notification.success": "Success", "notification.success": "Success",
"notification.user-deleted": "User has been deleted", "notification.user-deleted": "User has been deleted",
"notification.user-disabled": "User has been disabled",
"notification.user-enabled": "User has been enabled",
"notification.user-saved": "User has been saved", "notification.user-saved": "User has been saved",
"offline": "Offline", "offline": "Offline",
"online": "Online", "online": "Online",
@@ -142,17 +99,11 @@
"permissions.visibility.all": "All Items", "permissions.visibility.all": "All Items",
"permissions.visibility.title": "Item Visibility", "permissions.visibility.title": "Item Visibility",
"permissions.visibility.user": "Created Items Only", "permissions.visibility.user": "Created Items Only",
"proxy-host.forward-host": "Forward Hostname / IP",
"proxy-host.new": "New Proxy Host",
"proxy-hosts.actions-title": "Proxy Host #{id}", "proxy-hosts.actions-title": "Proxy Host #{id}",
"proxy-hosts.add": "Add Proxy Host", "proxy-hosts.add": "Add Proxy Host",
"proxy-hosts.count": "{count} Proxy Hosts", "proxy-hosts.count": "{count} Proxy Hosts",
"proxy-hosts.empty": "There are no Proxy Hosts", "proxy-hosts.empty": "There are no Proxy Hosts",
"proxy-hosts.title": "Proxy Hosts", "proxy-hosts.title": "Proxy Hosts",
"redirection-host.delete.content": "Are you sure you want to delete this Redirection host?",
"redirection-host.delete.title": "Delete Redirection Host",
"redirection-host.forward-domain": "Forward Domain",
"redirection-host.new": "New Redirection Host",
"redirection-hosts.actions-title": "Redirection Host #{id}", "redirection-hosts.actions-title": "Redirection Host #{id}",
"redirection-hosts.add": "Add Redirection Host", "redirection-hosts.add": "Add Redirection Host",
"redirection-hosts.count": "{count} Redirection Hosts", "redirection-hosts.count": "{count} Redirection Hosts",
@@ -166,12 +117,6 @@
"setup.title": "Welcome!", "setup.title": "Welcome!",
"sign-in": "Sign in", "sign-in": "Sign in",
"ssl-certificate": "SSL Certificate", "ssl-certificate": "SSL Certificate",
"stream.delete.content": "Are you sure you want to delete this Stream?",
"stream.delete.title": "Delete Stream",
"stream.edit": "Edit Stream",
"stream.forward-host": "Forward Host",
"stream.incoming-port": "Incoming Port",
"stream.new": "New Stream",
"streams.actions-title": "Stream #{id}", "streams.actions-title": "Stream #{id}",
"streams.add": "Add Stream", "streams.add": "Add Stream",
"streams.count": "{count} Streams", "streams.count": "{count} Streams",
@@ -196,9 +141,7 @@
"user.set-permissions": "Set Permissions for {name}", "user.set-permissions": "Set Permissions for {name}",
"user.switch-dark": "Switch to Dark mode", "user.switch-dark": "Switch to Dark mode",
"user.switch-light": "Switch to Light mode", "user.switch-light": "Switch to Light mode",
"username": "Username",
"users.actions-title": "User #{id}", "users.actions-title": "User #{id}",
"users.add": "Add User", "users.add": "Add User",
"users.empty": "There are no Users",
"users.title": "Users" "users.title": "Users"
} }

View File

@@ -1,36 +0,0 @@
#!/bin/bash
set -e -o pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$DIR/../src" || exit 1
if ! command -v jq &> /dev/null; then
echo "jq could not be found, please install it to sort JSON files."
exit 1
fi
# iterate over all json files in the current directory
for file in *.json; do
if [[ -f "$file" ]]; then
if [[ ! -s "$file" ]]; then
echo "Skipping empty file $file"
continue
fi
if [ "$file" == "lang-list.json" ]; then
continue
fi
# get content of file before sorting
original_content=$(<"$file")
# compare with sorted content
sorted_content=$(jq --tab --sort-keys . "$file")
if [ "$original_content" == "$sorted_content" ]; then
echo "$file is already sorted"
continue
fi
echo "Sorting $file"
jq --tab --sort-keys . "$file" | sponge "$file"
fi
done

View File

@@ -1,36 +1,24 @@
{ {
"access.access-count": {
"defaultMessage": "{count} Rules"
},
"access.actions-title": { "access.actions-title": {
"defaultMessage": "Access List #{id}" "defaultMessage": "Access List #{id}"
}, },
"access.access-count": {
"defaultMessage": "{count} Rules"
},
"access.add": { "access.add": {
"defaultMessage": "Add Access List" "defaultMessage": "Add Access List"
}, },
"access.auth-count": { "access.auth-count": {
"defaultMessage": "{count} Users" "defaultMessage": "{count} Users"
}, },
"access.edit": {
"defaultMessage": "Edit Access"
},
"access.empty": { "access.empty": {
"defaultMessage": "There are no Access Lists" "defaultMessage": "There are no Access Lists"
}, },
"access.new": { "access.satisfy-all": {
"defaultMessage": "New Access" "defaultMessage": "All"
},
"access.pass-auth": {
"defaultMessage": "Pass Auth to Upstream"
},
"access.public": {
"defaultMessage": "Publicly Accessible"
}, },
"access.satisfy-any": { "access.satisfy-any": {
"defaultMessage": "Satisfy Any" "defaultMessage": "Any"
},
"access.subtitle": {
"defaultMessage": "{users} User, {rules} Rules - Created: {date}"
}, },
"access.title": { "access.title": {
"defaultMessage": "Access" "defaultMessage": "Access"
@@ -41,21 +29,21 @@
"action.disable": { "action.disable": {
"defaultMessage": "Disable" "defaultMessage": "Disable"
}, },
"action.edit": {
"defaultMessage": "Edit"
},
"action.enable": { "action.enable": {
"defaultMessage": "Enable" "defaultMessage": "Enable"
}, },
"action.edit": {
"defaultMessage": "Edit"
},
"action.permissions": { "action.permissions": {
"defaultMessage": "Permissions" "defaultMessage": "Permissions"
}, },
"action.view-details": {
"defaultMessage": "View Details"
},
"auditlog.title": { "auditlog.title": {
"defaultMessage": "Audit Log" "defaultMessage": "Audit Log"
}, },
"action.view-details": {
"defaultMessage": "View Details"
},
"cancel": { "cancel": {
"defaultMessage": "Cancel" "defaultMessage": "Cancel"
}, },
@@ -77,18 +65,15 @@
"close": { "close": {
"defaultMessage": "Close" "defaultMessage": "Close"
}, },
"created-on": {
"defaultMessage": "Created: {date}"
},
"column.access": { "column.access": {
"defaultMessage": "Access" "defaultMessage": "Access"
}, },
"column.authorization": { "column.authorization": {
"defaultMessage": "Authorization" "defaultMessage": "Authorization"
}, },
"column.authorizations": {
"defaultMessage": "Authorizations"
},
"column.custom-locations": {
"defaultMessage": "Custom Locations"
},
"column.destination": { "column.destination": {
"defaultMessage": "Destination" "defaultMessage": "Destination"
}, },
@@ -122,48 +107,24 @@
"column.roles": { "column.roles": {
"defaultMessage": "Roles" "defaultMessage": "Roles"
}, },
"column.rules": {
"defaultMessage": "Rules"
},
"column.satisfy": { "column.satisfy": {
"defaultMessage": "Satisfy" "defaultMessage": "Satisfy"
}, },
"column.satisfy-all": {
"defaultMessage": "All"
},
"column.satisfy-any": {
"defaultMessage": "Any"
},
"column.scheme": { "column.scheme": {
"defaultMessage": "Scheme" "defaultMessage": "Scheme"
}, },
"column.source": { "column.status": {
"defaultMessage": "Source" "defaultMessage": "Status"
}, },
"column.ssl": { "column.ssl": {
"defaultMessage": "SSL" "defaultMessage": "SSL"
}, },
"column.status": { "column.source": {
"defaultMessage": "Status" "defaultMessage": "Source"
},
"created-on": {
"defaultMessage": "Created: {date}"
}, },
"dashboard.title": { "dashboard.title": {
"defaultMessage": "Dashboard" "defaultMessage": "Dashboard"
}, },
"dead-host.delete.content": {
"defaultMessage": "Are you sure you want to delete this 404 host?"
},
"dead-host.delete.title": {
"defaultMessage": "Delete 404 Host"
},
"dead-host.edit": {
"defaultMessage": "Edit 404 Host"
},
"dead-host.new": {
"defaultMessage": "New 404 Host"
},
"dead-hosts.actions-title": { "dead-hosts.actions-title": {
"defaultMessage": "404 Host #{id}" "defaultMessage": "404 Host #{id}"
}, },
@@ -173,9 +134,15 @@
"dead-hosts.count": { "dead-hosts.count": {
"defaultMessage": "{count} 404 Hosts" "defaultMessage": "{count} 404 Hosts"
}, },
"dead-host.edit": {
"defaultMessage": "Edit 404 Host"
},
"dead-hosts.empty": { "dead-hosts.empty": {
"defaultMessage": "There are no 404 Hosts" "defaultMessage": "There are no 404 Hosts"
}, },
"dead-host.new": {
"defaultMessage": "New 404 Host"
},
"dead-hosts.title": { "dead-hosts.title": {
"defaultMessage": "404 Hosts" "defaultMessage": "404 Hosts"
}, },
@@ -215,14 +182,8 @@
"email-address": { "email-address": {
"defaultMessage": "Email address" "defaultMessage": "Email address"
}, },
"empty-search": { "error.passwords-must-match": {
"defaultMessage": "No results found" "defaultMessage": "Passwords must match"
},
"empty-subtitle": {
"defaultMessage": "Why don't you create one?"
},
"enabled": {
"defaultMessage": "Enabled"
}, },
"error.invalid-auth": { "error.invalid-auth": {
"defaultMessage": "Invalid email or password" "defaultMessage": "Invalid email or password"
@@ -236,83 +197,23 @@
"error.max-domains": { "error.max-domains": {
"defaultMessage": "Too many domains, max is {max}" "defaultMessage": "Too many domains, max is {max}"
}, },
"error.passwords-must-match": {
"defaultMessage": "Passwords must match"
},
"error.required": { "error.required": {
"defaultMessage": "This is required" "defaultMessage": "This is required"
}, },
"event.created-dead-host": {
"defaultMessage": "Created 404 Host"
},
"event.created-redirection-host": {
"defaultMessage": "Created Redirection Host"
},
"event.created-stream": {
"defaultMessage": "Created Stream"
},
"event.created-user": { "event.created-user": {
"defaultMessage": "Created User" "defaultMessage": "Created User"
}, },
"event.deleted-dead-host": {
"defaultMessage": "Deleted 404 Host"
},
"event.deleted-stream": {
"defaultMessage": "Deleted Stream"
},
"event.deleted-user": { "event.deleted-user": {
"defaultMessage": "Deleted User" "defaultMessage": "Deleted User"
}, },
"event.disabled-dead-host": {
"defaultMessage": "Disabled 404 Host"
},
"event.disabled-redirection-host": {
"defaultMessage": "Disabled Redirection Host"
},
"event.disabled-stream": {
"defaultMessage": "Disabled Stream"
},
"event.enabled-dead-host": {
"defaultMessage": "Enabled 404 Host"
},
"event.enabled-redirection-host": {
"defaultMessage": "Enabled Redirection Host"
},
"event.enabled-stream": {
"defaultMessage": "Enabled Stream"
},
"event.updated-redirection-host": {
"defaultMessage": "Updated Redirection Host"
},
"event.updated-user": { "event.updated-user": {
"defaultMessage": "Updated User" "defaultMessage": "Updated User"
}, },
"footer.github-fork": { "footer.github-fork": {
"defaultMessage": "Fork me on Github" "defaultMessage": "Fork me on Github"
}, },
"generic.flags.title": { "empty-subtitle": {
"defaultMessage": "Options" "defaultMessage": "Why don't you create one?"
},
"host.flags.block-exploits": {
"defaultMessage": "Block Common Exploits"
},
"host.flags.cache-assets": {
"defaultMessage": "Cache Assets"
},
"host.flags.preserve-path": {
"defaultMessage": "Preserve Path"
},
"host.flags.protocols": {
"defaultMessage": "Protocols"
},
"host.flags.websockets-upgrade": {
"defaultMessage": "Websockets Support"
},
"host.forward-port": {
"defaultMessage": "Forward Port"
},
"host.forward-scheme": {
"defaultMessage": "Scheme"
}, },
"hosts.title": { "hosts.title": {
"defaultMessage": "Hosts" "defaultMessage": "Hosts"
@@ -347,51 +248,21 @@
"notfound.title": { "notfound.title": {
"defaultMessage": "Oops… You just found an error page" "defaultMessage": "Oops… You just found an error page"
}, },
"notification.access-saved": {
"defaultMessage": "Access has been saved"
},
"notification.dead-host-saved": { "notification.dead-host-saved": {
"defaultMessage": "404 Host has been saved" "defaultMessage": "404 Host has been saved"
}, },
"notification.error": { "notification.error": {
"defaultMessage": "Error" "defaultMessage": "Error"
}, },
"notification.host-deleted": {
"defaultMessage": "Host has been deleted"
},
"notification.host-disabled": {
"defaultMessage": "Host has been disabled"
},
"notification.host-enabled": {
"defaultMessage": "Host has been enabled"
},
"notification.redirection-host-saved": {
"defaultMessage": "Redirection Host has been saved"
},
"notification.stream-deleted": {
"defaultMessage": "Stream has been deleted"
},
"notification.stream-disabled": {
"defaultMessage": "Stream has been disabled"
},
"notification.stream-enabled": {
"defaultMessage": "Stream has been enabled"
},
"notification.success": {
"defaultMessage": "Success"
},
"notification.user-deleted": { "notification.user-deleted": {
"defaultMessage": "User has been deleted" "defaultMessage": "User has been deleted"
}, },
"notification.user-disabled": {
"defaultMessage": "User has been disabled"
},
"notification.user-enabled": {
"defaultMessage": "User has been enabled"
},
"notification.user-saved": { "notification.user-saved": {
"defaultMessage": "User has been saved" "defaultMessage": "User has been saved"
}, },
"notification.success": {
"defaultMessage": "Success"
},
"offline": { "offline": {
"defaultMessage": "Offline" "defaultMessage": "Offline"
}, },
@@ -428,12 +299,6 @@
"permissions.visibility.user": { "permissions.visibility.user": {
"defaultMessage": "Created Items Only" "defaultMessage": "Created Items Only"
}, },
"proxy-host.forward-host": {
"defaultMessage": "Forward Hostname / IP"
},
"proxy-host.new": {
"defaultMessage": "New Proxy Host"
},
"proxy-hosts.actions-title": { "proxy-hosts.actions-title": {
"defaultMessage": "Proxy Host #{id}" "defaultMessage": "Proxy Host #{id}"
}, },
@@ -449,18 +314,6 @@
"proxy-hosts.title": { "proxy-hosts.title": {
"defaultMessage": "Proxy Hosts" "defaultMessage": "Proxy Hosts"
}, },
"redirection-host.delete.content": {
"defaultMessage": "Are you sure you want to delete this Redirection host?"
},
"redirection-host.delete.title": {
"defaultMessage": "Delete Redirection Host"
},
"redirection-host.forward-domain": {
"defaultMessage": "Forward Domain"
},
"redirection-host.new": {
"defaultMessage": "New Redirection Host"
},
"redirection-hosts.actions-title": { "redirection-hosts.actions-title": {
"defaultMessage": "Redirection Host #{id}" "defaultMessage": "Redirection Host #{id}"
}, },
@@ -500,24 +353,6 @@
"ssl-certificate": { "ssl-certificate": {
"defaultMessage": "SSL Certificate" "defaultMessage": "SSL Certificate"
}, },
"stream.delete.content": {
"defaultMessage": "Are you sure you want to delete this Stream?"
},
"stream.delete.title": {
"defaultMessage": "Delete Stream"
},
"stream.edit": {
"defaultMessage": "Edit Stream"
},
"stream.forward-host": {
"defaultMessage": "Forward Host"
},
"stream.incoming-port": {
"defaultMessage": "Incoming Port"
},
"stream.new": {
"defaultMessage": "New Stream"
},
"streams.actions-title": { "streams.actions-title": {
"defaultMessage": "Stream #{id}" "defaultMessage": "Stream #{id}"
}, },
@@ -548,12 +383,12 @@
"user.current-password": { "user.current-password": {
"defaultMessage": "Current Password" "defaultMessage": "Current Password"
}, },
"user.delete.content": {
"defaultMessage": "Are you sure you want to delete this user?"
},
"user.delete.title": { "user.delete.title": {
"defaultMessage": "Delete User" "defaultMessage": "Delete User"
}, },
"user.delete.content": {
"defaultMessage": "Are you sure you want to delete this user?"
},
"user.edit": { "user.edit": {
"defaultMessage": "Edit User" "defaultMessage": "Edit User"
}, },
@@ -590,18 +425,12 @@
"user.switch-light": { "user.switch-light": {
"defaultMessage": "Switch to Light mode" "defaultMessage": "Switch to Light mode"
}, },
"username": {
"defaultMessage": "Username"
},
"users.actions-title": { "users.actions-title": {
"defaultMessage": "User #{id}" "defaultMessage": "User #{id}"
}, },
"users.add": { "users.add": {
"defaultMessage": "Add User" "defaultMessage": "Add User"
}, },
"users.empty": {
"defaultMessage": "There are no Users"
},
"users.title": { "users.title": {
"defaultMessage": "Users" "defaultMessage": "Users"
} }

View File

@@ -1,243 +0,0 @@
import cn from "classnames";
import { Field, Form, Formik } from "formik";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { BasicAuthField, Button, Loading } from "src/components";
import { useAccessList, useSetAccessList } from "src/hooks";
import { intl, T } from "src/locale";
import { validateString } from "src/modules/Validations";
import { showSuccess } from "src/notifications";
interface Props {
id: number | "new";
onClose: () => void;
}
export function AccessListModal({ id, onClose }: Props) {
const { data, isLoading, error } = useAccessList(id);
const { mutate: setAccessList } = useSetAccessList();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
setErrorMsg(null);
const { ...payload } = {
id: id === "new" ? undefined : id,
...values,
};
setAccessList(payload, {
onError: (err: any) => setErrorMsg(<T id={err.message} />),
onSuccess: () => {
showSuccess(intl.formatMessage({ id: "notification.access-saved" }));
onClose();
},
onSettled: () => {
setIsSubmitting(false);
setSubmitting(false);
},
});
};
const toggleClasses = "form-check-input";
const toggleEnabled = cn(toggleClasses, "bg-cyan");
return (
<Modal show onHide={onClose} animation={false}>
{!isLoading && error && (
<Alert variant="danger" className="m-3">
{error?.message || "Unknown error"}
</Alert>
)}
{isLoading && <Loading noLogo />}
{!isLoading && data && (
<Formik
initialValues={
{
name: data?.name,
satisfyAny: data?.satisfyAny,
passAuth: data?.passAuth,
// todo: more? there's stuff missing here?
meta: data?.meta || {},
} as any
}
onSubmit={onSubmit}
>
{({ setFieldValue }: any) => (
<Form>
<Modal.Header closeButton>
<Modal.Title>
<T id={data?.id ? "access.edit" : "access.new"} />
</Modal.Title>
</Modal.Header>
<Modal.Body className="p-0">
<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible>
{errorMsg}
</Alert>
<div className="card m-0 border-0">
<div className="card-header">
<ul className="nav nav-tabs card-header-tabs" data-bs-toggle="tabs">
<li className="nav-item" role="presentation">
<a
href="#tab-details"
className="nav-link active"
data-bs-toggle="tab"
aria-selected="true"
role="tab"
>
<T id="column.details" />
</a>
</li>
<li className="nav-item" role="presentation">
<a
href="#tab-auth"
className="nav-link"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<T id="column.authorizations" />
</a>
</li>
<li className="nav-item" role="presentation">
<a
href="#tab-access"
className="nav-link"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<T id="column.rules" />
</a>
</li>
</ul>
</div>
<div className="card-body">
<div className="tab-content">
<div className="tab-pane active show" id="tab-details" role="tabpanel">
<Field name="name" validate={validateString(8, 255)}>
{({ field }: any) => (
<div>
<label htmlFor="name" className="form-label">
<T id="column.name" />
</label>
<input
id="name"
type="text"
required
autoComplete="off"
className="form-control"
{...field}
/>
</div>
)}
</Field>
<div className="my-3">
<h3 className="py-2">
<T id="generic.flags.title" />
</h3>
<div className="divide-y">
<div>
<label className="row" htmlFor="satisfyAny">
<span className="col">
<T id="access.satisfy-any" />
</span>
<span className="col-auto">
<Field name="satisfyAny" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
id="satisfyAny"
className={
field.value
? toggleEnabled
: toggleClasses
}
type="checkbox"
name={field.name}
checked={field.value}
onChange={(e: any) => {
setFieldValue(
field.name,
e.target.checked,
);
}}
/>
</label>
)}
</Field>
</span>
</label>
</div>
<div>
<label className="row" htmlFor="passAuth">
<span className="col">
<T id="access.pass-auth" />
</span>
<span className="col-auto">
<Field name="passAuth" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
id="passAuth"
className={
field.value
? toggleEnabled
: toggleClasses
}
type="checkbox"
name={field.name}
checked={field.value}
onChange={(e: any) => {
setFieldValue(
field.name,
e.target.checked,
);
}}
/>
</label>
)}
</Field>
</span>
</label>
</div>
</div>
</div>
</div>
<div className="tab-pane" id="tab-auth" role="tabpanel">
<BasicAuthField />
</div>
<div className="tab-pane" id="tab-rules" role="tabpanel">
todo
</div>
</div>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
<T id="cancel" />
</Button>
<Button
type="submit"
actionType="primary"
className="ms-auto bg-cyan"
data-bs-dismiss="modal"
isLoading={isSubmitting}
disabled={isSubmitting}
>
<T id="save" />
</Button>
</Modal.Footer>
</Form>
)}
</Formik>
)}
</Modal>
);
}

View File

@@ -1,10 +1,10 @@
import { Field, Form, Formik } from "formik"; import { Field, Form, Formik } from "formik";
import { type ReactNode, useState } from "react"; import { useState } from "react";
import { Alert } from "react-bootstrap"; import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal"; import Modal from "react-bootstrap/Modal";
import { updateAuth } from "src/api/backend"; import { updateAuth } from "src/api/backend";
import { Button } from "src/components"; import { Button } from "src/components";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { validateString } from "src/modules/Validations"; import { validateString } from "src/modules/Validations";
interface Props { interface Props {
@@ -12,12 +12,12 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
export function ChangePasswordModal({ userId, onClose }: Props) { export function ChangePasswordModal({ userId, onClose }: Props) {
const [error, setError] = useState<ReactNode | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => { const onSubmit = async (values: any, { setSubmitting }: any) => {
if (values.new !== values.confirm) { if (values.new !== values.confirm) {
setError(<T id="error.passwords-must-match" />); setError(intl.formatMessage({ id: "error.passwords-must-match" }));
setSubmitting(false); setSubmitting(false);
return; return;
} }
@@ -30,7 +30,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
await updateAuth(userId, values.new, values.current); await updateAuth(userId, values.new, values.current);
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
setError(<T id={err.message} />); setError(intl.formatMessage({ id: err.message }));
} }
setIsSubmitting(false); setIsSubmitting(false);
setSubmitting(false); setSubmitting(false);
@@ -51,9 +51,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
{() => ( {() => (
<Form> <Form>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title> <Modal.Title>{intl.formatMessage({ id: "user.change-password" })}</Modal.Title>
<T id="user.change-password" />
</Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible> <Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
@@ -74,7 +72,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
{...field} {...field}
/> />
<label htmlFor="current"> <label htmlFor="current">
<T id="user.current-password" /> {intl.formatMessage({ id: "user.current-password" })}
</label> </label>
{form.errors.name ? ( {form.errors.name ? (
<div className="invalid-feedback"> <div className="invalid-feedback">
@@ -100,7 +98,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
{...field} {...field}
/> />
<label htmlFor="new"> <label htmlFor="new">
<T id="user.new-password" /> {intl.formatMessage({ id: "user.new-password" })}
</label> </label>
{form.errors.new ? ( {form.errors.new ? (
<div className="invalid-feedback"> <div className="invalid-feedback">
@@ -131,7 +129,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
</div> </div>
) : null} ) : null}
<label htmlFor="confirm"> <label htmlFor="confirm">
<T id="user.confirm-password" /> {intl.formatMessage({ id: "user.confirm-password" })}
</label> </label>
</div> </div>
)} )}
@@ -140,7 +138,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}> <Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
<T id="cancel" /> {intl.formatMessage({ id: "cancel" })}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@@ -150,7 +148,7 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
isLoading={isSubmitting} isLoading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}
> >
<T id="save" /> {intl.formatMessage({ id: "save" })}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Form> </Form>

View File

@@ -1,6 +1,6 @@
import { IconSettings } from "@tabler/icons-react"; import { IconSettings } from "@tabler/icons-react";
import { Form, Formik } from "formik"; import { Form, Formik } from "formik";
import { type ReactNode, useState } from "react"; import { useState } from "react";
import { Alert } from "react-bootstrap"; import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal"; import Modal from "react-bootstrap/Modal";
import { import {
@@ -12,7 +12,7 @@ import {
SSLOptionsFields, SSLOptionsFields,
} from "src/components"; } from "src/components";
import { useDeadHost, useSetDeadHost } from "src/hooks"; import { useDeadHost, useSetDeadHost } from "src/hooks";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { showSuccess } from "src/notifications"; import { showSuccess } from "src/notifications";
interface Props { interface Props {
@@ -22,7 +22,7 @@ interface Props {
export function DeadHostModal({ id, onClose }: Props) { export function DeadHostModal({ id, onClose }: Props) {
const { data, isLoading, error } = useDeadHost(id); const { data, isLoading, error } = useDeadHost(id);
const { mutate: setDeadHost } = useSetDeadHost(); const { mutate: setDeadHost } = useSetDeadHost();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null); const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => { const onSubmit = async (values: any, { setSubmitting }: any) => {
@@ -36,7 +36,7 @@ export function DeadHostModal({ id, onClose }: Props) {
}; };
setDeadHost(payload, { setDeadHost(payload, {
onError: (err: any) => setErrorMsg(<T id={err.message} />), onError: (err: any) => setErrorMsg(err.message),
onSuccess: () => { onSuccess: () => {
showSuccess(intl.formatMessage({ id: "notification.dead-host-saved" })); showSuccess(intl.formatMessage({ id: "notification.dead-host-saved" }));
onClose(); onClose();
@@ -76,13 +76,14 @@ export function DeadHostModal({ id, onClose }: Props) {
<Form> <Form>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title> <Modal.Title>
<T id={data?.id ? "dead-host.edit" : "dead-host.new"} /> {intl.formatMessage({ id: data?.id ? "dead-host.edit" : "dead-host.new" })}
</Modal.Title> </Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body className="p-0"> <Modal.Body className="p-0">
<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible> <Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible>
{errorMsg} {errorMsg}
</Alert> </Alert>
<div className="card m-0 border-0"> <div className="card m-0 border-0">
<div className="card-header"> <div className="card-header">
<ul className="nav nav-tabs card-header-tabs" data-bs-toggle="tabs"> <ul className="nav nav-tabs card-header-tabs" data-bs-toggle="tabs">
@@ -94,7 +95,7 @@ export function DeadHostModal({ id, onClose }: Props) {
aria-selected="true" aria-selected="true"
role="tab" role="tab"
> >
<T id="column.details" /> {intl.formatMessage({ id: "column.details" })}
</a> </a>
</li> </li>
<li className="nav-item" role="presentation"> <li className="nav-item" role="presentation">
@@ -106,7 +107,7 @@ export function DeadHostModal({ id, onClose }: Props) {
tabIndex={-1} tabIndex={-1}
role="tab" role="tab"
> >
<T id="column.ssl" /> {intl.formatMessage({ id: "column.ssl" })}
</a> </a>
</li> </li>
<li className="nav-item ms-auto" role="presentation"> <li className="nav-item ms-auto" role="presentation">
@@ -135,7 +136,7 @@ export function DeadHostModal({ id, onClose }: Props) {
label="ssl-certificate" label="ssl-certificate"
allowNew allowNew
/> />
<SSLOptionsFields color="bg-red" /> <SSLOptionsFields />
</div> </div>
<div className="tab-pane" id="tab-advanced" role="tabpanel"> <div className="tab-pane" id="tab-advanced" role="tabpanel">
<NginxConfigField /> <NginxConfigField />
@@ -146,17 +147,17 @@ export function DeadHostModal({ id, onClose }: Props) {
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}> <Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
<T id="cancel" /> {intl.formatMessage({ id: "cancel" })}
</Button> </Button>
<Button <Button
type="submit" type="submit"
actionType="primary" actionType="primary"
className="ms-auto bg-red" className="ms-auto"
data-bs-dismiss="modal" data-bs-dismiss="modal"
isLoading={isSubmitting} isLoading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}
> >
<T id="save" /> {intl.formatMessage({ id: "save" })}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Form> </Form>

View File

@@ -3,7 +3,7 @@ import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap"; import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal"; import Modal from "react-bootstrap/Modal";
import { Button } from "src/components"; import { Button } from "src/components";
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
title: string; title: string;
@@ -14,7 +14,7 @@ interface Props {
} }
export function DeleteConfirmModal({ title, children, onConfirm, onClose, invalidations }: Props) { export function DeleteConfirmModal({ title, children, onConfirm, onClose, invalidations }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [error, setError] = useState<ReactNode | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async () => { const onSubmit = async () => {
@@ -29,7 +29,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
queryClient.invalidateQueries({ queryKey: inv }); queryClient.invalidateQueries({ queryKey: inv });
}); });
} catch (err: any) { } catch (err: any) {
setError(<T id={err.message} />); setError(intl.formatMessage({ id: err.message }));
} }
setIsSubmitting(false); setIsSubmitting(false);
}; };
@@ -37,9 +37,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
return ( return (
<Modal show onHide={onClose} animation={false}> <Modal show onHide={onClose} animation={false}>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title> <Modal.Title>{title}</Modal.Title>
<T id={title} />
</Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible> <Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
@@ -49,7 +47,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}> <Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
<T id="cancel" /> {intl.formatMessage({ id: "cancel" })}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@@ -60,7 +58,7 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
disabled={isSubmitting} disabled={isSubmitting}
onClick={onSubmit} onClick={onSubmit}
> >
<T id="action.delete" /> {intl.formatMessage({ id: "action.delete" })}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Modal> </Modal>

View File

@@ -2,7 +2,7 @@ import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal"; import Modal from "react-bootstrap/Modal";
import { Button, EventFormatter, GravatarFormatter, Loading } from "src/components"; import { Button, EventFormatter, GravatarFormatter, Loading } from "src/components";
import { useAuditLog } from "src/hooks"; import { useAuditLog } from "src/hooks";
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
id: number; id: number;
@@ -22,9 +22,7 @@ export function EventDetailsModal({ id, onClose }: Props) {
{!isLoading && data && ( {!isLoading && data && (
<> <>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title> <Modal.Title>{intl.formatMessage({ id: "action.view-details" })}</Modal.Title>
<T id="action.view-details" />
</Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<div className="row"> <div className="row">
@@ -42,7 +40,7 @@ export function EventDetailsModal({ id, onClose }: Props) {
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose}> <Button data-bs-dismiss="modal" onClick={onClose}>
<T id="close" /> {intl.formatMessage({ id: "close" })}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</> </>

View File

@@ -1,13 +1,13 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import cn from "classnames"; import cn from "classnames";
import { Field, Form, Formik } from "formik"; import { Field, Form, Formik } from "formik";
import { type ReactNode, useState } from "react"; import { useState } from "react";
import { Alert } from "react-bootstrap"; import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal"; import Modal from "react-bootstrap/Modal";
import { setPermissions } from "src/api/backend"; import { setPermissions } from "src/api/backend";
import { Button, Loading } from "src/components"; import { Button, Loading } from "src/components";
import { useUser } from "src/hooks"; import { useUser } from "src/hooks";
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
userId: number; userId: number;
@@ -15,7 +15,7 @@ interface Props {
} }
export function PermissionsModal({ userId, onClose }: Props) { export function PermissionsModal({ userId, onClose }: Props) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null); const [errorMsg, setErrorMsg] = useState<string | null>(null);
const { data, isLoading, error } = useUser(userId); const { data, isLoading, error } = useUser(userId);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
@@ -29,7 +29,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
queryClient.invalidateQueries({ queryKey: ["users"] }); queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user"] }); queryClient.invalidateQueries({ queryKey: ["user"] });
} catch (err: any) { } catch (err: any) {
setErrorMsg(<T id={err.message} />); setErrorMsg(intl.formatMessage({ id: err.message }));
} }
setSubmitting(false); setSubmitting(false);
setIsSubmitting(false); setIsSubmitting(false);
@@ -50,7 +50,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
onChange={() => form.setFieldValue(field.name, "manage")} onChange={() => form.setFieldValue(field.name, "manage")}
/> />
<label htmlFor={`${field.name}-manage`} className={cn("btn", { active: field.value === "manage" })}> <label htmlFor={`${field.name}-manage`} className={cn("btn", { active: field.value === "manage" })}>
<T id="permissions.manage" /> {intl.formatMessage({ id: "permissions.manage" })}
</label> </label>
<input <input
type="radio" type="radio"
@@ -63,7 +63,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
onChange={() => form.setFieldValue(field.name, "view")} onChange={() => form.setFieldValue(field.name, "view")}
/> />
<label htmlFor={`${field.name}-view`} className={cn("btn", { active: field.value === "view" })}> <label htmlFor={`${field.name}-view`} className={cn("btn", { active: field.value === "view" })}>
<T id="permissions.view" /> {intl.formatMessage({ id: "permissions.view" })}
</label> </label>
<input <input
type="radio" type="radio"
@@ -76,7 +76,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
onChange={() => form.setFieldValue(field.name, "hidden")} onChange={() => form.setFieldValue(field.name, "hidden")}
/> />
<label htmlFor={`${field.name}-hidden`} className={cn("btn", { active: field.value === "hidden" })}> <label htmlFor={`${field.name}-hidden`} className={cn("btn", { active: field.value === "hidden" })}>
<T id="permissions.hidden" /> {intl.formatMessage({ id: "permissions.hidden" })}
</label> </label>
</div> </div>
</div> </div>
@@ -112,7 +112,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
<Form> <Form>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title> <Modal.Title>
<T id="user.set-permissions" data={{ name: data?.name }} /> {intl.formatMessage({ id: "user.set-permissions" }, { name: data?.name })}
</Modal.Title> </Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
@@ -121,7 +121,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
</Alert> </Alert>
<div className="mb-3"> <div className="mb-3">
<label htmlFor="asd" className="form-label"> <label htmlFor="asd" className="form-label">
<T id="permissions.visibility.title" /> {intl.formatMessage({ id: "permissions.visibility.title" })}
</label> </label>
<Field name="visibility"> <Field name="visibility">
{({ field, form }: any) => ( {({ field, form }: any) => (
@@ -140,7 +140,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
htmlFor={`${field.name}-user`} htmlFor={`${field.name}-user`}
className={cn("btn", { active: field.value === "user" })} className={cn("btn", { active: field.value === "user" })}
> >
<T id="permissions.visibility.user" /> {intl.formatMessage({ id: "permissions.visibility.user" })}
</label> </label>
<input <input
type="radio" type="radio"
@@ -156,7 +156,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
htmlFor={`${field.name}-all`} htmlFor={`${field.name}-all`}
className={cn("btn", { active: field.value === "all" })} className={cn("btn", { active: field.value === "all" })}
> >
<T id="permissions.visibility.all" /> {intl.formatMessage({ id: "permissions.visibility.all" })}
</label> </label>
</div> </div>
)} )}
@@ -166,7 +166,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
<> <>
<div className="mb-3"> <div className="mb-3">
<label htmlFor="ignored" className="form-label"> <label htmlFor="ignored" className="form-label">
<T id="proxy-hosts.title" /> {intl.formatMessage({ id: "proxy-hosts.title" })}
</label> </label>
<Field name="proxyHosts"> <Field name="proxyHosts">
{({ field, form }: any) => getPermissionButtons(field, form)} {({ field, form }: any) => getPermissionButtons(field, form)}
@@ -174,7 +174,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
</div> </div>
<div className="mb-3"> <div className="mb-3">
<label htmlFor="ignored" className="form-label"> <label htmlFor="ignored" className="form-label">
<T id="redirection-hosts.title" /> {intl.formatMessage({ id: "redirection-hosts.title" })}
</label> </label>
<Field name="redirectionHosts"> <Field name="redirectionHosts">
{({ field, form }: any) => getPermissionButtons(field, form)} {({ field, form }: any) => getPermissionButtons(field, form)}
@@ -182,7 +182,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
</div> </div>
<div className="mb-3"> <div className="mb-3">
<label htmlFor="ignored" className="form-label"> <label htmlFor="ignored" className="form-label">
<T id="dead-hosts.title" /> {intl.formatMessage({ id: "dead-hosts.title" })}
</label> </label>
<Field name="deadHosts"> <Field name="deadHosts">
{({ field, form }: any) => getPermissionButtons(field, form)} {({ field, form }: any) => getPermissionButtons(field, form)}
@@ -190,7 +190,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
</div> </div>
<div className="mb-3"> <div className="mb-3">
<label htmlFor="ignored" className="form-label"> <label htmlFor="ignored" className="form-label">
<T id="streams.title" /> {intl.formatMessage({ id: "streams.title" })}
</label> </label>
<Field name="streams"> <Field name="streams">
{({ field, form }: any) => getPermissionButtons(field, form)} {({ field, form }: any) => getPermissionButtons(field, form)}
@@ -198,7 +198,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
</div> </div>
<div className="mb-3"> <div className="mb-3">
<label htmlFor="ignored" className="form-label"> <label htmlFor="ignored" className="form-label">
<T id="access.title" /> {intl.formatMessage({ id: "access.title" })}
</label> </label>
<Field name="accessLists"> <Field name="accessLists">
{({ field, form }: any) => getPermissionButtons(field, form)} {({ field, form }: any) => getPermissionButtons(field, form)}
@@ -206,7 +206,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
</div> </div>
<div className="mb-3"> <div className="mb-3">
<label htmlFor="ignored" className="form-label"> <label htmlFor="ignored" className="form-label">
<T id="certificates.title" /> {intl.formatMessage({ id: "certificates.title" })}
</label> </label>
<Field name="certificates"> <Field name="certificates">
{({ field, form }: any) => getPermissionButtons(field, form)} {({ field, form }: any) => getPermissionButtons(field, form)}
@@ -217,7 +217,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}> <Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
<T id="cancel" /> {intl.formatMessage({ id: "cancel" })}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@@ -227,7 +227,7 @@ export function PermissionsModal({ userId, onClose }: Props) {
isLoading={isSubmitting} isLoading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}
> >
<T id="save" /> {intl.formatMessage({ id: "save" })}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Form> </Form>

View File

@@ -1,364 +0,0 @@
import { IconSettings } from "@tabler/icons-react";
import cn from "classnames";
import { Field, Form, Formik } from "formik";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import {
AccessField,
Button,
DomainNamesField,
Loading,
NginxConfigField,
SSLCertificateField,
SSLOptionsFields,
} from "src/components";
import { useProxyHost, useSetProxyHost } from "src/hooks";
import { intl, T } from "src/locale";
import { validateNumber, validateString } from "src/modules/Validations";
import { showSuccess } from "src/notifications";
interface Props {
id: number | "new";
onClose: () => void;
}
export function ProxyHostModal({ id, onClose }: Props) {
const { data, isLoading, error } = useProxyHost(id);
const { mutate: setProxyHost } = useSetProxyHost();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
setErrorMsg(null);
const { ...payload } = {
id: id === "new" ? undefined : id,
...values,
};
setProxyHost(payload, {
onError: (err: any) => setErrorMsg(<T id={err.message} />),
onSuccess: () => {
showSuccess(intl.formatMessage({ id: "notification.proxy-host-saved" }));
onClose();
},
onSettled: () => {
setIsSubmitting(false);
setSubmitting(false);
},
});
};
return (
<Modal show onHide={onClose} animation={false}>
{!isLoading && error && (
<Alert variant="danger" className="m-3">
{error?.message || "Unknown error"}
</Alert>
)}
{isLoading && <Loading noLogo />}
{!isLoading && data && (
<Formik
initialValues={
{
// Details tab
domainNames: data?.domainNames || [],
forwardScheme: data?.forwardScheme || "http",
forwardHost: data?.forwardHost || "",
forwardPort: data?.forwardPort || undefined,
accessListId: data?.accessListId || 0,
cachingEnabled: data?.cachingEnabled || false,
blockExploits: data?.blockExploits || false,
allowWebsocketUpgrade: data?.allowWebsocketUpgrade || false,
// Locations tab
locations: data?.locations || [],
// SSL tab
certificateId: data?.certificateId || 0,
sslForced: data?.sslForced || false,
http2Support: data?.http2Support || false,
hstsEnabled: data?.hstsEnabled || false,
hstsSubdomains: data?.hstsSubdomains || false,
// Advanced tab
advancedConfig: data?.advancedConfig || "",
meta: data?.meta || {},
} as any
}
onSubmit={onSubmit}
>
{() => (
<Form>
<Modal.Header closeButton>
<Modal.Title>
<T id={data?.id ? "proxy-host.edit" : "proxy-host.new"} />
</Modal.Title>
</Modal.Header>
<Modal.Body className="p-0">
<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible>
{errorMsg}
</Alert>
<div className="card m-0 border-0">
<div className="card-header">
<ul className="nav nav-tabs card-header-tabs" data-bs-toggle="tabs">
<li className="nav-item" role="presentation">
<a
href="#tab-details"
className="nav-link active"
data-bs-toggle="tab"
aria-selected="true"
role="tab"
>
<T id="column.details" />
</a>
</li>
<li className="nav-item" role="presentation">
<a
href="#tab-locations"
className="nav-link"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<T id="column.custom-locations" />
</a>
</li>
<li className="nav-item" role="presentation">
<a
href="#tab-ssl"
className="nav-link"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<T id="column.ssl" />
</a>
</li>
<li className="nav-item ms-auto" role="presentation">
<a
href="#tab-advanced"
className="nav-link"
title="Settings"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<IconSettings size={20} />
</a>
</li>
</ul>
</div>
<div className="card-body">
<div className="tab-content">
<div className="tab-pane active show" id="tab-details" role="tabpanel">
<DomainNamesField isWildcardPermitted />
<div className="row">
<div className="col-md-3">
<Field name="forwardScheme">
{({ field, form }: any) => (
<div className="mb-3">
<label
className="form-label"
htmlFor="forwardScheme"
>
<T id="host.forward-scheme" />
</label>
<select
id="forwardScheme"
className={`form-control ${form.errors.forwardScheme && form.touched.forwardScheme ? "is-invalid" : ""}`}
required
{...field}
>
<option value="http">http</option>
<option value="https">https</option>
</select>
{form.errors.forwardScheme ? (
<div className="invalid-feedback">
{form.errors.forwardScheme &&
form.touched.forwardScheme
? form.errors.forwardScheme
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
<div className="col-md-6">
<Field name="forwardHost" validate={validateString(1, 255)}>
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor="forwardHost">
<T id="proxy-host.forward-host" />
</label>
<input
id="forwardHost"
type="text"
className={`form-control ${form.errors.forwardHost && form.touched.forwardHost ? "is-invalid" : ""}`}
required
placeholder="example.com"
{...field}
/>
{form.errors.forwardHost ? (
<div className="invalid-feedback">
{form.errors.forwardHost &&
form.touched.forwardHost
? form.errors.forwardHost
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
<div className="col-md-3">
<Field name="forwardPort" validate={validateNumber(1, 65535)}>
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor="forwardPort">
<T id="host.forward-port" />
</label>
<input
id="forwardPort"
type="number"
min={1}
max={65535}
className={`form-control ${form.errors.forwardPort && form.touched.forwardPort ? "is-invalid" : ""}`}
required
placeholder="eg: 8081"
{...field}
/>
{form.errors.forwardPort ? (
<div className="invalid-feedback">
{form.errors.forwardPort &&
form.touched.forwardPort
? form.errors.forwardPort
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
</div>
<AccessField />
<div className="my-3">
<h4 className="py-2">
<T id="generic.flags.title" />
</h4>
<div className="divide-y">
<div>
<label className="row" htmlFor="cachingEnabled">
<span className="col">
<T id="host.flags.cache-assets" />
</span>
<span className="col-auto">
<Field name="cachingEnabled" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="cachingEnabled"
className={cn("form-check-input", {
"bg-lime": field.checked,
})}
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
<div>
<label className="row" htmlFor="blockExploits">
<span className="col">
<T id="host.flags.block-exploits" />
</span>
<span className="col-auto">
<Field name="blockExploits" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="blockExploits"
className={cn("form-check-input", {
"bg-lime": field.checked,
})}
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
<div>
<label className="row" htmlFor="allowWebsocketUpgrade">
<span className="col">
<T id="host.flags.websockets-upgrade" />
</span>
<span className="col-auto">
<Field name="allowWebsocketUpgrade" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="allowWebsocketUpgrade"
className={cn("form-check-input", {
"bg-lime": field.checked,
})}
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
</div>
</div>
</div>
<div className="tab-pane" id="tab-locations" role="tabpanel">
locations TODO
</div>
<div className="tab-pane" id="tab-ssl" role="tabpanel">
<SSLCertificateField
name="certificateId"
label="ssl-certificate"
allowNew
/>
<SSLOptionsFields color="bg-lime" />
</div>
<div className="tab-pane" id="tab-advanced" role="tabpanel">
<NginxConfigField />
</div>
</div>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
<T id="cancel" />
</Button>
<Button
type="submit"
actionType="primary"
className="ms-auto bg-lime"
data-bs-dismiss="modal"
isLoading={isSubmitting}
disabled={isSubmitting}
>
<T id="save" />
</Button>
</Modal.Footer>
</Form>
)}
</Formik>
)}
</Modal>
);
}

View File

@@ -1,298 +0,0 @@
import { IconSettings } from "@tabler/icons-react";
import cn from "classnames";
import { Field, Form, Formik } from "formik";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import {
Button,
DomainNamesField,
Loading,
NginxConfigField,
SSLCertificateField,
SSLOptionsFields,
} from "src/components";
import { useRedirectionHost, useSetRedirectionHost } from "src/hooks";
import { intl, T } from "src/locale";
import { validateString } from "src/modules/Validations";
import { showSuccess } from "src/notifications";
interface Props {
id: number | "new";
onClose: () => void;
}
export function RedirectionHostModal({ id, onClose }: Props) {
const { data, isLoading, error } = useRedirectionHost(id);
const { mutate: setRedirectionHost } = useSetRedirectionHost();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
setErrorMsg(null);
const { ...payload } = {
id: id === "new" ? undefined : id,
...values,
};
setRedirectionHost(payload, {
onError: (err: any) => setErrorMsg(<T id={err.message} />),
onSuccess: () => {
showSuccess(intl.formatMessage({ id: "notification.redirection-host-saved" }));
onClose();
},
onSettled: () => {
setIsSubmitting(false);
setSubmitting(false);
},
});
};
return (
<Modal show onHide={onClose} animation={false}>
{!isLoading && error && (
<Alert variant="danger" className="m-3">
{error?.message || "Unknown error"}
</Alert>
)}
{isLoading && <Loading noLogo />}
{!isLoading && data && (
<Formik
initialValues={
{
// Details tab
domainNames: data?.domainNames || [],
forwardDomainName: data?.forwardDomainName || "",
forwardScheme: data?.forwardScheme || "auto",
forwardHttpCode: data?.forwardHttpCode || 301,
preservePath: data?.preservePath || false,
blockExploits: data?.blockExploits || false,
// SSL tab
certificateId: data?.certificateId || 0,
sslForced: data?.sslForced || false,
http2Support: data?.http2Support || false,
hstsEnabled: data?.hstsEnabled || false,
hstsSubdomains: data?.hstsSubdomains || false,
// Advanced tab
advancedConfig: data?.advancedConfig || "",
meta: data?.meta || {},
} as any
}
onSubmit={onSubmit}
>
{() => (
<Form>
<Modal.Header closeButton>
<Modal.Title>
<T id={data?.id ? "redirection-host.edit" : "redirection-host.new"} />
</Modal.Title>
</Modal.Header>
<Modal.Body className="p-0">
<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible>
{errorMsg}
</Alert>
<div className="card m-0 border-0">
<div className="card-header">
<ul className="nav nav-tabs card-header-tabs" data-bs-toggle="tabs">
<li className="nav-item" role="presentation">
<a
href="#tab-details"
className="nav-link active"
data-bs-toggle="tab"
aria-selected="true"
role="tab"
>
<T id="column.details" />
</a>
</li>
<li className="nav-item" role="presentation">
<a
href="#tab-ssl"
className="nav-link"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<T id="column.ssl" />
</a>
</li>
<li className="nav-item ms-auto" role="presentation">
<a
href="#tab-advanced"
className="nav-link"
title="Settings"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<IconSettings size={20} />
</a>
</li>
</ul>
</div>
<div className="card-body">
<div className="tab-content">
<div className="tab-pane active show" id="tab-details" role="tabpanel">
<DomainNamesField isWildcardPermitted />
<div className="row">
<div className="col-md-4">
<Field name="forwardScheme">
{({ field, form }: any) => (
<div className="mb-3">
<label
className="form-label"
htmlFor="forwardScheme"
>
<T id="host.forward-scheme" />
</label>
<select
id="forwardScheme"
className={`form-control ${form.errors.forwardScheme && form.touched.forwardScheme ? "is-invalid" : ""}`}
required
{...field}
>
<option value="$scheme">Auto</option>
<option value="http">http</option>
<option value="https">https</option>
</select>
{form.errors.forwardScheme ? (
<div className="invalid-feedback">
{form.errors.forwardScheme &&
form.touched.forwardScheme
? form.errors.forwardScheme
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
<div className="col-md-8">
<Field
name="forwardDomainName"
validate={validateString(1, 255)}
>
{({ field, form }: any) => (
<div className="mb-3">
<label
className="form-label"
htmlFor="forwardDomainName"
>
<T id="redirection-host.forward-domain" />
</label>
<input
id="forwardDomainName"
type="text"
className={`form-control ${form.errors.forwardDomainName && form.touched.forwardDomainName ? "is-invalid" : ""}`}
required
placeholder="example.com"
{...field}
/>
{form.errors.forwardDomainName ? (
<div className="invalid-feedback">
{form.errors.forwardDomainName &&
form.touched.forwardDomainName
? form.errors.forwardDomainName
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
</div>
<div className="my-3">
<h4 className="py-2">
<T id="generic.flags.title" />
</h4>
<div className="divide-y">
<div>
<label className="row" htmlFor="preservePath">
<span className="col">
<T id="host.flags.preserve-path" />
</span>
<span className="col-auto">
<Field name="preservePath" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="preservePath"
className={cn("form-check-input", {
"bg-yellow": field.checked,
})}
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
<div>
<label className="row" htmlFor="blockExploits">
<span className="col">
<T id="host.flags.block-exploits" />
</span>
<span className="col-auto">
<Field name="blockExploits" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="blockExploits"
className={cn("form-check-input", {
"bg-yellow": field.checked,
})}
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
</div>
</div>
</div>
<div className="tab-pane" id="tab-ssl" role="tabpanel">
<SSLCertificateField
name="certificateId"
label="ssl-certificate"
allowNew
/>
<SSLOptionsFields color="bg-yellow" />
</div>
<div className="tab-pane" id="tab-advanced" role="tabpanel">
<NginxConfigField />
</div>
</div>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
<T id="cancel" />
</Button>
<Button
type="submit"
actionType="primary"
className="ms-auto bg-yellow"
data-bs-dismiss="modal"
isLoading={isSubmitting}
disabled={isSubmitting}
>
<T id="save" />
</Button>
</Modal.Footer>
</Form>
)}
</Formik>
)}
</Modal>
);
}

View File

@@ -1,11 +1,11 @@
import { Field, Form, Formik } from "formik"; import { Field, Form, Formik } from "formik";
import { generate } from "generate-password-browser"; import { generate } from "generate-password-browser";
import { type ReactNode, useState } from "react"; import { useState } from "react";
import { Alert } from "react-bootstrap"; import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal"; import Modal from "react-bootstrap/Modal";
import { updateAuth } from "src/api/backend"; import { updateAuth } from "src/api/backend";
import { Button } from "src/components"; import { Button } from "src/components";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { validateString } from "src/modules/Validations"; import { validateString } from "src/modules/Validations";
interface Props { interface Props {
@@ -13,18 +13,18 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
export function SetPasswordModal({ userId, onClose }: Props) { export function SetPasswordModal({ userId, onClose }: Props) {
const [error, setError] = useState<ReactNode | null>(null); const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const _onSubmit = async (values: any, { setSubmitting }: any) => { const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return; if (isSubmitting) return;
setError(null); setError(null);
try { try {
await updateAuth(userId, values.new); await updateAuth(userId, values.new);
onClose(); onClose();
} catch (err: any) { } catch (err: any) {
setError(<T id={err.message} />); setError(intl.formatMessage({ id: err.message }));
} }
setIsSubmitting(false); setIsSubmitting(false);
setSubmitting(false); setSubmitting(false);
@@ -38,14 +38,12 @@ export function SetPasswordModal({ userId, onClose }: Props) {
new: "", new: "",
} as any } as any
} }
onSubmit={_onSubmit} onSubmit={onSubmit}
> >
{() => ( {() => (
<Form> <Form>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title> <Modal.Title>{intl.formatMessage({ id: "user.set-password" })}</Modal.Title>
<T id="user.set-password" />
</Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible> <Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
@@ -71,7 +69,9 @@ export function SetPasswordModal({ userId, onClose }: Props) {
setShowPassword(true); setShowPassword(true);
}} }}
> >
<T id="password.generate" /> {intl.formatMessage({
id: "password.generate",
})}
</a>{" "} </a>{" "}
&mdash;{" "} &mdash;{" "}
<a <a
@@ -82,7 +82,9 @@ export function SetPasswordModal({ userId, onClose }: Props) {
setShowPassword(!showPassword); setShowPassword(!showPassword);
}} }}
> >
<T id={showPassword ? "password.hide" : "password.show"} /> {intl.formatMessage({
id: showPassword ? "password.hide" : "password.show",
})}
</a> </a>
</small> </small>
</p> </p>
@@ -96,8 +98,9 @@ export function SetPasswordModal({ userId, onClose }: Props) {
{...field} {...field}
/> />
<label htmlFor="new"> <label htmlFor="new">
<T id="user.new-password" /> {intl.formatMessage({ id: "user.new-password" })}
</label> </label>
{form.errors.new ? ( {form.errors.new ? (
<div className="invalid-feedback"> <div className="invalid-feedback">
{form.errors.new && form.touched.new ? form.errors.new : null} {form.errors.new && form.touched.new ? form.errors.new : null}
@@ -111,7 +114,7 @@ export function SetPasswordModal({ userId, onClose }: Props) {
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}> <Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
<T id="cancel" /> {intl.formatMessage({ id: "cancel" })}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@@ -121,7 +124,7 @@ export function SetPasswordModal({ userId, onClose }: Props) {
isLoading={isSubmitting} isLoading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}
> >
<T id="save" /> {intl.formatMessage({ id: "save" })}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Form> </Form>

View File

@@ -1,319 +0,0 @@
import { Field, Form, Formik } from "formik";
import { type ReactNode, useState } from "react";
import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal";
import { Button, Loading, SSLCertificateField, SSLOptionsFields } from "src/components";
import { useSetStream, useStream } from "src/hooks";
import { intl, T } from "src/locale";
import { validateNumber, validateString } from "src/modules/Validations";
import { showSuccess } from "src/notifications";
interface Props {
id: number | "new";
onClose: () => void;
}
export function StreamModal({ id, onClose }: Props) {
const { data, isLoading, error } = useStream(id);
const { mutate: setStream } = useSetStream();
const [errorMsg, setErrorMsg] = useState<ReactNode | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
setErrorMsg(null);
const { ...payload } = {
id: id === "new" ? undefined : id,
...values,
};
setStream(payload, {
onError: (err: any) => setErrorMsg(<T id={err.message} />),
onSuccess: () => {
showSuccess(intl.formatMessage({ id: "notification.stream-saved" }));
onClose();
},
onSettled: () => {
setIsSubmitting(false);
setSubmitting(false);
},
});
};
return (
<Modal show onHide={onClose} animation={false}>
{!isLoading && error && (
<Alert variant="danger" className="m-3">
{error?.message || "Unknown error"}
</Alert>
)}
{isLoading && <Loading noLogo />}
{!isLoading && data && (
<Formik
initialValues={
{
incomingPort: data?.incomingPort,
forwardingHost: data?.forwardingHost,
forwardingPort: data?.forwardingPort,
tcpForwarding: data?.tcpForwarding,
udpForwarding: data?.udpForwarding,
certificateId: data?.certificateId,
meta: data?.meta || {},
} as any
}
onSubmit={onSubmit}
>
{({ setFieldValue }: any) => (
<Form>
<Modal.Header closeButton>
<Modal.Title>
<T id={data?.id ? "stream.edit" : "stream.new"} />
</Modal.Title>
</Modal.Header>
<Modal.Body className="p-0">
<Alert variant="danger" show={!!errorMsg} onClose={() => setErrorMsg(null)} dismissible>
{errorMsg}
</Alert>
<div className="card m-0 border-0">
<div className="card-header">
<ul className="nav nav-tabs card-header-tabs" data-bs-toggle="tabs">
<li className="nav-item" role="presentation">
<a
href="#tab-details"
className="nav-link active"
data-bs-toggle="tab"
aria-selected="true"
role="tab"
>
<T id="column.details" />
</a>
</li>
<li className="nav-item" role="presentation">
<a
href="#tab-ssl"
className="nav-link"
data-bs-toggle="tab"
aria-selected="false"
tabIndex={-1}
role="tab"
>
<T id="column.ssl" />
</a>
</li>
</ul>
</div>
<div className="card-body">
<div className="tab-content">
<div className="tab-pane active show" id="tab-details" role="tabpanel">
<Field name="incomingPort" validate={validateNumber(1, 65535)}>
{({ field, form }: any) => (
<div className="mb-3">
<label className="form-label" htmlFor="incomingPort">
<T id="stream.incoming-port" />
</label>
<input
id="incomingPort"
type="number"
min={1}
max={65535}
className={`form-control ${form.errors.incomingPort && form.touched.incomingPort ? "is-invalid" : ""}`}
required
placeholder="eg: 8080"
{...field}
/>
{form.errors.incomingPort ? (
<div className="invalid-feedback">
{form.errors.incomingPort &&
form.touched.incomingPort
? form.errors.incomingPort
: null}
</div>
) : null}
</div>
)}
</Field>
<div className="row">
<div className="col-md-8">
<Field name="forwardingHost" validate={validateString(1, 255)}>
{({ field, form }: any) => (
<div className="mb-3">
<label
className="form-label"
htmlFor="forwardingHost"
>
<T id="stream.forward-host" />
</label>
<input
id="forwardingHost"
type="text"
className={`form-control ${form.errors.forwardingHost && form.touched.forwardingHost ? "is-invalid" : ""}`}
required
placeholder="example.com or 10.0.0.1 or 2001:db8:3333:4444:5555:6666:7777:8888"
{...field}
/>
{form.errors.forwardingHost ? (
<div className="invalid-feedback">
{form.errors.forwardingHost &&
form.touched.forwardingHost
? form.errors.forwardingHost
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
<div className="col-md-4">
<Field
name="forwardingPort"
validate={validateNumber(1, 65535)}
>
{({ field, form }: any) => (
<div className="mb-3">
<label
className="form-label"
htmlFor="forwardingPort"
>
<T id="stream.forward-port" />
</label>
<input
id="forwardingPort"
type="number"
min={1}
max={65535}
className={`form-control ${form.errors.forwardingPort && form.touched.forwardingPort ? "is-invalid" : ""}`}
required
placeholder="eg: 8081"
{...field}
/>
{form.errors.forwardingPort ? (
<div className="invalid-feedback">
{form.errors.forwardingPort &&
form.touched.forwardingPort
? form.errors.forwardingPort
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
</div>
<div className="my-3">
<h3 className="py-2">
<T id="host.flags.protocols" />
</h3>
<div className="divide-y">
<div>
<label className="row" htmlFor="tcpForwarding">
<span className="col">
<T id="streams.tcp" />
</span>
<span className="col-auto">
<Field name="tcpForwarding" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
id="tcpForwarding"
className="form-check-input"
type="checkbox"
name={field.name}
checked={field.value}
onChange={(e: any) => {
setFieldValue(
field.name,
e.target.checked,
);
if (!e.target.checked) {
setFieldValue(
"udpForwarding",
true,
);
}
}}
/>
</label>
)}
</Field>
</span>
</label>
</div>
<div>
<label className="row" htmlFor="udpForwarding">
<span className="col">
<T id="streams.udp" />
</span>
<span className="col-auto">
<Field name="udpForwarding" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
id="udpForwarding"
className="form-check-input"
type="checkbox"
name={field.name}
checked={field.value}
onChange={(e: any) => {
setFieldValue(
field.name,
e.target.checked,
);
if (!e.target.checked) {
setFieldValue(
"tcpForwarding",
true,
);
}
}}
/>
</label>
)}
</Field>
</span>
</label>
</div>
</div>
</div>
</div>
<div className="tab-pane" id="tab-ssl" role="tabpanel">
<SSLCertificateField
name="certificateId"
label="ssl-certificate"
allowNew
forHttp={false}
/>
<SSLOptionsFields
color="bg-blue"
forHttp={false}
forceDNSForNew
requireDomainNames
/>
</div>
</div>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
<T id="cancel" />
</Button>
<Button
type="submit"
actionType="primary"
className="ms-auto"
data-bs-dismiss="modal"
isLoading={isSubmitting}
disabled={isSubmitting}
>
<T id="save" />
</Button>
</Modal.Footer>
</Form>
)}
</Formik>
)}
</Modal>
);
}

View File

@@ -4,7 +4,7 @@ import { Alert } from "react-bootstrap";
import Modal from "react-bootstrap/Modal"; import Modal from "react-bootstrap/Modal";
import { Button, Loading } from "src/components"; import { Button, Loading } from "src/components";
import { useSetUser, useUser } from "src/hooks"; import { useSetUser, useUser } from "src/hooks";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { validateEmail, validateString } from "src/modules/Validations"; import { validateEmail, validateString } from "src/modules/Validations";
import { showSuccess } from "src/notifications"; import { showSuccess } from "src/notifications";
@@ -79,7 +79,7 @@ export function UserModal({ userId, onClose }: Props) {
<Form> <Form>
<Modal.Header closeButton> <Modal.Header closeButton>
<Modal.Title> <Modal.Title>
<T id={data?.id ? "user.edit" : "user.new"} /> {intl.formatMessage({ id: data?.id ? "user.edit" : "user.new" })}
</Modal.Title> </Modal.Title>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
@@ -99,7 +99,7 @@ export function UserModal({ userId, onClose }: Props) {
{...field} {...field}
/> />
<label htmlFor="name"> <label htmlFor="name">
<T id="user.full-name" /> {intl.formatMessage({ id: "user.full-name" })}
</label> </label>
{form.errors.name ? ( {form.errors.name ? (
<div className="invalid-feedback"> <div className="invalid-feedback">
@@ -125,7 +125,7 @@ export function UserModal({ userId, onClose }: Props) {
{...field} {...field}
/> />
<label htmlFor="nickname"> <label htmlFor="nickname">
<T id="user.nickname" /> {intl.formatMessage({ id: "user.nickname" })}
</label> </label>
{form.errors.nickname ? ( {form.errors.nickname ? (
<div className="invalid-feedback"> <div className="invalid-feedback">
@@ -152,7 +152,7 @@ export function UserModal({ userId, onClose }: Props) {
{...field} {...field}
/> />
<label htmlFor="email"> <label htmlFor="email">
<T id="email-address" /> {intl.formatMessage({ id: "email-address" })}
</label> </label>
{form.errors.email ? ( {form.errors.email ? (
<div className="invalid-feedback"> <div className="invalid-feedback">
@@ -167,14 +167,12 @@ export function UserModal({ userId, onClose }: Props) {
</div> </div>
{currentUser && data && currentUser?.id !== data?.id ? ( {currentUser && data && currentUser?.id !== data?.id ? (
<div className="my-3"> <div className="my-3">
<h4 className="py-2"> <h3 className="py-2">{intl.formatMessage({ id: "user.flags.title" })}</h3>
<T id="user.flags.title" />
</h4>
<div className="divide-y"> <div className="divide-y">
<div> <div>
<label className="row" htmlFor="isAdmin"> <label className="row" htmlFor="isAdmin">
<span className="col"> <span className="col">
<T id="role.admin" /> {intl.formatMessage({ id: "role.admin" })}
</span> </span>
<span className="col-auto"> <span className="col-auto">
<Field name="isAdmin" type="checkbox"> <Field name="isAdmin" type="checkbox">
@@ -195,7 +193,7 @@ export function UserModal({ userId, onClose }: Props) {
<div> <div>
<label className="row" htmlFor="isDisabled"> <label className="row" htmlFor="isDisabled">
<span className="col"> <span className="col">
<T id="disabled" /> {intl.formatMessage({ id: "disabled" })}
</span> </span>
<span className="col-auto"> <span className="col-auto">
<Field name="isDisabled" type="checkbox"> <Field name="isDisabled" type="checkbox">
@@ -219,7 +217,7 @@ export function UserModal({ userId, onClose }: Props) {
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}> <Button data-bs-dismiss="modal" onClick={onClose} disabled={isSubmitting}>
<T id="cancel" /> {intl.formatMessage({ id: "cancel" })}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@@ -229,7 +227,7 @@ export function UserModal({ userId, onClose }: Props) {
isLoading={isSubmitting} isLoading={isSubmitting}
disabled={isSubmitting} disabled={isSubmitting}
> >
<T id="save" /> {intl.formatMessage({ id: "save" })}
</Button> </Button>
</Modal.Footer> </Modal.Footer>
</Form> </Form>

View File

@@ -1,11 +1,7 @@
export * from "./AccessListModal";
export * from "./ChangePasswordModal"; export * from "./ChangePasswordModal";
export * from "./DeadHostModal"; export * from "./DeadHostModal";
export * from "./DeleteConfirmModal"; export * from "./DeleteConfirmModal";
export * from "./EventDetailsModal"; export * from "./EventDetailsModal";
export * from "./PermissionsModal"; export * from "./PermissionsModal";
export * from "./ProxyHostModal";
export * from "./RedirectionHostModal";
export * from "./SetPasswordModal"; export * from "./SetPasswordModal";
export * from "./StreamModal";
export * from "./UserModal"; export * from "./UserModal";

View File

@@ -85,18 +85,18 @@ const validateDomain = (allowWildcards = false) => {
const validateDomains = (allowWildcards = false, maxDomains?: number) => { const validateDomains = (allowWildcards = false, maxDomains?: number) => {
const vDom = validateDomain(allowWildcards); const vDom = validateDomain(allowWildcards);
return (value?: string[]): string | undefined => { return (value: string[]): string | undefined => {
if (!value?.length) { if (!value.length) {
return intl.formatMessage({ id: "error.required" }); return intl.formatMessage({ id: "error.required" });
} }
// Deny if the list of domains is hit // Deny if the list of domains is hit
if (maxDomains && value?.length >= maxDomains) { if (maxDomains && value.length >= maxDomains) {
return intl.formatMessage({ id: "error.max-domains" }, { max: maxDomains }); return intl.formatMessage({ id: "error.max-domains" }, { max: maxDomains });
} }
// validate each domain // validate each domain
for (let i = 0; i < value?.length; i++) { for (let i = 0; i < value.length; i++) {
if (!vDom(value[i])) { if (!vDom(value[i])) {
return intl.formatMessage({ id: "error.invalid-domain" }, { domain: value[i] }); return intl.formatMessage({ id: "error.invalid-domain" }, { domain: value[i] });
} }

View File

@@ -1,34 +1,18 @@
import type { Table as ReactTable } from "@tanstack/react-table"; import type { Table as ReactTable } from "@tanstack/react-table";
import { Button } from "src/components"; import { Button } from "src/components";
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
tableInstance: ReactTable<any>; tableInstance: ReactTable<any>;
onNew?: () => void;
isFiltered?: boolean;
} }
export default function Empty({ tableInstance, onNew, isFiltered }: Props) { export default function Empty({ tableInstance }: Props) {
return ( return (
<tr> <tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}> <td colSpan={tableInstance.getVisibleFlatColumns().length}>
<div className="text-center my-4"> <div className="text-center my-4">
{isFiltered ? ( <h2>{intl.formatMessage({ id: "access.empty" })}</h2>
<h2> <p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
<T id="empty.search" /> <Button className="btn-cyan my-3">{intl.formatMessage({ id: "access.add" })}</Button>
</h2>
) : (
<>
<h2>
<T id="access.empty" />
</h2>
<p className="text-muted">
<T id="empty-subtitle" />
</p>
<Button className="btn-cyan my-3" onClick={onNew}>
<T id="access.add" />
</Button>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -4,24 +4,23 @@ import { useMemo } from "react";
import type { AccessList } from "src/api/backend"; import type { AccessList } from "src/api/backend";
import { GravatarFormatter, ValueWithDateFormatter } from "src/components"; import { GravatarFormatter, ValueWithDateFormatter } from "src/components";
import { TableLayout } from "src/components/Table/TableLayout"; import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import Empty from "./Empty"; import Empty from "./Empty";
interface Props { interface Props {
data: AccessList[]; data: AccessList[];
isFiltered?: boolean;
isFetching?: boolean; isFetching?: boolean;
onEdit?: (id: number) => void;
onDelete?: (id: number) => void;
onNew?: () => void;
} }
export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, onNew }: Props) { export default function Table({ data, isFetching }: Props) {
const columnHelper = createColumnHelper<AccessList>(); const columnHelper = createColumnHelper<AccessList>();
const columns = useMemo( const columns = useMemo(
() => [ () => [
columnHelper.accessor((row: any) => row.owner, { columnHelper.accessor((row: any) => row.owner, {
id: "owner", id: "owner",
cell: (info: any) => <GravatarFormatter url={info.getValue().avatar} name={info.getValue().name} />, cell: (info: any) => {
const value = info.getValue();
return <GravatarFormatter url={value.avatar} name={value.name} />;
},
meta: { meta: {
className: "w-1", className: "w-1",
}, },
@@ -29,29 +28,42 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
columnHelper.accessor((row: any) => row, { columnHelper.accessor((row: any) => row, {
id: "name", id: "name",
header: intl.formatMessage({ id: "column.name" }), header: intl.formatMessage({ id: "column.name" }),
cell: (info: any) => ( cell: (info: any) => {
<ValueWithDateFormatter value={info.getValue().name} createdOn={info.getValue().createdOn} /> const value = info.getValue();
), // Bit of a hack to reuse the DomainsFormatter component
return <ValueWithDateFormatter value={value.name} createdOn={value.createdOn} />;
},
}), }),
columnHelper.accessor((row: any) => row.items, { columnHelper.accessor((row: any) => row.items, {
id: "items", id: "items",
header: intl.formatMessage({ id: "column.authorization" }), header: intl.formatMessage({ id: "column.authorization" }),
cell: (info: any) => <T id="access.auth-count" data={{ count: info.getValue().length }} />, cell: (info: any) => {
const value = info.getValue();
return intl.formatMessage({ id: "access.auth-count" }, { count: value.length });
},
}), }),
columnHelper.accessor((row: any) => row.clients, { columnHelper.accessor((row: any) => row.clients, {
id: "clients", id: "clients",
header: intl.formatMessage({ id: "column.access" }), header: intl.formatMessage({ id: "column.access" }),
cell: (info: any) => <T id="access.access-count" data={{ count: info.getValue().length }} />, cell: (info: any) => {
const value = info.getValue();
return intl.formatMessage({ id: "access.access-count" }, { count: value.length });
},
}), }),
columnHelper.accessor((row: any) => row.satisfyAny, { columnHelper.accessor((row: any) => row.satisfyAny, {
id: "satisfyAny", id: "satisfyAny",
header: intl.formatMessage({ id: "column.satisfy" }), header: intl.formatMessage({ id: "column.satisfy" }),
cell: (info: any) => <T id={info.getValue() ? "column.satisfy-any" : "column.satisfy-all"} />, cell: (info: any) => {
const t = info.getValue() ? "access.satisfy-any" : "access.satisfy-all";
return intl.formatMessage({ id: t });
},
}), }),
columnHelper.accessor((row: any) => row.proxyHostCount, { columnHelper.accessor((row: any) => row.proxyHostCount, {
id: "proxyHostCount", id: "proxyHostCount",
header: intl.formatMessage({ id: "proxy-hosts.title" }), header: intl.formatMessage({ id: "proxy-hosts.title" }),
cell: (info: any) => <T id="proxy-hosts.count" data={{ count: info.getValue() }} />, cell: (info: any) => {
return intl.formatMessage({ id: "proxy-hosts.count" }, { count: info.getValue() });
},
}), }),
columnHelper.display({ columnHelper.display({
id: "id", // todo: not needed for a display? id: "id", // todo: not needed for a display?
@@ -68,30 +80,21 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
</button> </button>
<div className="dropdown-menu dropdown-menu-end"> <div className="dropdown-menu dropdown-menu-end">
<span className="dropdown-header"> <span className="dropdown-header">
<T id="access.actions-title" data={{ id: info.row.original.id }} /> {intl.formatMessage(
{
id: "access.actions-title",
},
{ id: info.row.original.id },
)}
</span> </span>
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onEdit?.(info.row.original.id);
}}
>
<IconEdit size={16} /> <IconEdit size={16} />
<T id="action.edit" /> {intl.formatMessage({ id: "action.edit" })}
</a> </a>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDelete?.(info.row.original.id);
}}
>
<IconTrash size={16} /> <IconTrash size={16} />
<T id="action.delete" /> {intl.formatMessage({ id: "action.delete" })}
</a> </a>
</div> </div>
</span> </span>
@@ -102,7 +105,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
}, },
}), }),
], ],
[columnHelper, onEdit, onDelete], [columnHelper],
); );
const tableInstance = useReactTable<AccessList>({ const tableInstance = useReactTable<AccessList>({
@@ -116,10 +119,5 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
enableSortingRemoval: false, enableSortingRemoval: false,
}); });
return ( return <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} />} />;
<TableLayout
tableInstance={tableInstance}
emptyState={<Empty tableInstance={tableInstance} onNew={onNew} isFiltered={isFiltered} />}
/>
);
} }

View File

@@ -1,18 +1,11 @@
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { deleteAccessList } from "src/api/backend";
import { Button, LoadingPage } from "src/components"; import { Button, LoadingPage } from "src/components";
import { useAccessLists } from "src/hooks"; import { useAccessLists } from "src/hooks";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { AccessListModal, DeleteConfirmModal } from "src/modals";
import { showSuccess } from "src/notifications";
import Table from "./Table"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
const [search, setSearch] = useState("");
const [editId, setEditId] = useState(0 as number | "new");
const [deleteId, setDeleteId] = useState(0);
const { isFetching, isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]); const { isFetching, isLoading, isError, error, data } = useAccessLists(["owner", "items", "clients"]);
if (isLoading) { if (isLoading) {
@@ -23,27 +16,6 @@ export default function TableWrapper() {
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>; return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
} }
const handleDelete = async () => {
await deleteAccessList(deleteId);
showSuccess(intl.formatMessage({ id: "notification.access-deleted" }));
};
let filtered = null;
if (search && data) {
filtered = data?.filter((_item) => {
return true;
// TODO
// return (
// `${item.incomingPort}`.includes(search) ||
// `${item.forwardingPort}`.includes(search) ||
// item.forwardingHost.includes(search)
// );
});
} else if (search !== "") {
// this can happen if someone deletes the last item while searching
setSearch("");
}
return ( return (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-status-top bg-cyan" /> <div className="card-status-top bg-cyan" />
@@ -51,11 +23,8 @@ export default function TableWrapper() {
<div className="card-header"> <div className="card-header">
<div className="row w-full"> <div className="row w-full">
<div className="col"> <div className="col">
<h2 className="mt-1 mb-0"> <h2 className="mt-1 mb-0">{intl.formatMessage({ id: "access.title" })}</h2>
<T id="access.title" />
</h2>
</div> </div>
{data?.length ? (
<div className="col-md-auto col-sm-12"> <div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list"> <div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto"> <div className="input-group input-group-flat w-auto">
@@ -67,36 +36,16 @@ export default function TableWrapper() {
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
autoComplete="off" autoComplete="off"
onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
/> />
</div> </div>
<Button size="sm" className="btn-cyan" onClick={() => setEditId("new")}> <Button size="sm" className="btn-cyan">
<T id="access.add" /> {intl.formatMessage({ id: "access.add" })}
</Button> </Button>
</div> </div>
</div> </div>
) : null}
</div> </div>
</div> </div>
<Table <Table data={data ?? []} isFetching={isFetching} />
data={filtered ?? data ?? []}
isFetching={isFetching}
isFiltered={!!filtered}
onEdit={(id: number) => setEditId(id)}
onDelete={(id: number) => setDeleteId(id)}
onNew={() => setEditId("new")}
/>
{editId ? <AccessListModal id={editId} onClose={() => setEditId(0)} /> : null}
{deleteId ? (
<DeleteConfirmModal
title="access.delete.title"
onConfirm={handleDelete}
onClose={() => setDeleteId(0)}
invalidations={[["access-lists"], ["access-list", deleteId]]}
>
<T id="access.delete.content" />
</DeleteConfirmModal>
) : null}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,128 @@
import { IconDotsVertical, IconEdit, IconPower, IconSearch, IconTrash } from "@tabler/icons-react";
import { intl } from "src/locale";
export default function AuditTable() {
return (
<div className="card mt-4">
<div className="card-status-top bg-purple" />
<div className="card-table">
<div className="card-header">
<div className="row w-full">
<div className="col">
<h2 className="mt-1 mb-0">{intl.formatMessage({ id: "auditlog.title" })}</h2>
</div>
<div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto">
<span className="input-group-text input-group-text-sm">
<IconSearch size={16} />
</span>
<input
id="advanced-table-search"
type="text"
className="form-control form-control-sm"
autoComplete="off"
/>
</div>
</div>
</div>
</div>
</div>
<div id="advanced-table">
<div className="table-responsive">
<table className="table table-vcenter table-selectable">
<thead>
<tr>
<th className="w-1" />
<th>
<button type="button" className="table-sort d-flex justify-content-between">
Source
</button>
</th>
<th>
<button type="button" className="table-sort d-flex justify-content-between">
Destination
</button>
</th>
<th>
<button type="button" className="table-sort d-flex justify-content-between">
SSL
</button>
</th>
<th>
<button type="button" className="table-sort d-flex justify-content-between">
Access
</button>
</th>
<th>
<button type="button" className="table-sort d-flex justify-content-between">
Status
</button>
</th>
<th className="w-1" />
</tr>
</thead>
<tbody className="table-tbody">
<tr>
<td data-label="Owner">
<div className="d-flex py-1 align-items-center">
<span
className="avatar avatar-2 me-2"
style={{
backgroundImage:
"url(//www.gravatar.com/avatar/6193176330f8d38747f038c170ddb193?default=mm)",
}}
/>
</div>
</td>
<td data-label="Destination">
<div className="flex-fill">
<div className="font-weight-medium">
<span className="badge badge-lg domain-name">blog.jc21.com</span>
</div>
<div className="text-secondary mt-1">Created: 20th September 2024</div>
</div>
</td>
<td data-label="Source">http://172.17.0.1:3001</td>
<td data-label="SSL">Let's Encrypt</td>
<td data-label="Access">Public</td>
<td data-label="Status">
<span className="badge bg-lime-lt">Online</span>
</td>
<td data-label="Status" className="text-end">
<span className="dropdown">
<button
type="button"
className="btn dropdown-toggle btn-action btn-sm px-1"
data-bs-boundary="viewport"
data-bs-toggle="dropdown"
>
<IconDotsVertical />
</button>
<div className="dropdown-menu dropdown-menu-end">
<span className="dropdown-header">Proxy Host #2</span>
<a className="dropdown-item" href="#">
<IconEdit size={16} />
Edit
</a>
<a className="dropdown-item" href="#">
<IconPower size={16} />
Disable
</a>
<div className="dropdown-divider" />
<a className="dropdown-item" href="#">
<IconTrash size={16} />
Delete
</a>
</div>
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import { useMemo } from "react";
import type { AuditLog } from "src/api/backend"; import type { AuditLog } from "src/api/backend";
import { EventFormatter, GravatarFormatter } from "src/components"; import { EventFormatter, GravatarFormatter } from "src/components";
import { TableLayout } from "src/components/Table/TableLayout"; import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
data: AuditLog[]; data: AuditLog[];
@@ -47,7 +47,7 @@ export default function Table({ data, isFetching, onSelectItem }: Props) {
onSelectItem?.(info.row.original.id); onSelectItem?.(info.row.original.id);
}} }}
> >
<T id="action.view-details" /> {intl.formatMessage({ id: "action.view-details" })}
</button> </button>
); );
}, },

View File

@@ -1,8 +1,9 @@
import { IconSearch } from "@tabler/icons-react";
import { useState } from "react"; import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { LoadingPage } from "src/components"; import { LoadingPage } from "src/components";
import { useAuditLogs } from "src/hooks"; import { useAuditLogs } from "src/hooks";
import { T } from "src/locale"; import { intl } from "src/locale";
import { EventDetailsModal } from "src/modals"; import { EventDetailsModal } from "src/modals";
import Table from "./Table"; import Table from "./Table";
@@ -25,9 +26,22 @@ export default function TableWrapper() {
<div className="card-header"> <div className="card-header">
<div className="row w-full"> <div className="row w-full">
<div className="col"> <div className="col">
<h2 className="mt-1 mb-0"> <h2 className="mt-1 mb-0">{intl.formatMessage({ id: "auditlog.title" })}</h2>
<T id="auditlog.title" /> </div>
</h2> <div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto">
<span className="input-group-text input-group-text-sm">
<IconSearch size={16} />
</span>
<input
id="advanced-table-search"
type="text"
className="form-control form-control-sm"
autoComplete="off"
/>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,62 +1,34 @@
import type { Table as ReactTable } from "@tanstack/react-table"; import type { Table as ReactTable } from "@tanstack/react-table";
import { T } from "src/locale"; import { intl } from "src/locale";
/**
* This component should never render as there should always be 1 user minimum,
* but I'm keeping it for consistency.
*/
interface Props { interface Props {
tableInstance: ReactTable<any>; tableInstance: ReactTable<any>;
onNew?: () => void;
onNewCustom?: () => void;
isFiltered?: boolean;
} }
export default function Empty({ tableInstance, onNew, onNewCustom, isFiltered }: Props) { export default function Empty({ tableInstance }: Props) {
return ( return (
<tr> <tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}> <td colSpan={tableInstance.getVisibleFlatColumns().length}>
<div className="text-center my-4"> <div className="text-center my-4">
{isFiltered ? ( <h2>{intl.formatMessage({ id: "certificates.empty" })}</h2>
<h2> <p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
<T id="empty.search" />
</h2>
) : (
<>
<h2>
<T id="certificates.empty" />
</h2>
<p className="text-muted">
<T id="empty-subtitle" />
</p>
<div className="dropdown"> <div className="dropdown">
<button <button type="button" className="btn dropdown-toggle btn-pink my-3" data-bs-toggle="dropdown">
type="button" {intl.formatMessage({ id: "certificates.add" })}
className="btn dropdown-toggle btn-pink my-3"
data-bs-toggle="dropdown"
>
<T id="certificates.add" />
</button> </button>
<div className="dropdown-menu"> <div className="dropdown-menu">
<a <a className="dropdown-item" href="#">
className="dropdown-item" {intl.formatMessage({ id: "lets-encrypt" })}
href="#"
onClick={(e) => {
e.preventDefault();
onNew?.();
}}
>
<T id="lets-encrypt" />
</a> </a>
<a <a className="dropdown-item" href="#">
className="dropdown-item" {intl.formatMessage({ id: "certificates.custom" })}
href="#"
onClick={(e) => {
e.preventDefault();
onNewCustom?.();
}}
>
<T id="certificates.custom" />
</a> </a>
</div> </div>
</div> </div>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -4,7 +4,7 @@ import { useMemo } from "react";
import type { Certificate } from "src/api/backend"; import type { Certificate } from "src/api/backend";
import { DomainsFormatter, GravatarFormatter } from "src/components"; import { DomainsFormatter, GravatarFormatter } from "src/components";
import { TableLayout } from "src/components/Table/TableLayout"; import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import Empty from "./Empty"; import Empty from "./Empty";
interface Props { interface Props {
@@ -69,20 +69,25 @@ export default function Table({ data, isFetching }: Props) {
</button> </button>
<div className="dropdown-menu dropdown-menu-end"> <div className="dropdown-menu dropdown-menu-end">
<span className="dropdown-header"> <span className="dropdown-header">
<T id="certificates.actions-title" data={{ id: info.row.original.id }} /> {intl.formatMessage(
{
id: "certificates.actions-title",
},
{ id: info.row.original.id },
)}
</span> </span>
<a className="dropdown-item" href="#"> <a className="dropdown-item" href="#">
<IconEdit size={16} /> <IconEdit size={16} />
<T id="action.edit" /> {intl.formatMessage({ id: "action.edit" })}
</a> </a>
<a className="dropdown-item" href="#"> <a className="dropdown-item" href="#">
<IconPower size={16} /> <IconPower size={16} />
<T id="action.disable" /> {intl.formatMessage({ id: "action.disable" })}
</a> </a>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a className="dropdown-item" href="#"> <a className="dropdown-item" href="#">
<IconTrash size={16} /> <IconTrash size={16} />
<T id="action.delete" /> {intl.formatMessage({ id: "action.delete" })}
</a> </a>
</div> </div>
</span> </span>

View File

@@ -2,7 +2,7 @@ import { IconSearch } from "@tabler/icons-react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { LoadingPage } from "src/components"; import { LoadingPage } from "src/components";
import { useCertificates } from "src/hooks"; import { useCertificates } from "src/hooks";
import { T } from "src/locale"; import { intl } from "src/locale";
import Table from "./Table"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
@@ -28,9 +28,7 @@ export default function TableWrapper() {
<div className="card-header"> <div className="card-header">
<div className="row w-full"> <div className="row w-full">
<div className="col"> <div className="col">
<h2 className="mt-1 mb-0"> <h2 className="mt-1 mb-0">{intl.formatMessage({ id: "certificates.title" })}</h2>
<T id="certificates.title" />
</h2>
</div> </div>
<div className="col-md-auto col-sm-12"> <div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list"> <div className="ms-auto d-flex flex-wrap btn-list">
@@ -51,14 +49,14 @@ export default function TableWrapper() {
className="btn btn-sm dropdown-toggle btn-pink mt-1" className="btn btn-sm dropdown-toggle btn-pink mt-1"
data-bs-toggle="dropdown" data-bs-toggle="dropdown"
> >
<T id="certificates.add" /> {intl.formatMessage({ id: "certificates.add" })}
</button> </button>
<div className="dropdown-menu"> <div className="dropdown-menu">
<a className="dropdown-item" href="#"> <a className="dropdown-item" href="#">
<T id="lets-encrypt" /> {intl.formatMessage({ id: "lets-encrypt" })}
</a> </a>
<a className="dropdown-item" href="#"> <a className="dropdown-item" href="#">
<T id="certificates.custom" /> {intl.formatMessage({ id: "certificates.custom" })}
</a> </a>
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc } from "@tabler/icons-react"; import { IconArrowsCross, IconBolt, IconBoltOff, IconDisc } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useHostReport } from "src/hooks"; import { useHostReport } from "src/hooks";
import { T } from "src/locale"; import { intl } from "src/locale";
const Dashboard = () => { const Dashboard = () => {
const { data: hostReport } = useHostReport(); const { data: hostReport } = useHostReport();
@@ -9,9 +9,7 @@ const Dashboard = () => {
return ( return (
<div> <div>
<h2> <h2>{intl.formatMessage({ id: "dashboard.title" })}</h2>
<T id="dashboard.title" />
</h2>
<div className="row row-deck row-cards"> <div className="row row-deck row-cards">
<div className="col-12 my-4"> <div className="col-12 my-4">
<div className="row row-cards"> <div className="row row-cards">
@@ -33,7 +31,10 @@ const Dashboard = () => {
</div> </div>
<div className="col"> <div className="col">
<div className="font-weight-medium"> <div className="font-weight-medium">
<T id="proxy-hosts.count" data={{ count: hostReport?.proxy }} /> {intl.formatMessage(
{ id: "proxy-hosts.count" },
{ count: hostReport?.proxy },
)}
</div> </div>
</div> </div>
</div> </div>
@@ -57,7 +58,10 @@ const Dashboard = () => {
</span> </span>
</div> </div>
<div className="col"> <div className="col">
<T id="redirection-hosts.count" data={{ count: hostReport?.redirection }} /> {intl.formatMessage(
{ id: "redirection-hosts.count" },
{ count: hostReport?.redirection },
)}
</div> </div>
</div> </div>
</div> </div>
@@ -80,7 +84,7 @@ const Dashboard = () => {
</span> </span>
</div> </div>
<div className="col"> <div className="col">
<T id="streams.count" data={{ count: hostReport?.stream }} /> {intl.formatMessage({ id: "streams.count" }, { count: hostReport?.stream })}
</div> </div>
</div> </div>
</div> </div>
@@ -103,7 +107,10 @@ const Dashboard = () => {
</span> </span>
</div> </div>
<div className="col"> <div className="col">
<T id="dead-hosts.count" data={{ count: hostReport?.dead }} /> {intl.formatMessage(
{ id: "dead-hosts.count" },
{ count: hostReport?.dead },
)}
</div> </div>
</div> </div>
</div> </div>
@@ -115,25 +122,20 @@ const Dashboard = () => {
<pre> <pre>
<code>{`Todo: <code>{`Todo:
- Users: permissions modal and trigger after adding user
- modal dialgs for everything
- Tables
- check mobile - check mobile
- fix bad jwt not refreshing entire page
- add help docs for host types - add help docs for host types
- REDO SCREENSHOTS in docs folder - REDO SCREENSHOTS in docs folder
- translations for: - Remove letsEncryptEmail field from new certificate requests, use current user email server side
- src/components/Form/AccessField.tsx
- src/components/Form/SSLCertificateField.tsx
- src/components/Form/DNSProviderFields.tsx
- search codebase for "TODO"
- update documentation to add development notes for translations
- use syntax highligting for audit logs json output
- double check output of access field selection on proxy host dialog, after access lists are completed
- proxy host custom locations dialog
More for api, then implement here: More for api, then implement here:
- Properly implement refresh tokens - Properly implement refresh tokens
- Add error message_18n for all backend errors - Add error message_18n for all backend errors
- minor: certificates expand with hosts needs to omit 'is_deleted' - minor: certificates expand with hosts needs to omit 'is_deleted'
- properly wrap all logger.debug called in isDebug check - properly wrap all logger.debug called in isDebug check
- add new api endpoint changes to swagger docs
`}</code> `}</code>
</pre> </pre>

View File

@@ -5,7 +5,7 @@ import Alert from "react-bootstrap/Alert";
import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components"; import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components";
import { useAuthState } from "src/context"; import { useAuthState } from "src/context";
import { useHealth } from "src/hooks"; import { useHealth } from "src/hooks";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { validateEmail, validateString } from "src/modules/Validations"; import { validateEmail, validateString } from "src/modules/Validations";
import styles from "./index.module.css"; import styles from "./index.module.css";
@@ -57,9 +57,7 @@ export default function Login() {
</div> </div>
<div className="card card-md"> <div className="card card-md">
<div className="card-body"> <div className="card-body">
<h2 className="h2 text-center mb-4"> <h2 className="h2 text-center mb-4">{intl.formatMessage({ id: "login.title" })}</h2>
<T id="login.title" />
</h2>
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>} {formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
<Formik <Formik
initialValues={ initialValues={
@@ -76,7 +74,7 @@ export default function Login() {
<Field name="email" validate={validateEmail()}> <Field name="email" validate={validateEmail()}>
{({ field, form }: any) => ( {({ field, form }: any) => (
<label className="form-label"> <label className="form-label">
<T id="email-address" /> {intl.formatMessage({ id: "email-address" })}
<input <input
{...field} {...field}
ref={emailRef} ref={emailRef}
@@ -95,7 +93,7 @@ export default function Login() {
{({ field, form }: any) => ( {({ field, form }: any) => (
<> <>
<label className="form-label"> <label className="form-label">
<T id="password" /> {intl.formatMessage({ id: "password" })}
<input <input
{...field} {...field}
type="password" type="password"
@@ -113,7 +111,7 @@ export default function Login() {
</div> </div>
<div className="form-footer"> <div className="form-footer">
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}> <Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
<T id="sign-in" /> {intl.formatMessage({ id: "sign-in" })}
</Button> </Button>
</div> </div>
</Form> </Form>

View File

@@ -1,34 +1,21 @@
import type { Table as ReactTable } from "@tanstack/react-table"; import type { Table as ReactTable } from "@tanstack/react-table";
import { Button } from "src/components"; import { Button } from "src/components";
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
tableInstance: ReactTable<any>; tableInstance: ReactTable<any>;
onNew?: () => void; onNew?: () => void;
isFiltered?: boolean;
} }
export default function Empty({ tableInstance, onNew, isFiltered }: Props) { export default function Empty({ tableInstance, onNew }: Props) {
return ( return (
<tr> <tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}> <td colSpan={tableInstance.getVisibleFlatColumns().length}>
<div className="text-center my-4"> <div className="text-center my-4">
{isFiltered ? ( <h2>{intl.formatMessage({ id: "dead-hosts.empty" })}</h2>
<h2> <p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
<T id="empty.search" />
</h2>
) : (
<>
<h2>
<T id="dead-hosts.empty" />
</h2>
<p className="text-muted">
<T id="empty-subtitle" />
</p>
<Button className="btn-red my-3" onClick={onNew}> <Button className="btn-red my-3" onClick={onNew}>
<T id="dead-hosts.add" /> {intl.formatMessage({ id: "dead-hosts.add" })}
</Button> </Button>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -4,19 +4,17 @@ import { useMemo } from "react";
import type { DeadHost } from "src/api/backend"; import type { DeadHost } from "src/api/backend";
import { CertificateFormatter, DomainsFormatter, GravatarFormatter, StatusFormatter } from "src/components"; import { CertificateFormatter, DomainsFormatter, GravatarFormatter, StatusFormatter } from "src/components";
import { TableLayout } from "src/components/Table/TableLayout"; import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import Empty from "./Empty"; import Empty from "./Empty";
interface Props { interface Props {
data: DeadHost[]; data: DeadHost[];
isFiltered?: boolean;
isFetching?: boolean; isFetching?: boolean;
onEdit?: (id: number) => void; onEdit?: (id: number) => void;
onDelete?: (id: number) => void; onDelete?: (id: number) => void;
onDisableToggle?: (id: number, enabled: boolean) => void;
onNew?: () => void; onNew?: () => void;
} }
export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) { export default function Table({ data, isFetching, onEdit, onDelete, onNew }: Props) {
const columnHelper = createColumnHelper<DeadHost>(); const columnHelper = createColumnHelper<DeadHost>();
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -67,7 +65,12 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
</button> </button>
<div className="dropdown-menu dropdown-menu-end"> <div className="dropdown-menu dropdown-menu-end">
<span className="dropdown-header"> <span className="dropdown-header">
<T id="dead-hosts.actions-title" data={{ id: info.row.original.id }} /> {intl.formatMessage(
{
id: "dead-hosts.actions-title",
},
{ id: info.row.original.id },
)}
</span> </span>
<a <a
className="dropdown-item" className="dropdown-item"
@@ -78,18 +81,11 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
}} }}
> >
<IconEdit size={16} /> <IconEdit size={16} />
<T id="action.edit" /> {intl.formatMessage({ id: "action.edit" })}
</a> </a>
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDisableToggle?.(info.row.original.id, !info.row.original.enabled);
}}
>
<IconPower size={16} /> <IconPower size={16} />
<T id={info.row.original.enabled ? "action.disable" : "action.enable"} /> {intl.formatMessage({ id: "action.disable" })}
</a> </a>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a <a
@@ -101,7 +97,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
}} }}
> >
<IconTrash size={16} /> <IconTrash size={16} />
<T id="action.delete" /> {intl.formatMessage({ id: "action.delete" })}
</a> </a>
</div> </div>
</span> </span>
@@ -112,7 +108,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
}, },
}), }),
], ],
[columnHelper, onDelete, onEdit, onDisableToggle], [columnHelper, onDelete, onEdit],
); );
const tableInstance = useReactTable<DeadHost>({ const tableInstance = useReactTable<DeadHost>({
@@ -127,9 +123,6 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
}); });
return ( return (
<TableLayout <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} onNew={onNew} />} />
tableInstance={tableInstance}
emptyState={<Empty tableInstance={tableInstance} onNew={onNew} isFiltered={isFiltered} />}
/>
); );
} }

View File

@@ -1,18 +1,14 @@
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { deleteDeadHost, toggleDeadHost } from "src/api/backend";
import { Button, LoadingPage } from "src/components"; import { Button, LoadingPage } from "src/components";
import { useDeadHosts } from "src/hooks"; import { useDeadHosts } from "src/hooks";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { DeadHostModal, DeleteConfirmModal } from "src/modals"; import { DeadHostModal, DeleteConfirmModal } from "src/modals";
import { showSuccess } from "src/notifications"; import { showSuccess } from "src/notifications";
import Table from "./Table"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [deleteId, setDeleteId] = useState(0); const [deleteId, setDeleteId] = useState(0);
const [editId, setEditId] = useState(0 as number | "new"); const [editId, setEditId] = useState(0 as number | "new");
const { isFetching, isLoading, isError, error, data } = useDeadHosts(["owner", "certificate"]); const { isFetching, isLoading, isError, error, data } = useDeadHosts(["owner", "certificate"]);
@@ -26,27 +22,10 @@ export default function TableWrapper() {
} }
const handleDelete = async () => { const handleDelete = async () => {
await deleteDeadHost(deleteId); // await deleteUser(deleteId);
showSuccess(intl.formatMessage({ id: "notification.host-deleted" })); showSuccess(intl.formatMessage({ id: "notification.host-deleted" }));
}; };
const handleDisableToggle = async (id: number, enabled: boolean) => {
await toggleDeadHost(id, enabled);
queryClient.invalidateQueries({ queryKey: ["dead-hosts"] });
queryClient.invalidateQueries({ queryKey: ["dead-host", id] });
showSuccess(intl.formatMessage({ id: enabled ? "notification.host-enabled" : "notification.host-disabled" }));
};
let filtered = null;
if (search && data) {
filtered = data?.filter((item) => {
return item.domainNames.some((domain: string) => domain.toLowerCase().includes(search));
});
} else if (search !== "") {
// this can happen if someone deletes the last item while searching
setSearch("");
}
return ( return (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-status-top bg-red" /> <div className="card-status-top bg-red" />
@@ -54,11 +33,8 @@ export default function TableWrapper() {
<div className="card-header"> <div className="card-header">
<div className="row w-full"> <div className="row w-full">
<div className="col"> <div className="col">
<h2 className="mt-1 mb-0"> <h2 className="mt-1 mb-0">{intl.formatMessage({ id: "dead-hosts.title" })}</h2>
<T id="dead-hosts.title" />
</h2>
</div> </div>
{data?.length ? (
<div className="col-md-auto col-sm-12"> <div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list"> <div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto"> <div className="input-group input-group-flat w-auto">
@@ -70,35 +46,31 @@ export default function TableWrapper() {
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
autoComplete="off" autoComplete="off"
onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
/> />
</div> </div>
<Button size="sm" className="btn-red" onClick={() => setEditId("new")}> <Button size="sm" className="btn-red" onClick={() => setEditId("new")}>
<T id="dead-hosts.add" /> {intl.formatMessage({ id: "dead-hosts.add" })}
</Button> </Button>
</div> </div>
</div> </div>
) : null}
</div> </div>
</div> </div>
<Table <Table
data={filtered ?? data ?? []} data={data ?? []}
isFiltered={!!search}
isFetching={isFetching} isFetching={isFetching}
onEdit={(id: number) => setEditId(id)} onEdit={(id: number) => setEditId(id)}
onDelete={(id: number) => setDeleteId(id)} onDelete={(id: number) => setDeleteId(id)}
onDisableToggle={handleDisableToggle}
onNew={() => setEditId("new")} onNew={() => setEditId("new")}
/> />
{editId ? <DeadHostModal id={editId} onClose={() => setEditId(0)} /> : null} {editId ? <DeadHostModal id={editId} onClose={() => setEditId(0)} /> : null}
{deleteId ? ( {deleteId ? (
<DeleteConfirmModal <DeleteConfirmModal
title="dead-host.delete.title" title={intl.formatMessage({ id: "user.delete.title" })}
onConfirm={handleDelete} onConfirm={handleDelete}
onClose={() => setDeleteId(0)} onClose={() => setDeleteId(0)}
invalidations={[["dead-hosts"], ["dead-host", deleteId]]} invalidations={[["dead-hosts"], ["dead-host", deleteId]]}
> >
<T id="dead-host.delete.content" /> {intl.formatMessage({ id: "user.delete.content" })}
</DeleteConfirmModal> </DeleteConfirmModal>
) : null} ) : null}
</div> </div>

View File

@@ -1,34 +1,23 @@
import type { Table as ReactTable } from "@tanstack/react-table"; import type { Table as ReactTable } from "@tanstack/react-table";
import { Button } from "src/components"; import { Button } from "src/components";
import { T } from "src/locale"; import { intl } from "src/locale";
/**
* This component should never render as there should always be 1 user minimum,
* but I'm keeping it for consistency.
*/
interface Props { interface Props {
tableInstance: ReactTable<any>; tableInstance: ReactTable<any>;
onNew?: () => void;
isFiltered?: boolean;
} }
export default function Empty({ tableInstance, onNew, isFiltered }: Props) { export default function Empty({ tableInstance }: Props) {
return ( return (
<tr> <tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}> <td colSpan={tableInstance.getVisibleFlatColumns().length}>
<div className="text-center my-4"> <div className="text-center my-4">
{isFiltered ? ( <h2>{intl.formatMessage({ id: "proxy-hosts.empty" })}</h2>
<h2> <p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
<T id="empty.search" /> <Button className="btn-lime my-3">{intl.formatMessage({ id: "proxy-hosts.add" })}</Button>
</h2>
) : (
<>
<h2>
<T id="proxy-hosts.empty" />
</h2>
<p className="text-muted">
<T id="empty-subtitle" />
</p>
<Button className="btn-lime my-3" onClick={onNew}>
<T id="proxy-hosts.add" />
</Button>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -4,19 +4,14 @@ import { useMemo } from "react";
import type { ProxyHost } from "src/api/backend"; import type { ProxyHost } from "src/api/backend";
import { CertificateFormatter, DomainsFormatter, GravatarFormatter, StatusFormatter } from "src/components"; import { CertificateFormatter, DomainsFormatter, GravatarFormatter, StatusFormatter } from "src/components";
import { TableLayout } from "src/components/Table/TableLayout"; import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import Empty from "./Empty"; import Empty from "./Empty";
interface Props { interface Props {
data: ProxyHost[]; data: ProxyHost[];
isFiltered?: boolean;
isFetching?: boolean; isFetching?: boolean;
onEdit?: (id: number) => void;
onDelete?: (id: number) => void;
onDisableToggle?: (id: number, enabled: boolean) => void;
onNew?: () => void;
} }
export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) { export default function Table({ data, isFetching }: Props) {
const columnHelper = createColumnHelper<ProxyHost>(); const columnHelper = createColumnHelper<ProxyHost>();
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -69,7 +64,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
}, },
}), }),
columnHelper.display({ columnHelper.display({
id: "id", id: "id", // todo: not needed for a display?
cell: (info: any) => { cell: (info: any) => {
return ( return (
<span className="dropdown"> <span className="dropdown">
@@ -83,41 +78,25 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
</button> </button>
<div className="dropdown-menu dropdown-menu-end"> <div className="dropdown-menu dropdown-menu-end">
<span className="dropdown-header"> <span className="dropdown-header">
<T id="proxy-hosts.actions-title" data={{ id: info.row.original.id }} /> {intl.formatMessage(
{
id: "proxy-hosts.actions-title",
},
{ id: info.row.original.id },
)}
</span> </span>
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onEdit?.(info.row.original.id);
}}
>
<IconEdit size={16} /> <IconEdit size={16} />
<T id="action.edit" /> {intl.formatMessage({ id: "action.edit" })}
</a> </a>
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDisableToggle?.(info.row.original.id, !info.row.original.enabled);
}}
>
<IconPower size={16} /> <IconPower size={16} />
<T id={info.row.original.enabled ? "action.disable" : "action.enable"} /> {intl.formatMessage({ id: "action.disable" })}
</a> </a>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDelete?.(info.row.original.id);
}}
>
<IconTrash size={16} /> <IconTrash size={16} />
<T id="action.delete" /> {intl.formatMessage({ id: "action.delete" })}
</a> </a>
</div> </div>
</span> </span>
@@ -128,7 +107,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
}, },
}), }),
], ],
[columnHelper, onEdit, onDisableToggle, onDelete], [columnHelper],
); );
const tableInstance = useReactTable<ProxyHost>({ const tableInstance = useReactTable<ProxyHost>({
@@ -142,10 +121,5 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
enableSortingRemoval: false, enableSortingRemoval: false,
}); });
return ( return <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} />} />;
<TableLayout
tableInstance={tableInstance}
emptyState={<Empty tableInstance={tableInstance} onNew={onNew} isFiltered={isFiltered} />}
/>
);
} }

View File

@@ -1,20 +1,11 @@
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { deleteProxyHost, toggleProxyHost } from "src/api/backend";
import { Button, LoadingPage } from "src/components"; import { Button, LoadingPage } from "src/components";
import { useProxyHosts } from "src/hooks"; import { useProxyHosts } from "src/hooks";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { DeleteConfirmModal, ProxyHostModal } from "src/modals";
import { showSuccess } from "src/notifications";
import Table from "./Table"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [deleteId, setDeleteId] = useState(0);
const [editId, setEditId] = useState(0 as number | "new");
const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"]); const { isFetching, isLoading, isError, error, data } = useProxyHosts(["owner", "access_list", "certificate"]);
if (isLoading) { if (isLoading) {
@@ -25,32 +16,6 @@ export default function TableWrapper() {
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>; return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
} }
const handleDelete = async () => {
await deleteProxyHost(deleteId);
showSuccess(intl.formatMessage({ id: "notification.host-deleted" }));
};
const handleDisableToggle = async (id: number, enabled: boolean) => {
await toggleProxyHost(id, enabled);
queryClient.invalidateQueries({ queryKey: ["proxy-hosts"] });
queryClient.invalidateQueries({ queryKey: ["proxy-host", id] });
showSuccess(intl.formatMessage({ id: enabled ? "notification.host-enabled" : "notification.host-disabled" }));
};
let filtered = null;
if (search && data) {
filtered = data?.filter((_item) => {
return true;
// TODO
// item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) ||
// item.forwardDomainName.toLowerCase().includes(search)
// );
});
} else if (search !== "") {
// this can happen if someone deletes the last item while searching
setSearch("");
}
return ( return (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-status-top bg-lime" /> <div className="card-status-top bg-lime" />
@@ -58,11 +23,8 @@ export default function TableWrapper() {
<div className="card-header"> <div className="card-header">
<div className="row w-full"> <div className="row w-full">
<div className="col"> <div className="col">
<h2 className="mt-1 mb-0"> <h2 className="mt-1 mb-0">{intl.formatMessage({ id: "proxy-hosts.title" })}</h2>
<T id="proxy-hosts.title" />
</h2>
</div> </div>
{data?.length ? (
<div className="col-md-auto col-sm-12"> <div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list"> <div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto"> <div className="input-group input-group-flat w-auto">
@@ -77,33 +39,13 @@ export default function TableWrapper() {
/> />
</div> </div>
<Button size="sm" className="btn-lime"> <Button size="sm" className="btn-lime">
<T id="proxy-hosts.add" /> {intl.formatMessage({ id: "proxy-hosts.add" })}
</Button> </Button>
</div> </div>
</div> </div>
) : null}
</div> </div>
</div> </div>
<Table <Table data={data ?? []} isFetching={isFetching} />
data={filtered ?? data ?? []}
isFiltered={!!search}
isFetching={isFetching}
onEdit={(id: number) => setEditId(id)}
onDelete={(id: number) => setDeleteId(id)}
onDisableToggle={handleDisableToggle}
onNew={() => setEditId("new")}
/>
{editId ? <ProxyHostModal id={editId} onClose={() => setEditId(0)} /> : null}
{deleteId ? (
<DeleteConfirmModal
title="proxy-host.delete.title"
onConfirm={handleDelete}
onClose={() => setDeleteId(0)}
invalidations={[["proxy-hosts"], ["proxy-host", deleteId]]}
>
<T id="proxy-host.delete.content" />
</DeleteConfirmModal>
) : null}
</div> </div>
</div> </div>
); );

View File

@@ -1,34 +1,18 @@
import type { Table as ReactTable } from "@tanstack/react-table"; import type { Table as ReactTable } from "@tanstack/react-table";
import { Button } from "src/components"; import { Button } from "src/components";
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
tableInstance: ReactTable<any>; tableInstance: ReactTable<any>;
onNew?: () => void;
isFiltered?: boolean;
} }
export default function Empty({ tableInstance, onNew, isFiltered }: Props) { export default function Empty({ tableInstance }: Props) {
return ( return (
<tr> <tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}> <td colSpan={tableInstance.getVisibleFlatColumns().length}>
<div className="text-center my-4"> <div className="text-center my-4">
{isFiltered ? ( <h2>{intl.formatMessage({ id: "redirection-hosts.empty" })}</h2>
<h2> <p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
<T id="empty.search" /> <Button className="btn-yellow my-3">{intl.formatMessage({ id: "redirection-hosts.add" })}</Button>
</h2>
) : (
<>
<h2>
<T id="redirection-hosts.empty" />
</h2>
<p className="text-muted">
<T id="empty-subtitle" />
</p>
<Button className="btn-yellow my-3" onClick={onNew}>
<T id="redirection-hosts.add" />
</Button>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -4,19 +4,14 @@ import { useMemo } from "react";
import type { RedirectionHost } from "src/api/backend"; import type { RedirectionHost } from "src/api/backend";
import { CertificateFormatter, DomainsFormatter, GravatarFormatter, StatusFormatter } from "src/components"; import { CertificateFormatter, DomainsFormatter, GravatarFormatter, StatusFormatter } from "src/components";
import { TableLayout } from "src/components/Table/TableLayout"; import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import Empty from "./Empty"; import Empty from "./Empty";
interface Props { interface Props {
data: RedirectionHost[]; data: RedirectionHost[];
isFiltered?: boolean;
isFetching?: boolean; isFetching?: boolean;
onEdit?: (id: number) => void;
onDelete?: (id: number) => void;
onDisableToggle?: (id: number, enabled: boolean) => void;
onNew?: () => void;
} }
export default function Table({ data, isFetching, onEdit, onDelete, onDisableToggle, onNew, isFiltered }: Props) { export default function Table({ data, isFetching }: Props) {
const columnHelper = createColumnHelper<RedirectionHost>(); const columnHelper = createColumnHelper<RedirectionHost>();
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -74,7 +69,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
}, },
}), }),
columnHelper.display({ columnHelper.display({
id: "id", id: "id", // todo: not needed for a display?
cell: (info: any) => { cell: (info: any) => {
return ( return (
<span className="dropdown"> <span className="dropdown">
@@ -88,41 +83,25 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
</button> </button>
<div className="dropdown-menu dropdown-menu-end"> <div className="dropdown-menu dropdown-menu-end">
<span className="dropdown-header"> <span className="dropdown-header">
<T id="redirection-hosts.actions-title" data={{ id: info.row.original.id }} /> {intl.formatMessage(
{
id: "redirection-hosts.actions-title",
},
{ id: info.row.original.id },
)}
</span> </span>
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onEdit?.(info.row.original.id);
}}
>
<IconEdit size={16} /> <IconEdit size={16} />
<T id="action.edit" /> {intl.formatMessage({ id: "action.edit" })}
</a> </a>
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDisableToggle?.(info.row.original.id, !info.row.original.enabled);
}}
>
<IconPower size={16} /> <IconPower size={16} />
<T id={info.row.original.enabled ? "action.disable" : "action.enable"} /> {intl.formatMessage({ id: "action.disable" })}
</a> </a>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDelete?.(info.row.original.id);
}}
>
<IconTrash size={16} /> <IconTrash size={16} />
<T id="action.delete" /> {intl.formatMessage({ id: "action.delete" })}
</a> </a>
</div> </div>
</span> </span>
@@ -133,7 +112,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
}, },
}), }),
], ],
[columnHelper, onEdit, onDisableToggle, onDelete], [columnHelper],
); );
const tableInstance = useReactTable<RedirectionHost>({ const tableInstance = useReactTable<RedirectionHost>({
@@ -147,10 +126,5 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
enableSortingRemoval: false, enableSortingRemoval: false,
}); });
return ( return <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} />} />;
<TableLayout
tableInstance={tableInstance}
emptyState={<Empty tableInstance={tableInstance} onNew={onNew} isFiltered={isFiltered} />}
/>
);
} }

View File

@@ -1,20 +1,11 @@
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { deleteRedirectionHost, toggleRedirectionHost } from "src/api/backend";
import { Button, LoadingPage } from "src/components"; import { Button, LoadingPage } from "src/components";
import { useRedirectionHosts } from "src/hooks"; import { useRedirectionHosts } from "src/hooks";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { DeleteConfirmModal, RedirectionHostModal } from "src/modals";
import { showSuccess } from "src/notifications";
import Table from "./Table"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [deleteId, setDeleteId] = useState(0);
const [editId, setEditId] = useState(0 as number | "new");
const { isFetching, isLoading, isError, error, data } = useRedirectionHosts(["owner", "certificate"]); const { isFetching, isLoading, isError, error, data } = useRedirectionHosts(["owner", "certificate"]);
if (isLoading) { if (isLoading) {
@@ -25,31 +16,6 @@ export default function TableWrapper() {
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>; return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
} }
const handleDelete = async () => {
await deleteRedirectionHost(deleteId);
showSuccess(intl.formatMessage({ id: "notification.host-deleted" }));
};
const handleDisableToggle = async (id: number, enabled: boolean) => {
await toggleRedirectionHost(id, enabled);
queryClient.invalidateQueries({ queryKey: ["redirection-hosts"] });
queryClient.invalidateQueries({ queryKey: ["redirection-host", id] });
showSuccess(intl.formatMessage({ id: enabled ? "notification.host-enabled" : "notification.host-disabled" }));
};
let filtered = null;
if (search && data) {
filtered = data?.filter((item) => {
return (
item.domainNames.some((domain: string) => domain.toLowerCase().includes(search)) ||
item.forwardDomainName.toLowerCase().includes(search)
);
});
} else if (search !== "") {
// this can happen if someone deletes the last item while searching
setSearch("");
}
return ( return (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-status-top bg-yellow" /> <div className="card-status-top bg-yellow" />
@@ -57,11 +23,8 @@ export default function TableWrapper() {
<div className="card-header"> <div className="card-header">
<div className="row w-full"> <div className="row w-full">
<div className="col"> <div className="col">
<h2 className="mt-1 mb-0"> <h2 className="mt-1 mb-0">{intl.formatMessage({ id: "redirection-hosts.title" })}</h2>
<T id="redirection-hosts.title" />
</h2>
</div> </div>
{data?.length ? (
<div className="col-md-auto col-sm-12"> <div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list"> <div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto"> <div className="input-group input-group-flat w-auto">
@@ -73,37 +36,16 @@ export default function TableWrapper() {
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
autoComplete="off" autoComplete="off"
onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
/> />
</div> </div>
<Button size="sm" className="btn-yellow" onClick={() => setEditId("new")}> <Button size="sm" className="btn-yellow">
<T id="redirection-hosts.add" /> {intl.formatMessage({ id: "redirection-hosts.add" })}
</Button> </Button>
</div> </div>
</div> </div>
) : null}
</div> </div>
</div> </div>
<Table <Table data={data ?? []} isFetching={isFetching} />
data={filtered ?? data ?? []}
isFiltered={!!search}
isFetching={isFetching}
onEdit={(id: number) => setEditId(id)}
onDelete={(id: number) => setDeleteId(id)}
onDisableToggle={handleDisableToggle}
onNew={() => setEditId("new")}
/>
{editId ? <RedirectionHostModal id={editId} onClose={() => setEditId(0)} /> : null}
{deleteId ? (
<DeleteConfirmModal
title="redirection-host.delete.title"
onConfirm={handleDelete}
onClose={() => setDeleteId(0)}
invalidations={[["redirection-hosts"], ["redirection-host", deleteId]]}
>
<T id="redirection-host.delete.content" />
</DeleteConfirmModal>
) : null}
</div> </div>
</div> </div>
); );

View File

@@ -1,34 +1,18 @@
import type { Table as ReactTable } from "@tanstack/react-table"; import type { Table as ReactTable } from "@tanstack/react-table";
import { Button } from "src/components"; import { Button } from "src/components";
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
tableInstance: ReactTable<any>; tableInstance: ReactTable<any>;
onNew?: () => void;
isFiltered?: boolean;
} }
export default function Empty({ tableInstance, onNew, isFiltered }: Props) { export default function Empty({ tableInstance }: Props) {
return ( return (
<tr> <tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}> <td colSpan={tableInstance.getVisibleFlatColumns().length}>
<div className="text-center my-4"> <div className="text-center my-4">
{isFiltered ? ( <h2>{intl.formatMessage({ id: "streams.empty" })}</h2>
<h2> <p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
<T id="empty.search" /> <Button className="btn-blue my-3">{intl.formatMessage({ id: "streams.add" })}</Button>
</h2>
) : (
<>
<h2>
<T id="streams.empty" />
</h2>
<p className="text-muted">
<T id="empty-subtitle" />
</p>
<Button className="btn-blue my-3" onClick={onNew}>
<T id="streams.add" />
</Button>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -2,21 +2,16 @@ import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo } from "react"; import { useMemo } from "react";
import type { Stream } from "src/api/backend"; import type { Stream } from "src/api/backend";
import { CertificateFormatter, GravatarFormatter, StatusFormatter, ValueWithDateFormatter } from "src/components"; import { CertificateFormatter, DomainsFormatter, GravatarFormatter, StatusFormatter } from "src/components";
import { TableLayout } from "src/components/Table/TableLayout"; import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import Empty from "./Empty"; import Empty from "./Empty";
interface Props { interface Props {
data: Stream[]; data: Stream[];
isFiltered?: boolean;
isFetching?: boolean; isFetching?: boolean;
onEdit?: (id: number) => void;
onDelete?: (id: number) => void;
onDisableToggle?: (id: number, enabled: boolean) => void;
onNew?: () => void;
} }
export default function Table({ data, isFetching, isFiltered, onEdit, onDelete, onDisableToggle, onNew }: Props) { export default function Table({ data, isFetching }: Props) {
const columnHelper = createColumnHelper<Stream>(); const columnHelper = createColumnHelper<Stream>();
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -35,7 +30,8 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
header: intl.formatMessage({ id: "column.incoming-port" }), header: intl.formatMessage({ id: "column.incoming-port" }),
cell: (info: any) => { cell: (info: any) => {
const value = info.getValue(); const value = info.getValue();
return <ValueWithDateFormatter value={value.incomingPort} createdOn={value.createdOn} />; // Bit of a hack to reuse the DomainsFormatter component
return <DomainsFormatter domains={[value.incomingPort]} createdOn={value.createdOn} />;
}, },
}), }),
columnHelper.accessor((row: any) => row, { columnHelper.accessor((row: any) => row, {
@@ -55,12 +51,12 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
<> <>
{value.tcpForwarding ? ( {value.tcpForwarding ? (
<span className="badge badge-lg domain-name"> <span className="badge badge-lg domain-name">
<T id="streams.tcp" /> {intl.formatMessage({ id: "streams.tcp" })}
</span> </span>
) : null} ) : null}
{value.udpForwarding ? ( {value.udpForwarding ? (
<span className="badge badge-lg domain-name"> <span className="badge badge-lg domain-name">
<T id="streams.udp" /> {intl.formatMessage({ id: "streams.udp" })}
</span> </span>
) : null} ) : null}
</> </>
@@ -96,41 +92,25 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
</button> </button>
<div className="dropdown-menu dropdown-menu-end"> <div className="dropdown-menu dropdown-menu-end">
<span className="dropdown-header"> <span className="dropdown-header">
<T id="streams.actions-title" data={{ id: info.row.original.id }} /> {intl.formatMessage(
{
id: "streams.actions-title",
},
{ id: info.row.original.id },
)}
</span> </span>
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onEdit?.(info.row.original.id);
}}
>
<IconEdit size={16} /> <IconEdit size={16} />
<T id="action.edit" /> {intl.formatMessage({ id: "action.edit" })}
</a> </a>
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDisableToggle?.(info.row.original.id, !info.row.original.enabled);
}}
>
<IconPower size={16} /> <IconPower size={16} />
<T id="action.disable" /> {intl.formatMessage({ id: "action.disable" })}
</a> </a>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a <a className="dropdown-item" href="#">
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDelete?.(info.row.original.id);
}}
>
<IconTrash size={16} /> <IconTrash size={16} />
<T id="action.delete" /> {intl.formatMessage({ id: "action.delete" })}
</a> </a>
</div> </div>
</span> </span>
@@ -141,7 +121,7 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
}, },
}), }),
], ],
[columnHelper, onEdit, onDisableToggle, onDelete], [columnHelper],
); );
const tableInstance = useReactTable<Stream>({ const tableInstance = useReactTable<Stream>({
@@ -155,10 +135,5 @@ export default function Table({ data, isFetching, isFiltered, onEdit, onDelete,
enableSortingRemoval: false, enableSortingRemoval: false,
}); });
return ( return <TableLayout tableInstance={tableInstance} emptyState={<Empty tableInstance={tableInstance} />} />;
<TableLayout
tableInstance={tableInstance}
emptyState={<Empty tableInstance={tableInstance} onNew={onNew} isFiltered={isFiltered} />}
/>
);
} }

View File

@@ -1,20 +1,11 @@
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { deleteStream, toggleStream } from "src/api/backend";
import { Button, LoadingPage } from "src/components"; import { Button, LoadingPage } from "src/components";
import { useStreams } from "src/hooks"; import { useStreams } from "src/hooks";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { DeleteConfirmModal, StreamModal } from "src/modals";
import { showSuccess } from "src/notifications";
import Table from "./Table"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [editId, setEditId] = useState(0 as number | "new");
const [deleteId, setDeleteId] = useState(0);
const { isFetching, isLoading, isError, error, data } = useStreams(["owner", "certificate"]); const { isFetching, isLoading, isError, error, data } = useStreams(["owner", "certificate"]);
if (isLoading) { if (isLoading) {
@@ -25,34 +16,6 @@ export default function TableWrapper() {
return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>; return <Alert variant="danger">{error?.message || "Unknown error"}</Alert>;
} }
const handleDelete = async () => {
await deleteStream(deleteId);
showSuccess(intl.formatMessage({ id: "notification.stream-deleted" }));
};
const handleDisableToggle = async (id: number, enabled: boolean) => {
await toggleStream(id, enabled);
queryClient.invalidateQueries({ queryKey: ["streams"] });
queryClient.invalidateQueries({ queryKey: ["stream", id] });
showSuccess(
intl.formatMessage({ id: enabled ? "notification.stream-enabled" : "notification.stream-disabled" }),
);
};
let filtered = null;
if (search && data) {
filtered = data?.filter((item) => {
return (
`${item.incomingPort}`.includes(search) ||
`${item.forwardingPort}`.includes(search) ||
item.forwardingHost.includes(search)
);
});
} else if (search !== "") {
// this can happen if someone deletes the last item while searching
setSearch("");
}
return ( return (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-status-top bg-blue" /> <div className="card-status-top bg-blue" />
@@ -60,11 +23,8 @@ export default function TableWrapper() {
<div className="card-header"> <div className="card-header">
<div className="row w-full"> <div className="row w-full">
<div className="col"> <div className="col">
<h2 className="mt-1 mb-0"> <h2 className="mt-1 mb-0">{intl.formatMessage({ id: "streams.title" })}</h2>
<T id="streams.title" />
</h2>
</div> </div>
{data?.length ? (
<div className="col-md-auto col-sm-12"> <div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list"> <div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto"> <div className="input-group input-group-flat w-auto">
@@ -76,37 +36,16 @@ export default function TableWrapper() {
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
autoComplete="off" autoComplete="off"
onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
/> />
</div> </div>
<Button size="sm" className="btn-blue" onClick={() => setEditId("new")}> <Button size="sm" className="btn-blue">
<T id="streams.add" /> {intl.formatMessage({ id: "streams.add" })}
</Button> </Button>
</div> </div>
</div> </div>
) : null}
</div> </div>
</div> </div>
<Table <Table data={data ?? []} isFetching={isFetching} />
data={filtered ?? data ?? []}
isFetching={isFetching}
isFiltered={!!filtered}
onEdit={(id: number) => setEditId(id)}
onDelete={(id: number) => setDeleteId(id)}
onDisableToggle={handleDisableToggle}
onNew={() => setEditId("new")}
/>
{editId ? <StreamModal id={editId} onClose={() => setEditId(0)} /> : null}
{deleteId ? (
<DeleteConfirmModal
title="stream.delete.title"
onConfirm={handleDelete}
onClose={() => setDeleteId(0)}
invalidations={[["streams"], ["stream", deleteId]]}
>
<T id="stream.delete.content" />
</DeleteConfirmModal>
) : null}
</div> </div>
</div> </div>
); );

View File

@@ -1,16 +1,14 @@
import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react"; import { IconDotsVertical, IconEdit, IconPower, IconTrash } from "@tabler/icons-react";
import { T } from "src/locale"; import { intl } from "src/locale";
export default function SettingTable() { export default function AuditTable() {
return ( return (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-status-top bg-teal" /> <div className="card-status-top bg-teal" />
<div className="card-table"> <div className="card-table">
<div className="card-header"> <div className="card-header">
<div className="row w-full"> <div className="row w-full">
<h2 className="mt-1 mb-0"> <h2 className="mt-1 mb-0">{intl.formatMessage({ id: "settings.title" })}</h2>
<T id="settings.title" />
</h2>
</div> </div>
</div> </div>
<div id="advanced-table"> <div id="advanced-table">

View File

@@ -6,7 +6,7 @@ import { Alert } from "react-bootstrap";
import { createUser } from "src/api/backend"; import { createUser } from "src/api/backend";
import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components"; import { Button, LocalePicker, Page, ThemeSwitcher } from "src/components";
import { useAuthState } from "src/context"; import { useAuthState } from "src/context";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { validateEmail, validateString } from "src/modules/Validations"; import { validateEmail, validateString } from "src/modules/Validations";
import styles from "./index.module.css"; import styles from "./index.module.css";
@@ -89,12 +89,8 @@ export default function Setup() {
{({ isSubmitting }) => ( {({ isSubmitting }) => (
<Form> <Form>
<div className="card-body text-center py-4 p-sm-5"> <div className="card-body text-center py-4 p-sm-5">
<h1 className="mt-5"> <h1 className="mt-5">{intl.formatMessage({ id: "setup.title" })}</h1>
<T id="setup.title" /> <p className="text-secondary">{intl.formatMessage({ id: "setup.preamble" })}</p>
</h1>
<p className="text-secondary">
<T id="setup.preamble" />
</p>
</div> </div>
<hr /> <hr />
<div className="card-body"> <div className="card-body">
@@ -109,7 +105,7 @@ export default function Setup() {
{...field} {...field}
/> />
<label htmlFor="name"> <label htmlFor="name">
<T id="user.full-name" /> {intl.formatMessage({ id: "user.full-name" })}
</label> </label>
{form.errors.name ? ( {form.errors.name ? (
<div className="invalid-feedback"> <div className="invalid-feedback">
@@ -134,7 +130,7 @@ export default function Setup() {
{...field} {...field}
/> />
<label htmlFor="email"> <label htmlFor="email">
<T id="email-address" /> {intl.formatMessage({ id: "email-address" })}
</label> </label>
{form.errors.email ? ( {form.errors.email ? (
<div className="invalid-feedback"> <div className="invalid-feedback">
@@ -159,7 +155,7 @@ export default function Setup() {
{...field} {...field}
/> />
<label htmlFor="password"> <label htmlFor="password">
<T id="user.new-password" /> {intl.formatMessage({ id: "user.new-password" })}
</label> </label>
{form.errors.password ? ( {form.errors.password ? (
<div className="invalid-feedback"> <div className="invalid-feedback">
@@ -182,7 +178,7 @@ export default function Setup() {
disabled={isSubmitting} disabled={isSubmitting}
className="w-100" className="w-100"
> >
<T id="save" /> {intl.formatMessage({ id: "save" })}
</Button> </Button>
</div> </div>
</Form> </Form>

View File

@@ -1,34 +1,21 @@
import type { Table as ReactTable } from "@tanstack/react-table"; import type { Table as ReactTable } from "@tanstack/react-table";
import { Button } from "src/components"; import { Button } from "src/components";
import { T } from "src/locale"; import { intl } from "src/locale";
interface Props { interface Props {
tableInstance: ReactTable<any>; tableInstance: ReactTable<any>;
onNewUser?: () => void; onNewUser?: () => void;
isFiltered?: boolean;
} }
export default function Empty({ tableInstance, onNewUser, isFiltered }: Props) { export default function Empty({ tableInstance, onNewUser }: Props) {
return ( return (
<tr> <tr>
<td colSpan={tableInstance.getVisibleFlatColumns().length}> <td colSpan={tableInstance.getVisibleFlatColumns().length}>
<div className="text-center my-4"> <div className="text-center my-4">
{isFiltered ? ( <h2>{intl.formatMessage({ id: "proxy-hosts.empty" })}</h2>
<h2> <p className="text-muted">{intl.formatMessage({ id: "empty-subtitle" })}</p>
<T id="empty-search" /> <Button className="btn-lime my-3" onClick={onNewUser}>
</h2> {intl.formatMessage({ id: "proxy-hosts.add" })}
) : (
<>
<h2>
<T id="users.empty" />
</h2>
<p className="text-muted">
<T id="empty-subtitle" />
</p>
<Button className="btn-orange my-3" onClick={onNewUser}>
<T id="users.add" />
</Button> </Button>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -1,40 +1,30 @@
import { IconDotsVertical, IconEdit, IconLock, IconPower, IconShield, IconTrash } from "@tabler/icons-react"; import { IconDotsVertical, IconEdit, IconLock, IconShield, IconTrash } from "@tabler/icons-react";
import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table"; import { createColumnHelper, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useMemo } from "react"; import { useMemo } from "react";
import type { User } from "src/api/backend"; import type { User } from "src/api/backend";
import { import { EmailFormatter, GravatarFormatter, RolesFormatter, ValueWithDateFormatter } from "src/components";
EmailFormatter,
EnabledFormatter,
GravatarFormatter,
RolesFormatter,
ValueWithDateFormatter,
} from "src/components";
import { TableLayout } from "src/components/Table/TableLayout"; import { TableLayout } from "src/components/Table/TableLayout";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import Empty from "./Empty"; import Empty from "./Empty";
interface Props { interface Props {
data: User[]; data: User[];
isFiltered?: boolean;
isFetching?: boolean; isFetching?: boolean;
currentUserId?: number; currentUserId?: number;
onEditUser?: (id: number) => void; onEditUser?: (id: number) => void;
onEditPermissions?: (id: number) => void; onEditPermissions?: (id: number) => void;
onSetPassword?: (id: number) => void; onSetPassword?: (id: number) => void;
onDeleteUser?: (id: number) => void; onDeleteUser?: (id: number) => void;
onDisableToggle?: (id: number, enabled: boolean) => void;
onNewUser?: () => void; onNewUser?: () => void;
} }
export default function Table({ export default function Table({
data, data,
isFiltered,
isFetching, isFetching,
currentUserId, currentUserId,
onEditUser, onEditUser,
onEditPermissions, onEditPermissions,
onSetPassword, onSetPassword,
onDeleteUser, onDeleteUser,
onDisableToggle,
onNewUser, onNewUser,
}: Props) { }: Props) {
const columnHelper = createColumnHelper<User>(); const columnHelper = createColumnHelper<User>();
@@ -72,6 +62,7 @@ export default function Table({
return <EmailFormatter email={info.getValue()} />; return <EmailFormatter email={info.getValue()} />;
}, },
}), }),
// TODO: formatter for roles
columnHelper.accessor((row: any) => row.roles, { columnHelper.accessor((row: any) => row.roles, {
id: "roles", id: "roles",
header: intl.formatMessage({ id: "column.roles" }), header: intl.formatMessage({ id: "column.roles" }),
@@ -79,13 +70,6 @@ export default function Table({
return <RolesFormatter roles={info.getValue()} />; return <RolesFormatter roles={info.getValue()} />;
}, },
}), }),
columnHelper.accessor((row: any) => row.isDisabled, {
id: "isDisabled",
header: intl.formatMessage({ id: "column.status" }),
cell: (info: any) => {
return <EnabledFormatter enabled={!info.getValue()} />;
},
}),
columnHelper.display({ columnHelper.display({
id: "id", // todo: not needed for a display? id: "id", // todo: not needed for a display?
cell: (info: any) => { cell: (info: any) => {
@@ -101,7 +85,12 @@ export default function Table({
</button> </button>
<div className="dropdown-menu dropdown-menu-end"> <div className="dropdown-menu dropdown-menu-end">
<span className="dropdown-header"> <span className="dropdown-header">
<T id="users.actions-title" data={{ id: info.row.original.id }} /> {intl.formatMessage(
{
id: "users.actions-title",
},
{ id: info.row.original.id },
)}
</span> </span>
<a <a
className="dropdown-item" className="dropdown-item"
@@ -112,7 +101,7 @@ export default function Table({
}} }}
> >
<IconEdit size={16} /> <IconEdit size={16} />
<T id="users.edit" /> {intl.formatMessage({ id: "user.edit" })}
</a> </a>
{currentUserId !== info.row.original.id ? ( {currentUserId !== info.row.original.id ? (
<> <>
@@ -125,7 +114,7 @@ export default function Table({
}} }}
> >
<IconShield size={16} /> <IconShield size={16} />
<T id="action.permissions" /> {intl.formatMessage({ id: "action.permissions" })}
</a> </a>
<a <a
className="dropdown-item" className="dropdown-item"
@@ -136,18 +125,7 @@ export default function Table({
}} }}
> >
<IconLock size={16} /> <IconLock size={16} />
<T id="user.set-password" /> {intl.formatMessage({ id: "user.set-password" })}
</a>
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onDisableToggle?.(info.row.original.id, info.row.original.isDisabled);
}}
>
<IconPower size={16} />
<T id={info.row.original.isDisabled ? "action.enable" : "action.disable"} />
</a> </a>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
<a <a
@@ -159,7 +137,7 @@ export default function Table({
}} }}
> >
<IconTrash size={16} /> <IconTrash size={16} />
<T id="action.delete" /> {intl.formatMessage({ id: "action.delete" })}
</a> </a>
</> </>
) : null} ) : null}
@@ -172,7 +150,7 @@ export default function Table({
}, },
}), }),
], ],
[columnHelper, currentUserId, onEditUser, onDisableToggle, onDeleteUser, onEditPermissions, onSetPassword], [columnHelper, currentUserId, onEditUser, onDeleteUser, onEditPermissions, onSetPassword],
); );
const tableInstance = useReactTable<User>({ const tableInstance = useReactTable<User>({
@@ -189,7 +167,7 @@ export default function Table({
return ( return (
<TableLayout <TableLayout
tableInstance={tableInstance} tableInstance={tableInstance}
emptyState={<Empty tableInstance={tableInstance} onNewUser={onNewUser} isFiltered={isFiltered} />} emptyState={<Empty tableInstance={tableInstance} onNewUser={onNewUser} />}
/> />
); );
} }

View File

@@ -1,18 +1,15 @@
import { IconSearch } from "@tabler/icons-react"; import { IconSearch } from "@tabler/icons-react";
import { useQueryClient } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import Alert from "react-bootstrap/Alert"; import Alert from "react-bootstrap/Alert";
import { deleteUser, toggleUser } from "src/api/backend"; import { deleteUser } from "src/api/backend";
import { Button, LoadingPage } from "src/components"; import { Button, LoadingPage } from "src/components";
import { useUser, useUsers } from "src/hooks"; import { useUser, useUsers } from "src/hooks";
import { intl, T } from "src/locale"; import { intl } from "src/locale";
import { DeleteConfirmModal, PermissionsModal, SetPasswordModal, UserModal } from "src/modals"; import { DeleteConfirmModal, PermissionsModal, SetPasswordModal, UserModal } from "src/modals";
import { showSuccess } from "src/notifications"; import { showSuccess } from "src/notifications";
import Table from "./Table"; import Table from "./Table";
export default function TableWrapper() { export default function TableWrapper() {
const queryClient = useQueryClient();
const [search, setSearch] = useState("");
const [editUserId, setEditUserId] = useState(0 as number | "new"); const [editUserId, setEditUserId] = useState(0 as number | "new");
const [editUserPermissionsId, setEditUserPermissionsId] = useState(0); const [editUserPermissionsId, setEditUserPermissionsId] = useState(0);
const [editUserPasswordId, setEditUserPasswordId] = useState(0); const [editUserPasswordId, setEditUserPasswordId] = useState(0);
@@ -33,27 +30,6 @@ export default function TableWrapper() {
showSuccess(intl.formatMessage({ id: "notification.user-deleted" })); showSuccess(intl.formatMessage({ id: "notification.user-deleted" }));
}; };
const handleDisableToggle = async (id: number, enabled: boolean) => {
await toggleUser(id, enabled);
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", id] });
showSuccess(intl.formatMessage({ id: enabled ? "notification.user-enabled" : "notification.user-disabled" }));
};
let filtered = null;
if (search && data) {
filtered = data?.filter((item) => {
return (
item.name.toLowerCase().includes(search) ||
item.nickname.toLowerCase().includes(search) ||
item.email.toLowerCase().includes(search)
);
});
} else if (search !== "") {
// this can happen if someone deletes the last item while searching
setSearch("");
}
return ( return (
<div className="card mt-4"> <div className="card mt-4">
<div className="card-status-top bg-orange" /> <div className="card-status-top bg-orange" />
@@ -61,11 +37,8 @@ export default function TableWrapper() {
<div className="card-header"> <div className="card-header">
<div className="row w-full"> <div className="row w-full">
<div className="col"> <div className="col">
<h2 className="mt-1 mb-0"> <h2 className="mt-1 mb-0">{intl.formatMessage({ id: "users.title" })}</h2>
<T id="users.title" />
</h2>
</div> </div>
{data?.length ? (
<div className="col-md-auto col-sm-12"> <div className="col-md-auto col-sm-12">
<div className="ms-auto d-flex flex-wrap btn-list"> <div className="ms-auto d-flex flex-wrap btn-list">
<div className="input-group input-group-flat w-auto"> <div className="input-group input-group-flat w-auto">
@@ -77,28 +50,23 @@ export default function TableWrapper() {
type="text" type="text"
className="form-control form-control-sm" className="form-control form-control-sm"
autoComplete="off" autoComplete="off"
onChange={(e: any) => setSearch(e.target.value.toLowerCase().trim())}
/> />
</div> </div>
<Button size="sm" className="btn-orange" onClick={() => setEditUserId("new")}> <Button size="sm" className="btn-orange" onClick={() => setEditUserId("new")}>
<T id="users.add" /> {intl.formatMessage({ id: "users.add" })}
</Button> </Button>
</div> </div>
</div> </div>
) : null}
</div> </div>
</div> </div>
<Table <Table
data={filtered ?? data ?? []} data={data ?? []}
isFiltered={!!search}
isFetching={isFetching} isFetching={isFetching}
currentUserId={currentUser?.id} currentUserId={currentUser?.id}
onEditUser={(id: number) => setEditUserId(id)} onEditUser={(id: number) => setEditUserId(id)}
onEditPermissions={(id: number) => setEditUserPermissionsId(id)} onEditPermissions={(id: number) => setEditUserPermissionsId(id)}
onSetPassword={(id: number) => setEditUserPasswordId(id)} onSetPassword={(id: number) => setEditUserPasswordId(id)}
onDeleteUser={(id: number) => setDeleteUserId(id)} onDeleteUser={(id: number) => setDeleteUserId(id)}
onDisableToggle={handleDisableToggle}
onNewUser={() => setEditUserId("new")} onNewUser={() => setEditUserId("new")}
/> />
{editUserId ? <UserModal userId={editUserId} onClose={() => setEditUserId(0)} /> : null} {editUserId ? <UserModal userId={editUserId} onClose={() => setEditUserId(0)} /> : null}
@@ -107,12 +75,12 @@ export default function TableWrapper() {
) : null} ) : null}
{deleteUserId ? ( {deleteUserId ? (
<DeleteConfirmModal <DeleteConfirmModal
title="user.delete.title" title={intl.formatMessage({ id: "user.delete.title" })}
onConfirm={handleDelete} onConfirm={handleDelete}
onClose={() => setDeleteUserId(0)} onClose={() => setDeleteUserId(0)}
invalidations={[["users"], ["user", deleteUserId]]} invalidations={[["users"], ["user", deleteUserId]]}
> >
<T id="user.delete.content" /> {intl.formatMessage({ id: "user.delete.content" })}
</DeleteConfirmModal> </DeleteConfirmModal>
) : null} ) : null}
{editUserPasswordId ? ( {editUserPasswordId ? (

View File

@@ -19,12 +19,6 @@ export default defineConfig({
throw error; throw error;
} }
console.log(stdout); console.log(stdout);
execFile("yarn", ["locale-sort"], (error, stdout, _stderr) => {
if (error) {
throw error;
}
console.log(stdout);
});
}); });
} }
}); });

View File

@@ -5,6 +5,7 @@ DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo -e "${BLUE} ${CYAN}Building docker multiarch: ${YELLOW}${*}${RESET}" echo -e "${BLUE} ${CYAN}Building docker multiarch: ${YELLOW}${*}${RESET}"
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${DIR}/.." || exit 1 cd "${DIR}/.." || exit 1
# determine commit if not already set # determine commit if not already set