Compare commits

..

16 Commits

Author SHA1 Message Date
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
111 changed files with 1199 additions and 4069 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

@@ -13,7 +13,6 @@ import utils from "../lib/utils.js";
import { ssl as logger } from "../logger.js"; import { ssl as logger } from "../logger.js";
import certificateModel from "../models/certificate.js"; import certificateModel from "../models/certificate.js";
import tokenModel from "../models/token.js"; import tokenModel from "../models/token.js";
import userModel from "../models/user.js";
import internalAuditLog from "./audit-log.js"; import internalAuditLog from "./audit-log.js";
import internalHost from "./host.js"; import internalHost from "./host.js";
import internalNginx from "./nginx.js"; import internalNginx from "./nginx.js";
@@ -82,7 +81,7 @@ const internalCertificate = {
Promise.resolve({ Promise.resolve({
permission_visibility: "all", permission_visibility: "all",
}), }),
token: tokenModel(), token: new tokenModel(),
}, },
{ id: certificate.id }, { id: certificate.id },
) )
@@ -119,7 +118,10 @@ const internalCertificate = {
data.nice_name = data.domain_names.join(", "); data.nice_name = data.domain_names.join(", ");
} }
const certificate = await certificateModel.query().insertAndFetch(data).then(utils.omitRow(omissions())); const certificate = await certificateModel
.query()
.insertAndFetch(data)
.then(utils.omitRow(omissions()));
if (certificate.provider === "letsencrypt") { if (certificate.provider === "letsencrypt") {
// Request a new Cert from LE. Let the fun begin. // Request a new Cert from LE. Let the fun begin.
@@ -137,19 +139,12 @@ const internalCertificate = {
// 2. Disable them in nginx temporarily // 2. Disable them in nginx temporarily
await internalCertificate.disableInUseHosts(inUseResult); await internalCertificate.disableInUseHosts(inUseResult);
const user = await userModel.query().where("is_deleted", 0).andWhere("id", data.owner_user_id).first();
if (!user || !user.email) {
throw new error.ValidationError(
"A valid email address must be set on your user account to use Let's Encrypt",
);
}
// With DNS challenge no config is needed, so skip 3 and 5. // With DNS challenge no config is needed, so skip 3 and 5.
if (certificate.meta?.dns_challenge) { if (certificate.meta?.dns_challenge) {
try { try {
await internalNginx.reload(); await internalNginx.reload();
// 4. Request cert // 4. Request cert
await internalCertificate.requestLetsEncryptSslWithDnsChallenge(certificate, user.email); await internalCertificate.requestLetsEncryptSslWithDnsChallenge(certificate);
await internalNginx.reload(); await internalNginx.reload();
// 6. Re-instate previously disabled hosts // 6. Re-instate previously disabled hosts
await internalCertificate.enableInUseHosts(inUseResult); await internalCertificate.enableInUseHosts(inUseResult);
@@ -164,9 +159,9 @@ const internalCertificate = {
try { try {
await internalNginx.generateLetsEncryptRequestConfig(certificate); await internalNginx.generateLetsEncryptRequestConfig(certificate);
await internalNginx.reload(); await internalNginx.reload();
setTimeout(() => {}, 5000); setTimeout(() => {}, 5000)
// 4. Request cert // 4. Request cert
await internalCertificate.requestLetsEncryptSsl(certificate, user.email); await internalCertificate.requestLetsEncryptSsl(certificate);
// 5. Remove LE config // 5. Remove LE config
await internalNginx.deleteLetsEncryptRequestConfig(certificate); await internalNginx.deleteLetsEncryptRequestConfig(certificate);
await internalNginx.reload(); await internalNginx.reload();
@@ -209,12 +204,13 @@ const internalCertificate = {
data.meta = _.assign({}, data.meta || {}, certificate.meta); data.meta = _.assign({}, data.meta || {}, certificate.meta);
// Add to audit log // Add to audit log
await internalAuditLog.add(access, { await internalAuditLog
action: "created", .add(access, {
object_type: "certificate", action: "created",
object_id: certificate.id, object_type: "certificate",
meta: data, object_id: certificate.id,
}); meta: data,
});
return certificate; return certificate;
}, },
@@ -252,12 +248,13 @@ const internalCertificate = {
} }
// Add to audit log // Add to audit log
await internalAuditLog.add(access, { await internalAuditLog
action: "updated", .add(access, {
object_type: "certificate", action: "updated",
object_id: row.id, object_type: "certificate",
meta: _.omit(data, ["expires_on"]), // this prevents json circular reference because expires_on might be raw object_id: row.id,
}); meta: _.omit(data, ["expires_on"]), // this prevents json circular reference because expires_on might be raw
});
return savedRow; return savedRow;
}, },
@@ -271,7 +268,7 @@ const internalCertificate = {
* @return {Promise} * @return {Promise}
*/ */
get: async (access, data) => { get: async (access, data) => {
const accessData = await access.can("certificates:get", data.id); const accessData = await access.can("certificates:get", data.id)
const query = certificateModel const query = certificateModel
.query() .query()
.where("is_deleted", 0) .where("is_deleted", 0)
@@ -370,9 +367,12 @@ const internalCertificate = {
throw new error.ItemNotFoundError(data.id); throw new error.ItemNotFoundError(data.id);
} }
await certificateModel.query().where("id", row.id).patch({ await certificateModel
is_deleted: 1, .query()
}); .where("id", row.id)
.patch({
is_deleted: 1,
});
// Add to audit log // Add to audit log
row.meta = internalCertificate.cleanMeta(row.meta); row.meta = internalCertificate.cleanMeta(row.meta);
@@ -435,7 +435,10 @@ const internalCertificate = {
* @returns {Promise} * @returns {Promise}
*/ */
getCount: async (userId, visibility) => { getCount: async (userId, visibility) => {
const query = certificateModel.query().count("id as count").where("is_deleted", 0); const query = certificateModel
.query()
.count("id as count")
.where("is_deleted", 0);
if (visibility !== "all") { if (visibility !== "all") {
query.andWhere("owner_user_id", userId); query.andWhere("owner_user_id", userId);
@@ -498,10 +501,12 @@ const internalCertificate = {
* @param {Access} access * @param {Access} access
* @param {Object} data * @param {Object} data
* @param {Array} data.domain_names * @param {Array} data.domain_names
* @param {String} data.meta.letsencrypt_email
* @param {Boolean} data.meta.letsencrypt_agree
* @returns {Promise} * @returns {Promise}
*/ */
createQuickCertificate: async (access, data) => { createQuickCertificate: async (access, data) => {
return await internalCertificate.create(access, { return internalCertificate.create(access, {
provider: "letsencrypt", provider: "letsencrypt",
domain_names: data.domain_names, domain_names: data.domain_names,
meta: data.meta, meta: data.meta,
@@ -647,7 +652,7 @@ const internalCertificate = {
const certData = {}; const certData = {};
try { try {
const result = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-subject", "-noout"]); const result = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-subject", "-noout"])
// Examples: // Examples:
// subject=CN = *.jc21.com // subject=CN = *.jc21.com
// subject=CN = something.example.com // subject=CN = something.example.com
@@ -734,10 +739,9 @@ const internalCertificate = {
/** /**
* Request a certificate using the http challenge * Request a certificate using the http challenge
* @param {Object} certificate the certificate row * @param {Object} certificate the certificate row
* @param {String} email the email address to use for registration
* @returns {Promise} * @returns {Promise}
*/ */
requestLetsEncryptSsl: async (certificate, email) => { requestLetsEncryptSsl: async (certificate) => {
logger.info( logger.info(
`Requesting LetsEncrypt certificates for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`, `Requesting LetsEncrypt certificates for Cert #${certificate.id}: ${certificate.domain_names.join(", ")}`,
); );
@@ -756,7 +760,7 @@ const internalCertificate = {
"--authenticator", "--authenticator",
"webroot", "webroot",
"--email", "--email",
email, certificate.meta.letsencrypt_email,
"--preferred-challenges", "--preferred-challenges",
"dns,http", "dns,http",
"--domains", "--domains",
@@ -775,10 +779,9 @@ const internalCertificate = {
/** /**
* @param {Object} certificate the certificate row * @param {Object} certificate the certificate row
* @param {String} email the email address to use for registration
* @returns {Promise} * @returns {Promise}
*/ */
requestLetsEncryptSslWithDnsChallenge: async (certificate, email) => { requestLetsEncryptSslWithDnsChallenge: async (certificate) => {
await installPlugin(certificate.meta.dns_provider); await installPlugin(certificate.meta.dns_provider);
const dnsPlugin = dnsPlugins[certificate.meta.dns_provider]; const dnsPlugin = dnsPlugins[certificate.meta.dns_provider];
logger.info( logger.info(
@@ -804,7 +807,7 @@ const internalCertificate = {
`npm-${certificate.id}`, `npm-${certificate.id}`,
"--agree-tos", "--agree-tos",
"--email", "--email",
email, certificate.meta.letsencrypt_email,
"--domains", "--domains",
certificate.domain_names.join(","), certificate.domain_names.join(","),
"--authenticator", "--authenticator",
@@ -844,7 +847,7 @@ const internalCertificate = {
* @returns {Promise} * @returns {Promise}
*/ */
renew: async (access, data) => { renew: async (access, data) => {
await access.can("certificates:update", data); await access.can("certificates:update", data)
const certificate = await internalCertificate.get(access, data); const certificate = await internalCertificate.get(access, data);
if (certificate.provider === "letsencrypt") { if (certificate.provider === "letsencrypt") {
@@ -857,9 +860,11 @@ const internalCertificate = {
`${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`, `${internalCertificate.getLiveCertPath(certificate.id)}/fullchain.pem`,
); );
const updatedCertificate = await certificateModel.query().patchAndFetchById(certificate.id, { const updatedCertificate = await certificateModel
expires_on: moment(certInfo.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"), .query()
}); .patchAndFetchById(certificate.id, {
expires_on: moment(certInfo.dates.to, "X").format("YYYY-MM-DD HH:mm:ss"),
});
// Add to audit log // Add to audit log
await internalAuditLog.add(access, { await internalAuditLog.add(access, {
@@ -1154,9 +1159,7 @@ const internalCertificate = {
return "no-host"; return "no-host";
} }
// Other errors // Other errors
logger.info( logger.info(`HTTP challenge test failed for domain ${domain} because code ${result.responsecode} was returned`);
`HTTP challenge test failed for domain ${domain} because code ${result.responsecode} was returned`,
);
return `other:${result.responsecode}`; return `other:${result.responsecode}`;
} }
@@ -1198,7 +1201,7 @@ const internalCertificate = {
getLiveCertPath: (certificateId) => { getLiveCertPath: (certificateId) => {
return `/etc/letsencrypt/live/npm-${certificateId}`; return `/etc/letsencrypt/live/npm-${certificateId}`;
}, }
}; };
export default internalCertificate; export default internalCertificate;

View File

@@ -54,21 +54,10 @@ const internalDeadHost = {
thisData.advanced_config = ""; thisData.advanced_config = "";
} }
const row = await deadHostModel.query() const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions()));
.insertAndFetch(thisData)
.then(utils.omitRow(omissions()));
// Add to audit log
await internalAuditLog.add(access, {
action: "created",
object_type: "dead-host",
object_id: row.id,
meta: thisData,
});
if (createCertificate) { if (createCertificate) {
const cert = await internalCertificate.createQuickCertificate(access, data); const cert = await internalCertificate.createQuickCertificate(access, data);
// update host with cert id // update host with cert id
await internalDeadHost.update(access, { await internalDeadHost.update(access, {
id: row.id, id: row.id,
@@ -82,13 +71,17 @@ const internalDeadHost = {
expand: ["certificate", "owner"], expand: ["certificate", "owner"],
}); });
// Sanity check
if (createCertificate && !freshRow.certificate_id) {
throw new errs.InternalValidationError("The host was created but the Certificate creation failed.");
}
// Configure nginx // Configure nginx
await internalNginx.configure(deadHostModel, "dead_host", freshRow); await internalNginx.configure(deadHostModel, "dead_host", freshRow);
data.meta = _.assign({}, data.meta || {}, freshRow.meta);
// Add to audit log
await internalAuditLog.add(access, {
action: "created",
object_type: "dead-host",
object_id: freshRow.id,
meta: data,
});
return freshRow; return freshRow;
}, },
@@ -101,6 +94,7 @@ const internalDeadHost = {
*/ */
update: async (access, data) => { update: async (access, data) => {
const createCertificate = data.certificate_id === "new"; const createCertificate = data.certificate_id === "new";
if (createCertificate) { if (createCertificate) {
delete data.certificate_id; delete data.certificate_id;
} }
@@ -153,13 +147,6 @@ const internalDeadHost = {
thisData = internalHost.cleanSslHstsData(thisData, row); thisData = internalHost.cleanSslHstsData(thisData, row);
// do the row update
await deadHostModel
.query()
.where({id: data.id})
.patch(data);
// Add to audit log // Add to audit log
await internalAuditLog.add(access, { await internalAuditLog.add(access, {
action: "updated", action: "updated",
@@ -240,7 +227,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 +234,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)) { logger.debug(`Deleting file: ${filename}`);
return;
}
try { try {
logger.debug(`Deleting file: ${filename}`);
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

@@ -6,6 +6,46 @@ import utils from "./utils.js";
const CERTBOT_VERSION_REPLACEMENT = "$(certbot --version | grep -Eo '[0-9](\\.[0-9]+)+')"; const CERTBOT_VERSION_REPLACEMENT = "$(certbot --version | grep -Eo '[0-9](\\.[0-9]+)+')";
/**
* @param {array} pluginKeys
*/
const installPlugins = async (pluginKeys) => {
let hasErrors = false;
return new Promise((resolve, reject) => {
if (pluginKeys.length === 0) {
resolve();
return;
}
batchflow(pluginKeys)
.sequential()
.each((_i, pluginKey, next) => {
certbot
.installPlugin(pluginKey)
.then(() => {
next();
})
.catch((err) => {
hasErrors = true;
next(err);
});
})
.error((err) => {
logger.error(err.message);
})
.end(() => {
if (hasErrors) {
reject(
new errs.CommandError("Some plugins failed to install. Please check the logs above", 1),
);
} else {
resolve();
}
});
});
};
/** /**
* Installs a cerbot plugin given the key for the object from * Installs a cerbot plugin given the key for the object from
* ../global/certbot-dns-plugins.json * ../global/certbot-dns-plugins.json
@@ -44,43 +84,4 @@ const installPlugin = async (pluginKey) => {
}); });
}; };
/**
* @param {array} pluginKeys
*/
const installPlugins = async (pluginKeys) => {
let hasErrors = false;
return new Promise((resolve, reject) => {
if (pluginKeys.length === 0) {
resolve();
return;
}
batchflow(pluginKeys)
.sequential()
.each((_i, pluginKey, next) => {
installPlugin(pluginKey)
.then(() => {
next();
})
.catch((err) => {
hasErrors = true;
next(err);
});
})
.error((err) => {
logger.error(err.message);
})
.end(() => {
if (hasErrors) {
reject(
new errs.CommandError("Some plugins failed to install. Please check the logs above", 1),
);
} else {
resolve();
}
});
});
};
export { installPlugins, installPlugin }; export { installPlugins, installPlugin };

View File

@@ -98,8 +98,6 @@ router
name: dnsPlugins[key].name, name: dnsPlugins[key].name,
credentials: dnsPlugins[key].credentials, credentials: dnsPlugins[key].credentials,
})); }));
clean.sort((a, b) => a.name.localeCompare(b.name));
res.status(200).send(clean); res.status(200).send(clean);
} catch (err) { } catch (err) {
logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`); logger.debug(`${req.method.toUpperCase()} ${req.path}: ${err}`);

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

@@ -62,9 +62,15 @@
"dns_provider_credentials": { "dns_provider_credentials": {
"type": "string" "type": "string"
}, },
"letsencrypt_agree": {
"type": "boolean"
},
"letsencrypt_certificate": { "letsencrypt_certificate": {
"type": "object" "type": "object"
}, },
"letsencrypt_email": {
"$ref": "../common.json#/properties/email"
},
"propagation_seconds": { "propagation_seconds": {
"type": "integer", "type": "integer",
"minimum": 0 "minimum": 0

View File

@@ -36,6 +36,8 @@
"domain_names": ["test.example.com"], "domain_names": ["test.example.com"],
"expires_on": "2025-01-07T04:34:18.000Z", "expires_on": "2025-01-07T04:34:18.000Z",
"meta": { "meta": {
"letsencrypt_email": "jc@jc21.com",
"letsencrypt_agree": true,
"dns_challenge": false "dns_challenge": false
} }
} }

View File

@@ -37,6 +37,8 @@
"nice_name": "My Test Cert", "nice_name": "My Test Cert",
"domain_names": ["test.jc21.supernerd.pro"], "domain_names": ["test.jc21.supernerd.pro"],
"meta": { "meta": {
"letsencrypt_email": "jc@jc21.com",
"letsencrypt_agree": true,
"dns_challenge": false "dns_challenge": false
} }
} }

View File

@@ -36,6 +36,8 @@
"domain_names": ["test.example.com"], "domain_names": ["test.example.com"],
"expires_on": "2025-01-07T04:34:18.000Z", "expires_on": "2025-01-07T04:34:18.000Z",
"meta": { "meta": {
"letsencrypt_email": "jc@jc21.com",
"letsencrypt_agree": true,
"dns_challenge": false "dns_challenge": false
} }
} }

View File

@@ -52,6 +52,8 @@
"nice_name": "test.example.com", "nice_name": "test.example.com",
"domain_names": ["test.example.com"], "domain_names": ["test.example.com"],
"meta": { "meta": {
"letsencrypt_email": "jc@jc21.com",
"letsencrypt_agree": true,
"dns_challenge": false, "dns_challenge": false,
"letsencrypt_certificate": { "letsencrypt_certificate": {
"cn": "test.example.com", "cn": "test.example.com",

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

@@ -121,13 +121,11 @@ const setupCertbotPlugins = async () => {
// Make sure credentials file exists // Make sure credentials file exists
const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`; const credentials_loc = `/etc/letsencrypt/credentials/credentials-${certificate.id}`;
// Escape single quotes and backslashes // Escape single quotes and backslashes
if (typeof certificate.meta.dns_provider_credentials === "string") { const escapedCredentials = certificate.meta.dns_provider_credentials
const escapedCredentials = certificate.meta.dns_provider_credentials .replaceAll("'", "\\'")
.replaceAll("'", "\\'") .replaceAll("\\", "\\\\");
.replaceAll("\\", "\\\\"); const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`;
const credentials_cmd = `[ -f '${credentials_loc}' ] || { mkdir -p /etc/letsencrypt/credentials 2> /dev/null; echo '${escapedCredentials}' > '${credentials_loc}' && chmod 600 '${credentials_loc}'; }`; promises.push(utils.exec(credentials_cmd));
promises.push(utils.exec(credentials_cmd));
}
} }
return true; return true;
}); });

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": {
@@ -20,7 +19,6 @@
"@tabler/icons-react": "^3.35.0", "@tabler/icons-react": "^3.35.0",
"@tanstack/react-query": "^5.89.0", "@tanstack/react-query": "^5.89.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@uiw/react-textarea-code-editor": "^3.1.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"country-flag-icons": "^1.5.20", "country-flag-icons": "^1.5.20",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",

View File

@@ -1,14 +1,3 @@
:root {
color-scheme: light dark;
}
.light {
color-scheme: light;
}
.dark {
color-scheme: dark;
}
.modal-backdrop { .modal-backdrop {
--tblr-backdrop-opacity: 0.8 !important; --tblr-backdrop-opacity: 0.8 !important;
} }
@@ -23,54 +12,3 @@
.ml-1 { .ml-1 {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
.react-select-container {
.react-select__control {
color: var(--tblr-body-color);
background-color: var(--tblr-bg-forms);
border: var(--tblr-border-width) solid var(--tblr-border-color);
.react-select__input {
color: var(--tblr-body-color) !important;
}
.react-select__single-value {
color: var(--tblr-body-color);
}
.react-select__multi-value {
border: 1px solid var(--tblr-border-color);
background-color: var(--tblr-bg-surface-tertiary);
color: var(--tblr-secondary) !important;
.react-select__multi-value__label {
color: var(--tblr-secondary) !important;
}
}
}
.react-select__menu {
background-color: var(--tblr-bg-forms);
.react-select__option {
background: rgba(var(--tblr-primary-rgb), .04);
color: inherit !important;
&.react-select__option--is-focused {
background: rgba(var(--tblr-primary-rgb), .1);
}
&.react-select__option--is-focused.react-select__option--is-selected {
background: rgba(var(--tblr-primary-rgb), .2);
}
}
}
}
.textareaMono {
font-family: 'Courier New', Courier, monospace !important;
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

@@ -1,8 +1,16 @@
.dnsChallengeWarning { .dnsChallengeWarning {
border: 1px solid var(--tblr-orange-lt); border: 1px solid #fecaca; /* Tailwind's red-300 */
padding: 1rem; padding: 1rem;
border-radius: 0.375rem; border-radius: 0.375rem; /* Tailwind's rounded-md */
margin-top: 1rem; margin-top: 1rem;
background-color: var(--tblr-cyan-lt);
} }
.textareaMono {
font-family: 'Courier New', Courier, monospace !important;
/* background-color: #f9fafb;
border: 1px solid #d1d5db;
padding: 0.5rem;
border-radius: 0.375rem;
width: 100%; */
resize: vertical;
}

View File

@@ -1,3 +1,4 @@
import cn from "classnames";
import { Field, useFormikContext } from "formik"; import { Field, useFormikContext } from "formik";
import { useState } from "react"; import { useState } from "react";
import Select, { type ActionMeta } from "react-select"; import Select, { type ActionMeta } from "react-select";
@@ -10,6 +11,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();
@@ -18,8 +20,8 @@ export function DNSProviderFields() {
const v: any = values || {}; const v: any = values || {};
const handleChange = (newValue: any, _actionMeta: ActionMeta<DNSProviderOption>) => { const handleChange = (newValue: any, _actionMeta: ActionMeta<DNSProviderOption>) => {
setFieldValue("meta.dnsProvider", newValue?.value); setFieldValue("dnsProvider", newValue?.value);
setFieldValue("meta.dnsProviderCredentials", newValue?.credentials); setFieldValue("dnsProviderCredentials", newValue?.credentials);
setDnsProviderId(newValue?.value); setDnsProviderId(newValue?.value);
}; };
@@ -32,20 +34,18 @@ export function DNSProviderFields() {
return ( return (
<div className={styles.dnsChallengeWarning}> <div className={styles.dnsChallengeWarning}>
<p className="text-info"> <p className="text-danger">
This section requires some knowledge about Certbot and DNS plugins. Please consult the respective This section requires some knowledge about Certbot and its DNS plugins. Please consult the respective
plugins documentation. plugins documentation.
</p> </p>
<Field name="meta.dnsProvider"> <Field name="dnsProvider">
{({ field }: any) => ( {({ field }: any) => (
<div className="row"> <div className="row">
<label htmlFor="dnsProvider" className="form-label"> <label htmlFor="dnsProvider" className="form-label">
DNS Provider DNS Provider
</label> </label>
<Select <Select
className="react-select-container"
classNamePrefix="react-select"
name={field.name} name={field.name}
id="dnsProvider" id="dnsProvider"
closeMenuOnSelect={true} closeMenuOnSelect={true}
@@ -62,44 +62,39 @@ export function DNSProviderFields() {
{dnsProviderId ? ( {dnsProviderId ? (
<> <>
<Field name="meta.dnsProviderCredentials"> <Field name="dnsProviderCredentials">
{({ field }: any) => ( {({ field }: any) => (
<div className="mt-3"> <div className="row mt-3">
<label htmlFor="dnsProviderCredentials" className="form-label"> <label htmlFor="dnsProviderCredentials" className="form-label">
Credentials File Content Credentials File Content
</label> </label>
<textarea <textarea
id="dnsProviderCredentials" id="dnsProviderCredentials"
className="form-control textareaMono" className={cn("form-control", styles.textareaMono)}
rows={3} rows={3}
spellCheck={false} spellCheck={false}
value={v.meta.dnsProviderCredentials || ""} value={v.dnsProviderCredentials || ""}
{...field} {...field}
/> />
<div> <small className="text-muted">
<small className="text-muted"> This plugin requires a configuration file containing an API token or other
This plugin requires a configuration file containing an API token or other credentials to your provider
credentials to your provider </small>
</small> <small className="text-danger">
</div> This data will be stored as plaintext in the database and in a file!
<div> </small>
<small className="text-danger">
This data will be stored as plaintext in the database and in a file!
</small>
</div>
</div> </div>
)} )}
</Field> </Field>
<Field name="meta.propagationSeconds"> <Field name="propagationSeconds">
{({ field }: any) => ( {({ field }: any) => (
<div className="mt-3"> <div className="row mt-3">
<label htmlFor="propagationSeconds" className="form-label"> <label htmlFor="propagationSeconds" className="form-label">
Propagation Seconds Propagation Seconds
</label> </label>
<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,9 @@
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";
type SelectOption = { export type SelectOption = {
label: string; label: string;
value: string; value: string;
color?: string; color?: string;
@@ -24,10 +22,17 @@ export function DomainNamesField({
label = "domain-names", label = "domain-names",
id = "domainNames", id = "domainNames",
maxDomains, maxDomains,
isWildcardPermitted = true, isWildcardPermitted,
dnsProviderWildcardSupported = true, dnsProviderWildcardSupported,
}: Props) { }: Props) {
const { setFieldValue } = useFormikContext(); const { values, setFieldValue } = useFormikContext();
const getDomainCount = (v: string[] | undefined): number => {
if (v?.length) {
return v.length;
}
return 0;
};
const handleChange = (v: MultiValue<SelectOption>, _actionMeta: ActionMeta<SelectOption>) => { const handleChange = (v: MultiValue<SelectOption>, _actionMeta: ActionMeta<SelectOption>) => {
const doms = v?.map((i: SelectOption) => { const doms = v?.map((i: SelectOption) => {
@@ -36,43 +41,75 @@ export function DomainNamesField({
setFieldValue(name, doms); setFieldValue(name, doms);
}; };
const helperTexts: ReactNode[] = []; const isDomainValid = (d: string): boolean => {
const dom = d.trim().toLowerCase();
const v: any = values;
// Deny if the list of domains is hit
if (maxDomains && getDomainCount(v?.[name]) >= maxDomains) {
return false;
}
if (dom.length < 3) {
return false;
}
// Prevent wildcards
if ((!isWildcardPermitted || !dnsProviderWildcardSupported) && dom.indexOf("*") !== -1) {
return false;
}
// Prevent duplicate * in domain
if ((dom.match(/\*/g) || []).length > 1) {
return false;
}
// Prevent some invalid characters
if ((dom.match(/(@|,|!|&|\$|#|%|\^|\(|\))/g) || []).length > 0) {
return false;
}
// This will match *.com type domains,
return dom.match(/\*\.[^.]+$/m) === null;
};
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: "wildcards-not-permitted" }));
} else if (!dnsProviderWildcardSupported) { } else if (!dnsProviderWildcardSupported) {
helperTexts.push(<T id="domain-names.wildcards-not-supported" />); helperTexts.push(intl.formatMessage({ id: "wildcards-not-supported" }));
} }
return ( return (
<Field name={name} validate={validateDomains(isWildcardPermitted && dnsProviderWildcardSupported, maxDomains)}> <Field name={name}>
{({ 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"
classNamePrefix="react-select"
name={field.name} name={field.name}
id={id} id={id}
closeMenuOnSelect={true} closeMenuOnSelect={true}
isClearable={false} isClearable={false}
isValidNewOption={validateDomain(isWildcardPermitted && dnsProviderWildcardSupported)} isValidNewOption={isDomainValid}
isMulti isMulti
placeholder={intl.formatMessage({ id: "domain-names.placeholder" })} placeholder="Start typing to add domain..."
onChange={handleChange} onChange={handleChange}
value={field.value?.map((d: string) => ({ label: d, value: d }))} value={field.value?.map((d: string) => ({ label: d, value: d }))}
/> />
{form.errors[field.name] && form.touched[field.name] ? ( {form.errors[field.name] ? (
<small className="text-danger">{form.errors[field.name]}</small> <div className="invalid-feedback">
{form.errors[field.name] && form.touched[field.name] ? form.errors[field.name] : null}
</div>
) : helperTexts.length ? ( ) : helperTexts.length ? (
helperTexts.map((i, idx) => ( helperTexts.map((i) => (
<small key={idx} className="text-info"> <div key={i} className="invalid-feedback text-info">
{i} {i}
</small> </div>
)) ))
) : null} ) : null}
</div> </div>

View File

@@ -1,40 +0,0 @@
import CodeEditor from "@uiw/react-textarea-code-editor";
import { Field } from "formik";
import { intl, T } from "src/locale";
interface Props {
id?: string;
name?: string;
label?: string;
}
export function NginxConfigField({
name = "advancedConfig",
label = "nginx-config.label",
id = "advancedConfig",
}: Props) {
return (
<Field name={name}>
{({ field }: any) => (
<div className="mt-3">
<label htmlFor={id} className="form-label">
<T id={label} />
</label>
<CodeEditor
language="nginx"
placeholder={intl.formatMessage({ id: "nginx-config.placeholder" })}
padding={15}
data-color-mode="dark"
minHeight={200}
indentWidth={2}
style={{
fontFamily: "ui-monospace,SFMono-Regular,SF Mono,Consolas,Liberation Mono,Menlo,monospace",
borderRadius: "0.3rem",
minHeight: "200px",
}}
{...field}
/>
</div>
)}
</Field>
);
}

View File

@@ -2,8 +2,8 @@ import { IconShield } from "@tabler/icons-react";
import { Field, useFormikContext } from "formik"; 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, useUser } 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,35 +38,27 @@ export function SSLCertificateField({
id = "certificateId", id = "certificateId",
required, required,
allowNew, allowNew,
forHttp = true,
}: Props) { }: Props) {
const { data: currentUser } = useUser("me");
const { isLoading, isError, error, data } = useCertificates(); const { isLoading, isError, error, data } = useCertificates();
const { values, setFieldValue } = useFormikContext(); const { values, setFieldValue } = useFormikContext();
const v: any = values || {}; const v: any = values || {};
const handleChange = (newValue: any, _actionMeta: ActionMeta<CertOption>) => { const handleChange = (newValue: any, _actionMeta: ActionMeta<CertOption>) => {
setFieldValue(name, newValue?.value); setFieldValue(name, newValue?.value);
const { const { sslForced, http2Support, hstsEnabled, hstsSubdomains, dnsChallenge, letsencryptEmail } = v;
sslForced, if (!newValue?.value) {
http2Support,
hstsEnabled,
hstsSubdomains,
dnsChallenge,
dnsProvider,
dnsProviderCredentials,
propagationSeconds,
} = v;
if (forHttp && !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);
hstsSubdomains && setFieldValue("hstsSubdomains", false); hstsSubdomains && setFieldValue("hstsSubdomains", false);
} }
if (newValue?.value !== "new") { if (newValue?.value === "new") {
dnsChallenge && setFieldValue("dnsChallenge", undefined); if (!letsencryptEmail) {
dnsProvider && setFieldValue("dnsProvider", undefined); setFieldValue("letsencryptEmail", currentUser?.email);
dnsProviderCredentials && setFieldValue("dnsProviderCredentials", undefined); }
propagationSeconds && setFieldValue("propagationSeconds", undefined); } else {
dnsChallenge && setFieldValue("dnsChallenge", false);
} }
}; };
@@ -96,7 +87,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,15 +97,13 @@ 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}
{!isLoading && !isError ? ( {!isLoading && !isError ? (
<Select <Select
className="react-select-container" defaultValue={options[0]}
classNamePrefix="react-select"
defaultValue={options.find((o) => o.value === field.value) || options[0]}
options={options} options={options}
components={{ Option }} components={{ Option }}
styles={{ styles={{

View File

@@ -1,41 +1,24 @@
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";
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 || {};
const newCertificate = v?.certificateId === "new"; const newCertificate = v?.certificateId === "new";
const hasCertificate = newCertificate || (v?.certificateId && v?.certificateId > 0); const hasCertificate = newCertificate || (v?.certificateId && v?.certificateId > 0);
const { sslForced, http2Support, hstsEnabled, hstsSubdomains, meta } = v; const { sslForced, http2Support, hstsEnabled, hstsSubdomains, dnsChallenge } = v;
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) {
setFieldValue("meta.dnsProvider", undefined);
setFieldValue("meta.dnsProviderCredentials", undefined);
setFieldValue("meta.propagationSeconds", undefined);
}
}; };
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">
@@ -48,9 +31,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
onChange={(e) => handleToggleChange(e, field.name)} onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate} disabled={!hasCertificate}
/> />
<span className="form-check-label"> <span className="form-check-label">Force SSL</span>
<T id="domains.force-ssl" />
</span>
</label> </label>
)} )}
</Field> </Field>
@@ -66,9 +47,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
onChange={(e) => handleToggleChange(e, field.name)} onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate} disabled={!hasCertificate}
/> />
<span className="form-check-label"> <span className="form-check-label">HTTP/2 Support</span>
<T id="domains.http2-support" />
</span>
</label> </label>
)} )}
</Field> </Field>
@@ -86,9 +65,7 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
onChange={(e) => handleToggleChange(e, field.name)} onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate || !sslForced} disabled={!hasCertificate || !sslForced}
/> />
<span className="form-check-label"> <span className="form-check-label">HSTS Enabled</span>
<T id="domains.hsts-enabled" />
</span>
</label> </label>
)} )}
</Field> </Field>
@@ -104,42 +81,48 @@ export function SSLOptionsFields({ forHttp = true, forceDNSForNew, requireDomain
onChange={(e) => handleToggleChange(e, field.name)} onChange={(e) => handleToggleChange(e, field.name)}
disabled={!hasCertificate || !hstsEnabled} disabled={!hasCertificate || !hstsEnabled}
/> />
<span className="form-check-label"> <span className="form-check-label">HSTS Enabled</span>
<T id="domains.hsts-subdomains" />
</span>
</label> </label>
)} )}
</Field> </Field>
</div> </div>
</div> </div>
</div>
);
return (
<div>
{forHttp ? getHttpOptions() : null}
{newCertificate ? ( {newCertificate ? (
<> <>
<Field name="meta.dnsChallenge"> <Field name="dnsChallenge">
{({ field }: any) => ( {({ field }: any) => (
<label className="form-check form-switch mt-1"> <label className="form-check form-switch mt-1">
<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">Use a DNS Challenge</span>
<T id="domains.use-dns" />
</span>
</label> </label>
)} )}
</Field> </Field>
{requireDomainNames ? <DomainNamesField /> : null}
{dnsChallenge ? <DNSProviderFields /> : null} {dnsChallenge ? <DNSProviderFields /> : null}
<Field name="letsencryptEmail">
{({ field }: any) => (
<div className="row mt-5">
<label htmlFor="letsencryptEmail" className="form-label">
Email Address for Let's Encrypt
</label>
<input
id="letsencryptEmail"
type="email"
className="form-control"
required
{...field}
/>
</div>
)}
</Field>
</> </>
) : null} ) : null}
</div> </>
); );
} }

View File

@@ -1,7 +1,4 @@
export * from "./AccessField";
export * from "./BasicAuthField";
export * from "./DNSProviderFields"; export * from "./DNSProviderFields";
export * from "./DomainNamesField"; export * from "./DomainNamesField";
export * from "./NginxConfigField";
export * from "./SSLCertificateField"; export * from "./SSLCertificateField";
export * from "./SSLOptionsFields"; export * from "./SSLOptionsFields";

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

@@ -1,6 +1,13 @@
.btn { .darkBtn {
color: light-dark(var(--tblr-dark), var(--tblr-light)) !important; color: var(--tblr-light) !important;
&:hover {
border: var(--tblr-btn-border-width) solid transparent !important;
background: color-mix(in srgb, var(--tblr-btn-hover-bg) 10%, transparent) !important;
}
}
.lightBtn {
color: var(--tblr-dark) !important;
&:hover { &:hover {
border: var(--tblr-btn-border-width) solid transparent !important; border: var(--tblr-btn-border-width) solid transparent !important;
background: color-mix(in srgb, var(--tblr-btn-hover-bg) 10%, transparent) !important; background: color-mix(in srgb, var(--tblr-btn-hover-bg) 10%, transparent) !important;

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() {
@@ -15,8 +15,11 @@ function LocalePicker() {
location.reload(); location.reload();
}; };
const classes = ["btn", "dropdown-toggle", "btn-sm", styles.btn]; const classes = ["btn", "dropdown-toggle", "btn-sm"];
const cns = cn(...classes, getTheme() === "dark" ? "btn-ghost-dark" : "btn-ghost-light"); let cns = cn(...classes, "btn-ghost-light", styles.lightBtn);
if (getTheme() === "dark") {
cns = cn(...classes, "btn-ghost-dark", styles.darkBtn);
}
return ( return (
<div className="dropdown"> <div className="dropdown">
@@ -35,13 +38,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

@@ -40,8 +40,6 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
useEffect(() => { useEffect(() => {
document.body.dataset.theme = theme; document.body.dataset.theme = theme;
document.body.classList.remove(theme === Light ? Dark : Light);
document.body.classList.add(theme);
localStorage.setItem(StorageKey, theme); localStorage.setItem(StorageKey, theme);
}, [theme]); }, [theme]);
@@ -55,7 +53,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const getTheme = () => { const getTheme = () => {
return theme; return theme;
}; }
document.documentElement.setAttribute("data-bs-theme", theme); document.documentElement.setAttribute("data-bs-theme", theme);
return <ThemeContext.Provider value={{ theme, toggleTheme, setTheme, getTheme }}>{children}</ThemeContext.Provider>; return <ThemeContext.Provider value={{ theme, toggleTheme, setTheme, getTheme }}>{children}</ThemeContext.Provider>;

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}",
@@ -61,74 +50,26 @@
"dead-hosts.title": "404 Hosts", "dead-hosts.title": "404 Hosts",
"disabled": "Disabled", "disabled": "Disabled",
"domain-names": "Domain Names", "domain-names": "Domain Names",
"domain-names.max": "{count} domain names maximum",
"domain-names.placeholder": "Start typing to add domain...",
"domain-names.wildcards-not-permitted": "Wildcards not permitted for this type",
"domain-names.wildcards-not-supported": "Wildcards not supported for this CA",
"domains.force-ssl": "Force SSL",
"domains.hsts-enabled": "HSTS Enabled",
"domains.hsts-subdomains": "HSTS Sub-domains",
"domains.http2-support": "HTTP/2 Support",
"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-email": "Invalid email address",
"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",
"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",
"loading": "Loading…", "loading": "Loading…",
"login.title": "Login to your account", "login.title": "Login to your account",
"nginx-config.label": "Custom Nginx Configuration",
"nginx-config.placeholder": "# Enter your custom Nginx configuration here at your own risk!",
"no-permission-error": "You do not have access to view this.", "no-permission-error": "You do not have access to view this.",
"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.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 +83,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 +101,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 +125,9 @@
"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" "wildcards-not-permitted": "Wildcards not permitted for this type",
"wildcards-not-supported": "Wildcards not supported for this CA"
} }

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"
}, },
@@ -185,134 +152,29 @@
"domain-names": { "domain-names": {
"defaultMessage": "Domain Names" "defaultMessage": "Domain Names"
}, },
"domain-names.max": {
"defaultMessage": "{count} domain names maximum"
},
"domain-names.placeholder": {
"defaultMessage": "Start typing to add domain..."
},
"domain-names.wildcards-not-permitted": {
"defaultMessage": "Wildcards not permitted for this type"
},
"domain-names.wildcards-not-supported": {
"defaultMessage": "Wildcards not supported for this CA"
},
"domains.force-ssl": {
"defaultMessage": "Force SSL"
},
"domains.hsts-enabled": {
"defaultMessage": "HSTS Enabled"
},
"domains.hsts-subdomains": {
"defaultMessage": "HSTS Sub-domains"
},
"domains.http2-support": {
"defaultMessage": "HTTP/2 Support"
},
"domains.use-dns": {
"defaultMessage": "Use DNS Challenge"
},
"email-address": { "email-address": {
"defaultMessage": "Email address" "defaultMessage": "Email address"
}, },
"empty-search": {
"defaultMessage": "No results found"
},
"empty-subtitle": {
"defaultMessage": "Why don't you create one?"
},
"enabled": {
"defaultMessage": "Enabled"
},
"error.invalid-auth": {
"defaultMessage": "Invalid email or password"
},
"error.invalid-domain": {
"defaultMessage": "Invalid domain: {domain}"
},
"error.invalid-email": {
"defaultMessage": "Invalid email address"
},
"error.max-domains": {
"defaultMessage": "Too many domains, max is {max}"
},
"error.passwords-must-match": { "error.passwords-must-match": {
"defaultMessage": "Passwords must match" "defaultMessage": "Passwords must match"
}, },
"error.required": { "error.invalid-auth": {
"defaultMessage": "This is required" "defaultMessage": "Invalid email or password"
},
"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"
@@ -329,12 +191,6 @@
"login.title": { "login.title": {
"defaultMessage": "Login to your account" "defaultMessage": "Login to your account"
}, },
"nginx-config.label": {
"defaultMessage": "Custom Nginx Configuration"
},
"nginx-config.placeholder": {
"defaultMessage": "# Enter your custom Nginx configuration here at your own risk!"
},
"no-permission-error": { "no-permission-error": {
"defaultMessage": "You do not have access to view this." "defaultMessage": "You do not have access to view this."
}, },
@@ -347,51 +203,18 @@
"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": {
"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 +251,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 +266,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 +305,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 +335,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,19 +377,19 @@
"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"
},
"wildcards-not-permitted": {
"defaultMessage": "Wildcards not permitted for this type"
},
"wildcards-not-supported": {
"defaultMessage": "Wildcards not supported for this CA"
} }
} }

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,27 +12,21 @@ 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 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;
} }
if (isSubmitting) return;
setIsSubmitting(true);
setError(null); setError(null);
try { try {
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);
setSubmitting(false); setSubmitting(false);
}; };
@@ -48,12 +42,10 @@ export function ChangePasswordModal({ userId, onClose }: Props) {
} }
onSubmit={onSubmit} onSubmit={onSubmit}
> >
{() => ( {({ isSubmitting }) => (
<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 +66,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 +92,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 +123,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 +132,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 +142,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,19 +1,11 @@
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 { Button, DomainNamesField, Loading, SSLCertificateField, SSLOptionsFields } from "src/components";
Button, import { useDeadHost } from "src/hooks";
DomainNamesField, import { intl } from "src/locale";
Loading,
NginxConfigField,
SSLCertificateField,
SSLOptionsFields,
} from "src/components";
import { useDeadHost, useSetDeadHost } from "src/hooks";
import { intl, T } from "src/locale";
import { showSuccess } from "src/notifications";
interface Props { interface Props {
id: number | "new"; id: number | "new";
@@ -21,31 +13,28 @@ 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 onSubmit = async (values: any, { setSubmitting }: any) => { const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return; setSubmitting(true);
setIsSubmitting(true);
setErrorMsg(null); setErrorMsg(null);
console.log("SUBMIT:", values);
setSubmitting(false);
// const { ...payload } = {
// id: id === "new" ? undefined : id,
// roles: [],
// ...values,
// };
const { ...payload } = { // setDeadHost(payload, {
id: id === "new" ? undefined : id, // onError: (err: any) => setErrorMsg(err.message),
...values, // onSuccess: () => {
}; // showSuccess(intl.formatMessage({ id: "notification.dead-host-saved" }));
// onClose();
setDeadHost(payload, { // },
onError: (err: any) => setErrorMsg(<T id={err.message} />), // onSettled: () => setSubmitting(false),
onSuccess: () => { // });
showSuccess(intl.formatMessage({ id: "notification.dead-host-saved" }));
onClose();
},
onSettled: () => {
setIsSubmitting(false);
setSubmitting(false);
},
});
}; };
return ( return (
@@ -67,22 +56,22 @@ export function DeadHostModal({ id, onClose }: Props) {
http2Support: data?.http2Support, http2Support: data?.http2Support,
hstsEnabled: data?.hstsEnabled, hstsEnabled: data?.hstsEnabled,
hstsSubdomains: data?.hstsSubdomains, hstsSubdomains: data?.hstsSubdomains,
meta: data?.meta || {},
} as any } as any
} }
onSubmit={onSubmit} onSubmit={onSubmit}
> >
{() => ( {({ isSubmitting }) => (
<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 +83,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 +95,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,28 +124,157 @@ 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 /> <h4>Advanced</h4>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* <div className="row">
<div className="col-lg-6">
<div className="mb-3">
<Field name="name" validate={validateString(1, 50)}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="name"
className={`form-control ${form.errors.name && form.touched.name ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "user.full-name" })}
{...field}
/>
<label htmlFor="name">
{intl.formatMessage({ id: "user.full-name" })}
</label>
{form.errors.name ? (
<div className="invalid-feedback">
{form.errors.name && form.touched.name
? form.errors.name
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
</div>
<div className="col-lg-6">
<div className="mb-3">
<Field name="nickname" validate={validateString(1, 30)}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="nickname"
className={`form-control ${form.errors.nickname && form.touched.nickname ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "user.nickname" })}
{...field}
/>
<label htmlFor="nickname">
{intl.formatMessage({ id: "user.nickname" })}
</label>
{form.errors.nickname ? (
<div className="invalid-feedback">
{form.errors.nickname && form.touched.nickname
? form.errors.nickname
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
</div>
</div>
<div className="mb-3">
<Field name="email" validate={validateEmail()}>
{({ field, form }: any) => (
<div className="form-floating mb-3">
<input
id="email"
type="email"
className={`form-control ${form.errors.email && form.touched.email ? "is-invalid" : ""}`}
placeholder={intl.formatMessage({ id: "email-address" })}
{...field}
/>
<label htmlFor="email">
{intl.formatMessage({ id: "email-address" })}
</label>
{form.errors.email ? (
<div className="invalid-feedback">
{form.errors.email && form.touched.email
? form.errors.email
: null}
</div>
) : null}
</div>
)}
</Field>
</div>
{currentUser && data && currentUser?.id !== data?.id ? (
<div className="my-3">
<h3 className="py-2">{intl.formatMessage({ id: "user.flags.title" })}</h3>
<div className="divide-y">
<div>
<label className="row" htmlFor="isAdmin">
<span className="col">
{intl.formatMessage({ id: "role.admin" })}
</span>
<span className="col-auto">
<Field name="isAdmin" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="isAdmin"
className="form-check-input"
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
<div>
<label className="row" htmlFor="isDisabled">
<span className="col">
{intl.formatMessage({ id: "disabled" })}
</span>
<span className="col-auto">
<Field name="isDisabled" type="checkbox">
{({ field }: any) => (
<label className="form-check form-check-single form-switch">
<input
{...field}
id="isDisabled"
className="form-check-input"
type="checkbox"
/>
</label>
)}
</Field>
</span>
</label>
</div>
</div>
</div>
) : null} */}
</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,12 +14,11 @@ 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 [submitting, setSubmitting] = useState(false);
const onSubmit = async () => { const onSubmit = async () => {
if (isSubmitting) return; setSubmitting(true);
setIsSubmitting(true);
setError(null); setError(null);
try { try {
await onConfirm(); await onConfirm();
@@ -29,17 +28,15 @@ 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); setSubmitting(false);
}; };
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>
@@ -48,19 +45,19 @@ export function DeleteConfirmModal({ title, children, onConfirm, onClose, invali
{children} {children}
</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={submitting}>
<T id="cancel" /> {intl.formatMessage({ id: "cancel" })}
</Button> </Button>
<Button <Button
type="submit" type="submit"
actionType="primary" actionType="primary"
className="ms-auto btn-red" className="ms-auto btn-red"
data-bs-dismiss="modal" data-bs-dismiss="modal"
isLoading={isSubmitting} isLoading={submitting}
disabled={isSubmitting} disabled={submitting}
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,13 +15,10 @@ 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 onSubmit = async (values: any, { setSubmitting }: any) => { const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
setErrorMsg(null); setErrorMsg(null);
try { try {
await setPermissions(userId, values); await setPermissions(userId, values);
@@ -29,10 +26,9 @@ 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);
}; };
const getPermissionButtons = (field: any, form: any) => { const getPermissionButtons = (field: any, form: any) => {
@@ -50,7 +46,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 +59,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 +72,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>
@@ -108,11 +104,11 @@ export function PermissionsModal({ userId, onClose }: Props) {
} }
onSubmit={onSubmit} onSubmit={onSubmit}
> >
{() => ( {({ isSubmitting }) => (
<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 +117,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 +136,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 +152,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 +162,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 +170,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 +178,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 +186,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 +194,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 +202,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 +213,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 +223,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,20 +13,17 @@ 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 _onSubmit = async (values: any, { setSubmitting }: any) => { const onSubmit = async (values: any, { setSubmitting }: any) => {
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);
setSubmitting(false); setSubmitting(false);
}; };
@@ -38,14 +35,12 @@ export function SetPasswordModal({ userId, onClose }: Props) {
new: "", new: "",
} as any } as any
} }
onSubmit={_onSubmit} onSubmit={onSubmit}
> >
{() => ( {({ isSubmitting }) => (
<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 +66,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 +79,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 +95,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 +111,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 +121,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";
@@ -17,13 +17,9 @@ export function UserModal({ userId, onClose }: Props) {
const { data: currentUser, isLoading: currentIsLoading } = useUser("me"); const { data: currentUser, isLoading: currentIsLoading } = useUser("me");
const { mutate: setUser } = useSetUser(); const { mutate: setUser } = useSetUser();
const [errorMsg, setErrorMsg] = useState<string | null>(null); const [errorMsg, setErrorMsg] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const onSubmit = async (values: any, { setSubmitting }: any) => { const onSubmit = async (values: any, { setSubmitting }: any) => {
if (isSubmitting) return;
setIsSubmitting(true);
setErrorMsg(null); setErrorMsg(null);
const { ...payload } = { const { ...payload } = {
id: userId === "new" ? undefined : userId, id: userId === "new" ? undefined : userId,
roles: [], roles: [],
@@ -47,10 +43,7 @@ export function UserModal({ userId, onClose }: Props) {
showSuccess(intl.formatMessage({ id: "notification.user-saved" })); showSuccess(intl.formatMessage({ id: "notification.user-saved" }));
onClose(); onClose();
}, },
onSettled: () => { onSettled: () => setSubmitting(false),
setIsSubmitting(false);
setSubmitting(false);
},
}); });
}; };
@@ -75,11 +68,11 @@ export function UserModal({ userId, onClose }: Props) {
} }
onSubmit={onSubmit} onSubmit={onSubmit}
> >
{() => ( {({ isSubmitting }) => (
<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 +92,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 +118,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 +145,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 +160,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 +186,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 +210,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 +220,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

@@ -1,5 +1,3 @@
import { intl } from "src/locale";
const validateString = (minLength = 0, maxLength = 0) => { const validateString = (minLength = 0, maxLength = 0) => {
if (minLength <= 0 && maxLength <= 0) { if (minLength <= 0 && maxLength <= 0) {
// this doesn't require translation // this doesn't require translation
@@ -8,14 +6,12 @@ const validateString = (minLength = 0, maxLength = 0) => {
return (value: string): string | undefined => { return (value: string): string | undefined => {
if (minLength && (typeof value === "undefined" || !value.length)) { if (minLength && (typeof value === "undefined" || !value.length)) {
return intl.formatMessage({ id: "error.required" }); return "This is required";
} }
if (minLength && value.length < minLength) { if (minLength && value.length < minLength) {
// TODO: i18n
return `Minimum length is ${minLength} character${minLength === 1 ? "" : "s"}`; return `Minimum length is ${minLength} character${minLength === 1 ? "" : "s"}`;
} }
if (maxLength && (typeof value === "undefined" || value.length > maxLength)) { if (maxLength && (typeof value === "undefined" || value.length > maxLength)) {
// TODO: i18n
return `Maximum length is ${maxLength} character${maxLength === 1 ? "" : "s"}`; return `Maximum length is ${maxLength} character${maxLength === 1 ? "" : "s"}`;
} }
}; };
@@ -30,14 +26,12 @@ const validateNumber = (min = -1, max = -1) => {
return (value: string): string | undefined => { return (value: string): string | undefined => {
const int: number = +value; const int: number = +value;
if (min > -1 && !int) { if (min > -1 && !int) {
return intl.formatMessage({ id: "error.required" }); return "This is required";
} }
if (min > -1 && int < min) { if (min > -1 && int < min) {
// TODO: i18n
return `Minimum is ${min}`; return `Minimum is ${min}`;
} }
if (max > -1 && int > max) { if (max > -1 && int > max) {
// TODO: i18n
return `Maximum is ${max}`; return `Maximum is ${max}`;
} }
}; };
@@ -46,62 +40,12 @@ const validateNumber = (min = -1, max = -1) => {
const validateEmail = () => { const validateEmail = () => {
return (value: string): string | undefined => { return (value: string): string | undefined => {
if (!value.length) { if (!value.length) {
return intl.formatMessage({ id: "error.required" }); return "This is required";
} }
if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) { if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(value)) {
return intl.formatMessage({ id: "error.invalid-email" }); return "Invalid email address";
} }
}; };
}; };
const validateDomain = (allowWildcards = false) => { export { validateEmail, validateNumber, validateString };
return (d: string): boolean => {
const dom = d.trim().toLowerCase();
if (dom.length < 3) {
return false;
}
// Prevent wildcards
if (!allowWildcards && dom.indexOf("*") !== -1) {
return false;
}
// Prevent duplicate * in domain
if ((dom.match(/\*/g) || []).length > 1) {
return false;
}
// Prevent some invalid characters
if ((dom.match(/(@|,|!|&|\$|#|%|\^|\(|\))/g) || []).length > 0) {
return false;
}
// This will match *.com type domains,
return dom.match(/\*\.[^.]+$/m) === null;
};
};
const validateDomains = (allowWildcards = false, maxDomains?: number) => {
const vDom = validateDomain(allowWildcards);
return (value?: string[]): string | undefined => {
if (!value?.length) {
return intl.formatMessage({ id: "error.required" });
}
// Deny if the list of domains is hit
if (maxDomains && value?.length >= maxDomains) {
return intl.formatMessage({ id: "error.max-domains" }, { max: maxDomains });
}
// validate each domain
for (let i = 0; i < value?.length; i++) {
if (!vDom(value[i])) {
return intl.formatMessage({ id: "error.invalid-domain" }, { domain: value[i] });
}
}
};
};
export { validateEmail, validateNumber, validateString, validateDomains, validateDomain };

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,52 +23,29 @@ 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"> <span className="input-group-text input-group-text-sm">
<span className="input-group-text input-group-text-sm"> <IconSearch size={16} />
<IconSearch size={16} /> </span>
</span> <input
<input id="advanced-table-search"
id="advanced-table-search" 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>
<Button size="sm" className="btn-cyan" onClick={() => setEditId("new")}>
<T id="access.add" />
</Button>
</div> </div>
<Button size="sm" className="btn-cyan">
{intl.formatMessage({ id: "access.add" })}
</Button>
</div> </div>
) : null} </div>
</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" /> <div className="dropdown">
</h2> <button type="button" className="btn dropdown-toggle btn-pink my-3" data-bs-toggle="dropdown">
) : ( {intl.formatMessage({ id: "certificates.add" })}
<> </button>
<h2> <div className="dropdown-menu">
<T id="certificates.empty" /> <a className="dropdown-item" href="#">
</h2> {intl.formatMessage({ id: "lets-encrypt" })}
<p className="text-muted"> </a>
<T id="empty-subtitle" /> <a className="dropdown-item" href="#">
</p> {intl.formatMessage({ id: "certificates.custom" })}
<div className="dropdown"> </a>
<button </div>
type="button" </div>
className="btn dropdown-toggle btn-pink my-3"
data-bs-toggle="dropdown"
>
<T id="certificates.add" />
</button>
<div className="dropdown-menu">
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onNew?.();
}}
>
<T id="lets-encrypt" />
</a>
<a
className="dropdown-item"
href="#"
onClick={(e) => {
e.preventDefault();
onNewCustom?.();
}}
>
<T id="certificates.custom" />
</a>
</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,19 @@ 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:
- 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" /> <Button className="btn-red my-3" onClick={onNew}>
</h2> {intl.formatMessage({ id: "dead-hosts.add" })}
) : ( </Button>
<>
<h2>
<T id="dead-hosts.empty" />
</h2>
<p className="text-muted">
<T id="empty-subtitle" />
</p>
<Button className="btn-red my-3" onClick={onNew}>
<T id="dead-hosts.add" />
</Button>
</>
)}
</div> </div>
</td> </td>
</tr> </tr>

View File

@@ -4,19 +4,16 @@ 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;
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, onDelete, onNew }: Props) {
const columnHelper = createColumnHelper<DeadHost>(); const columnHelper = createColumnHelper<DeadHost>();
const columns = useMemo( const columns = useMemo(
() => [ () => [
@@ -67,29 +64,20 @@ 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" 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
@@ -101,7 +89,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 +100,7 @@ export default function Table({ data, isFetching, onEdit, onDelete, onDisableTog
}, },
}), }),
], ],
[columnHelper, onDelete, onEdit, onDisableToggle], [columnHelper, onDelete],
); );
const tableInstance = useReactTable<DeadHost>({ const tableInstance = useReactTable<DeadHost>({
@@ -127,9 +115,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,51 +33,43 @@ 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"> <span className="input-group-text input-group-text-sm">
<span className="input-group-text input-group-text-sm"> <IconSearch size={16} />
<IconSearch size={16} /> </span>
</span> <input
<input id="advanced-table-search"
id="advanced-table-search" 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>
<Button size="sm" className="btn-red" onClick={() => setEditId("new")}>
<T id="dead-hosts.add" />
</Button>
</div> </div>
<Button size="sm" className="btn-red" onClick={() => setEditId("new")}>
{intl.formatMessage({ id: "dead-hosts.add" })}
</Button>
</div> </div>
) : null} </div>
</div> </div>
</div> </div>
<Table <Table
data={filtered ?? data ?? []} data={data ?? []}
isFiltered={!!search}
isFetching={isFetching} isFetching={isFetching}
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,52 +23,29 @@ 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"> <span className="input-group-text input-group-text-sm">
<span className="input-group-text input-group-text-sm"> <IconSearch size={16} />
<IconSearch size={16} /> </span>
</span> <input
<input id="advanced-table-search"
id="advanced-table-search" type="text"
type="text" className="form-control form-control-sm"
className="form-control form-control-sm" autoComplete="off"
autoComplete="off" />
/>
</div>
<Button size="sm" className="btn-lime">
<T id="proxy-hosts.add" />
</Button>
</div> </div>
<Button size="sm" className="btn-lime">
{intl.formatMessage({ id: "proxy-hosts.add" })}
</Button>
</div> </div>
) : null} </div>
</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,53 +23,29 @@ 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"> <span className="input-group-text input-group-text-sm">
<span className="input-group-text input-group-text-sm"> <IconSearch size={16} />
<IconSearch size={16} /> </span>
</span> <input
<input id="advanced-table-search"
id="advanced-table-search" 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>
<Button size="sm" className="btn-yellow" onClick={() => setEditId("new")}>
<T id="redirection-hosts.add" />
</Button>
</div> </div>
<Button size="sm" className="btn-yellow">
{intl.formatMessage({ id: "redirection-hosts.add" })}
</Button>
</div> </div>
) : null} </div>
</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} />}
/>
);
} }

Some files were not shown because too many files have changed in this diff Show More