mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2026-01-21 19:25:43 +00:00
Merge branch 'develop' into develop
This commit is contained in:
@@ -535,6 +535,14 @@
|
||||
"credentials": "[default]\naws_access_key_id=AKIAIOSFODNN7EXAMPLE\naws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
"full_plugin_name": "dns-route53"
|
||||
},
|
||||
"simply": {
|
||||
"name": "Simply",
|
||||
"package_name": "certbot-dns-simply",
|
||||
"version": "~=0.1.2",
|
||||
"dependencies": "",
|
||||
"credentials": "dns_simply_account_name = UExxxxxx\ndns_simply_api_key = DsHJdsjh2812872sahj",
|
||||
"full_plugin_name": "dns-simply"
|
||||
},
|
||||
"spaceship": {
|
||||
"name": "Spaceship",
|
||||
"package_name": "certbot-dns-spaceship",
|
||||
|
||||
288
backend/internal/2fa.js
Normal file
288
backend/internal/2fa.js
Normal file
@@ -0,0 +1,288 @@
|
||||
import bcrypt from "bcrypt";
|
||||
import crypto from "node:crypto";
|
||||
import { authenticator } from "otplib";
|
||||
import authModel from "../models/auth.js";
|
||||
import userModel from "../models/user.js";
|
||||
import errs from "../lib/error.js";
|
||||
|
||||
const APP_NAME = "Nginx Proxy Manager";
|
||||
const BACKUP_CODE_COUNT = 8;
|
||||
|
||||
/**
|
||||
* Generate backup codes
|
||||
* @returns {Promise<{plain: string[], hashed: string[]}>}
|
||||
*/
|
||||
const generateBackupCodes = async () => {
|
||||
const plain = [];
|
||||
const hashed = [];
|
||||
|
||||
for (let i = 0; i < BACKUP_CODE_COUNT; i++) {
|
||||
const code = crypto.randomBytes(4).toString("hex").toUpperCase();
|
||||
plain.push(code);
|
||||
const hash = await bcrypt.hash(code, 10);
|
||||
hashed.push(hash);
|
||||
}
|
||||
|
||||
return { plain, hashed };
|
||||
};
|
||||
|
||||
export default {
|
||||
/**
|
||||
* Generate a new TOTP secret
|
||||
* @returns {string}
|
||||
*/
|
||||
generateSecret: () => {
|
||||
return authenticator.generateSecret();
|
||||
},
|
||||
|
||||
/**
|
||||
* Generate otpauth URL for QR code
|
||||
* @param {string} email
|
||||
* @param {string} secret
|
||||
* @returns {string}
|
||||
*/
|
||||
generateOTPAuthURL: (email, secret) => {
|
||||
return authenticator.keyuri(email, APP_NAME, secret);
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify a TOTP code
|
||||
* @param {string} secret
|
||||
* @param {string} code
|
||||
* @returns {boolean}
|
||||
*/
|
||||
verifyCode: (secret, code) => {
|
||||
try {
|
||||
return authenticator.verify({ token: code, secret });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if user has 2FA enabled
|
||||
* @param {number} userId
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
isEnabled: async (userId) => {
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", userId)
|
||||
.where("type", "password")
|
||||
.first();
|
||||
|
||||
if (!auth || !auth.meta) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return auth.meta.totp_enabled === true;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get 2FA status for user
|
||||
* @param {number} userId
|
||||
* @returns {Promise<{enabled: boolean, backupCodesRemaining: number}>}
|
||||
*/
|
||||
getStatus: async (userId) => {
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", userId)
|
||||
.where("type", "password")
|
||||
.first();
|
||||
|
||||
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
|
||||
return { enabled: false, backupCodesRemaining: 0 };
|
||||
}
|
||||
|
||||
const backupCodes = auth.meta.backup_codes || [];
|
||||
return {
|
||||
enabled: true,
|
||||
backupCodesRemaining: backupCodes.length,
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Start 2FA setup - store pending secret
|
||||
* @param {number} userId
|
||||
* @returns {Promise<{secret: string, otpauthUrl: string}>}
|
||||
*/
|
||||
startSetup: async (userId) => {
|
||||
const user = await userModel.query().where("id", userId).first();
|
||||
if (!user) {
|
||||
throw new errs.ItemNotFoundError("User not found");
|
||||
}
|
||||
|
||||
const secret = authenticator.generateSecret();
|
||||
const otpauthUrl = authenticator.keyuri(user.email, APP_NAME, secret);
|
||||
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", userId)
|
||||
.where("type", "password")
|
||||
.first();
|
||||
|
||||
if (!auth) {
|
||||
throw new errs.ItemNotFoundError("Auth record not found");
|
||||
}
|
||||
|
||||
const meta = auth.meta || {};
|
||||
meta.totp_pending_secret = secret;
|
||||
|
||||
await authModel.query().where("id", auth.id).patch({ meta });
|
||||
|
||||
return { secret, otpauthUrl };
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable 2FA after verifying code
|
||||
* @param {number} userId
|
||||
* @param {string} code
|
||||
* @returns {Promise<{backupCodes: string[]}>}
|
||||
*/
|
||||
enable: async (userId, code) => {
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", userId)
|
||||
.where("type", "password")
|
||||
.first();
|
||||
|
||||
if (!auth || !auth.meta || !auth.meta.totp_pending_secret) {
|
||||
throw new errs.ValidationError("No pending 2FA setup found");
|
||||
}
|
||||
|
||||
const secret = auth.meta.totp_pending_secret;
|
||||
const valid = authenticator.verify({ token: code, secret });
|
||||
|
||||
if (!valid) {
|
||||
throw new errs.ValidationError("Invalid verification code");
|
||||
}
|
||||
|
||||
const { plain, hashed } = await generateBackupCodes();
|
||||
|
||||
const meta = {
|
||||
...auth.meta,
|
||||
totp_secret: secret,
|
||||
totp_enabled: true,
|
||||
totp_enabled_at: new Date().toISOString(),
|
||||
backup_codes: hashed,
|
||||
};
|
||||
delete meta.totp_pending_secret;
|
||||
|
||||
await authModel.query().where("id", auth.id).patch({ meta });
|
||||
|
||||
return { backupCodes: plain };
|
||||
},
|
||||
|
||||
/**
|
||||
* Disable 2FA
|
||||
* @param {number} userId
|
||||
* @param {string} code
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
disable: async (userId, code) => {
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", userId)
|
||||
.where("type", "password")
|
||||
.first();
|
||||
|
||||
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
|
||||
throw new errs.ValidationError("2FA is not enabled");
|
||||
}
|
||||
|
||||
const valid = authenticator.verify({
|
||||
token: code,
|
||||
secret: auth.meta.totp_secret,
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new errs.ValidationError("Invalid verification code");
|
||||
}
|
||||
|
||||
const meta = { ...auth.meta };
|
||||
delete meta.totp_secret;
|
||||
delete meta.totp_enabled;
|
||||
delete meta.totp_enabled_at;
|
||||
delete meta.backup_codes;
|
||||
|
||||
await authModel.query().where("id", auth.id).patch({ meta });
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify 2FA code for login
|
||||
* @param {number} userId
|
||||
* @param {string} code
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
verifyForLogin: async (userId, code) => {
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", userId)
|
||||
.where("type", "password")
|
||||
.first();
|
||||
|
||||
if (!auth || !auth.meta || !auth.meta.totp_secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try TOTP code first
|
||||
const valid = authenticator.verify({
|
||||
token: code,
|
||||
secret: auth.meta.totp_secret,
|
||||
});
|
||||
|
||||
if (valid) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Try backup codes
|
||||
const backupCodes = auth.meta.backup_codes || [];
|
||||
for (let i = 0; i < backupCodes.length; i++) {
|
||||
const match = await bcrypt.compare(code.toUpperCase(), backupCodes[i]);
|
||||
if (match) {
|
||||
// Remove used backup code
|
||||
const updatedCodes = [...backupCodes];
|
||||
updatedCodes.splice(i, 1);
|
||||
const meta = { ...auth.meta, backup_codes: updatedCodes };
|
||||
await authModel.query().where("id", auth.id).patch({ meta });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
|
||||
/**
|
||||
* Regenerate backup codes
|
||||
* @param {number} userId
|
||||
* @param {string} code
|
||||
* @returns {Promise<{backupCodes: string[]}>}
|
||||
*/
|
||||
regenerateBackupCodes: async (userId, code) => {
|
||||
const auth = await authModel
|
||||
.query()
|
||||
.where("user_id", userId)
|
||||
.where("type", "password")
|
||||
.first();
|
||||
|
||||
if (!auth || !auth.meta || !auth.meta.totp_enabled) {
|
||||
throw new errs.ValidationError("2FA is not enabled");
|
||||
}
|
||||
|
||||
const valid = authenticator.verify({
|
||||
token: code,
|
||||
secret: auth.meta.totp_secret,
|
||||
});
|
||||
|
||||
if (!valid) {
|
||||
throw new errs.ValidationError("Invalid verification code");
|
||||
}
|
||||
|
||||
const { plain, hashed } = await generateBackupCodes();
|
||||
|
||||
const meta = { ...auth.meta, backup_codes: hashed };
|
||||
await authModel.query().where("id", auth.id).patch({ meta });
|
||||
|
||||
return { backupCodes: plain };
|
||||
},
|
||||
};
|
||||
@@ -798,6 +798,11 @@ const internalCertificate = {
|
||||
certificate.domain_names.join(","),
|
||||
];
|
||||
|
||||
// Add key-type parameter if specified
|
||||
if (certificate.meta?.key_type) {
|
||||
args.push("--key-type", certificate.meta.key_type);
|
||||
}
|
||||
|
||||
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id);
|
||||
args.push(...adds.args);
|
||||
|
||||
@@ -858,6 +863,11 @@ const internalCertificate = {
|
||||
);
|
||||
}
|
||||
|
||||
// Add key-type parameter if specified
|
||||
if (certificate.meta?.key_type) {
|
||||
args.push("--key-type", certificate.meta.key_type);
|
||||
}
|
||||
|
||||
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
|
||||
args.push(...adds.args);
|
||||
|
||||
@@ -938,6 +948,11 @@ const internalCertificate = {
|
||||
"--disable-hook-validation",
|
||||
];
|
||||
|
||||
// Add key-type parameter if specified
|
||||
if (certificate.meta?.key_type) {
|
||||
args.push("--key-type", certificate.meta.key_type);
|
||||
}
|
||||
|
||||
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
|
||||
args.push(...adds.args);
|
||||
|
||||
@@ -979,6 +994,11 @@ const internalCertificate = {
|
||||
"--no-random-sleep-on-renew",
|
||||
];
|
||||
|
||||
// Add key-type parameter if specified
|
||||
if (certificate.meta?.key_type) {
|
||||
args.push("--key-type", certificate.meta.key_type);
|
||||
}
|
||||
|
||||
const adds = internalCertificate.getAdditionalCertbotArgs(certificate.id, certificate.meta.dns_provider);
|
||||
args.push(...adds.args);
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import { parseDatePeriod } from "../lib/helpers.js";
|
||||
import authModel from "../models/auth.js";
|
||||
import TokenModel from "../models/token.js";
|
||||
import userModel from "../models/user.js";
|
||||
import twoFactor from "./2fa.js";
|
||||
|
||||
const ERROR_MESSAGE_INVALID_AUTH = "Invalid email or password";
|
||||
const ERROR_MESSAGE_INVALID_AUTH_I18N = "error.invalid-auth";
|
||||
const ERROR_MESSAGE_INVALID_2FA = "Invalid verification code";
|
||||
const ERROR_MESSAGE_INVALID_2FA_I18N = "error.invalid-2fa";
|
||||
|
||||
export default {
|
||||
/**
|
||||
@@ -59,6 +62,25 @@ export default {
|
||||
throw new errs.AuthError(`Invalid scope: ${data.scope}`);
|
||||
}
|
||||
|
||||
// Check if 2FA is enabled
|
||||
const has2FA = await twoFactor.isEnabled(user.id);
|
||||
if (has2FA) {
|
||||
// Return challenge token instead of full token
|
||||
const challengeToken = await Token.create({
|
||||
iss: issuer || "api",
|
||||
attrs: {
|
||||
id: user.id,
|
||||
},
|
||||
scope: ["2fa-challenge"],
|
||||
expiresIn: "5m",
|
||||
});
|
||||
|
||||
return {
|
||||
requires_2fa: true,
|
||||
challenge_token: challengeToken.token,
|
||||
};
|
||||
}
|
||||
|
||||
// Create a moment of the expiry expression
|
||||
const expiry = parseDatePeriod(data.expiry);
|
||||
if (expiry === null) {
|
||||
@@ -129,6 +151,65 @@ export default {
|
||||
throw new error.AssertionFailedError("Existing token contained invalid user data");
|
||||
},
|
||||
|
||||
/**
|
||||
* Verify 2FA code and return full token
|
||||
* @param {string} challengeToken
|
||||
* @param {string} code
|
||||
* @param {string} [expiry]
|
||||
* @returns {Promise}
|
||||
*/
|
||||
verify2FA: async (challengeToken, code, expiry) => {
|
||||
const Token = TokenModel();
|
||||
const tokenExpiry = expiry || "1d";
|
||||
|
||||
// Verify challenge token
|
||||
let tokenData;
|
||||
try {
|
||||
tokenData = await Token.load(challengeToken);
|
||||
} catch {
|
||||
throw new errs.AuthError("Invalid or expired challenge token");
|
||||
}
|
||||
|
||||
// Check scope
|
||||
if (!tokenData.scope || tokenData.scope[0] !== "2fa-challenge") {
|
||||
throw new errs.AuthError("Invalid challenge token");
|
||||
}
|
||||
|
||||
const userId = tokenData.attrs?.id;
|
||||
if (!userId) {
|
||||
throw new errs.AuthError("Invalid challenge token");
|
||||
}
|
||||
|
||||
// Verify 2FA code
|
||||
const valid = await twoFactor.verifyForLogin(userId, code);
|
||||
if (!valid) {
|
||||
throw new errs.AuthError(
|
||||
ERROR_MESSAGE_INVALID_2FA,
|
||||
ERROR_MESSAGE_INVALID_2FA_I18N,
|
||||
);
|
||||
}
|
||||
|
||||
// Create full token
|
||||
const expiryDate = parseDatePeriod(tokenExpiry);
|
||||
if (expiryDate === null) {
|
||||
throw new errs.AuthError(`Invalid expiry time: ${tokenExpiry}`);
|
||||
}
|
||||
|
||||
const signed = await Token.create({
|
||||
iss: "api",
|
||||
attrs: {
|
||||
id: userId,
|
||||
},
|
||||
scope: ["user"],
|
||||
expiresIn: tokenExpiry,
|
||||
});
|
||||
|
||||
return {
|
||||
token: signed.token,
|
||||
expires: expiryDate.toISOString(),
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {Object} user
|
||||
* @returns {Promise}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"mysql2": "^3.15.3",
|
||||
"node-rsa": "^1.1.1",
|
||||
"objection": "3.0.1",
|
||||
"otplib": "^12.0.1",
|
||||
"path": "^0.12.7",
|
||||
"pg": "^8.16.3",
|
||||
"proxy-agent": "^6.5.0",
|
||||
|
||||
@@ -53,4 +53,35 @@ router
|
||||
}
|
||||
});
|
||||
|
||||
router
|
||||
.route("/2fa")
|
||||
.options((_, res) => {
|
||||
res.sendStatus(204);
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /tokens/2fa
|
||||
*
|
||||
* Verify 2FA code and get full token
|
||||
*/
|
||||
.post(async (req, res, next) => {
|
||||
try {
|
||||
const { challenge_token, code } = req.body;
|
||||
|
||||
if (!challenge_token || !code) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: "Missing challenge_token or code",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await internalToken.verify2FA(challenge_token, code);
|
||||
res.status(200).send(result);
|
||||
} catch (err) {
|
||||
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import express from "express";
|
||||
import internal2FA from "../internal/2fa.js";
|
||||
import internalUser from "../internal/user.js";
|
||||
import Access from "../lib/access.js";
|
||||
import { isCI } from "../lib/config.js";
|
||||
@@ -325,4 +326,186 @@ router
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* User 2FA status
|
||||
*
|
||||
* /api/users/123/2fa
|
||||
*/
|
||||
router
|
||||
.route("/:user_id/2fa")
|
||||
.options((_, res) => {
|
||||
res.sendStatus(204);
|
||||
})
|
||||
.all(jwtdecode())
|
||||
.all(userIdFromMe)
|
||||
|
||||
/**
|
||||
* GET /api/users/123/2fa
|
||||
*
|
||||
* Get 2FA status for a user
|
||||
*/
|
||||
.get(async (req, res, next) => {
|
||||
try {
|
||||
const userId = Number.parseInt(req.params.user_id, 10);
|
||||
const access = res.locals.access;
|
||||
|
||||
// Users can only view their own 2FA status
|
||||
if (access.token.getUserId() !== userId && !access.token.hasScope("admin")) {
|
||||
throw new errs.PermissionError("Cannot view 2FA status for other users");
|
||||
}
|
||||
|
||||
const status = await internal2FA.getStatus(userId);
|
||||
res.status(200).send(status);
|
||||
} catch (err) {
|
||||
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||
next(err);
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* DELETE /api/users/123/2fa
|
||||
*
|
||||
* Disable 2FA for a user
|
||||
*/
|
||||
.delete(async (req, res, next) => {
|
||||
try {
|
||||
const userId = Number.parseInt(req.params.user_id, 10);
|
||||
const access = res.locals.access;
|
||||
|
||||
// Users can only disable their own 2FA
|
||||
if (access.token.getUserId() !== userId && !access.token.hasScope("admin")) {
|
||||
throw new errs.PermissionError("Cannot disable 2FA for other users");
|
||||
}
|
||||
|
||||
const { code } = req.body;
|
||||
if (!code) {
|
||||
throw new errs.ValidationError("Verification code is required");
|
||||
}
|
||||
|
||||
await internal2FA.disable(userId, code);
|
||||
res.status(200).send({ success: true });
|
||||
} catch (err) {
|
||||
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* User 2FA setup
|
||||
*
|
||||
* /api/users/123/2fa/setup
|
||||
*/
|
||||
router
|
||||
.route("/:user_id/2fa/setup")
|
||||
.options((_, res) => {
|
||||
res.sendStatus(204);
|
||||
})
|
||||
.all(jwtdecode())
|
||||
.all(userIdFromMe)
|
||||
|
||||
/**
|
||||
* POST /api/users/123/2fa/setup
|
||||
*
|
||||
* Start 2FA setup, returns QR code URL
|
||||
*/
|
||||
.post(async (req, res, next) => {
|
||||
try {
|
||||
const userId = Number.parseInt(req.params.user_id, 10);
|
||||
const access = res.locals.access;
|
||||
|
||||
// Users can only setup their own 2FA
|
||||
if (access.token.getUserId() !== userId) {
|
||||
throw new errs.PermissionError("Cannot setup 2FA for other users");
|
||||
}
|
||||
|
||||
const result = await internal2FA.startSetup(userId);
|
||||
res.status(200).send(result);
|
||||
} catch (err) {
|
||||
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* User 2FA enable
|
||||
*
|
||||
* /api/users/123/2fa/enable
|
||||
*/
|
||||
router
|
||||
.route("/:user_id/2fa/enable")
|
||||
.options((_, res) => {
|
||||
res.sendStatus(204);
|
||||
})
|
||||
.all(jwtdecode())
|
||||
.all(userIdFromMe)
|
||||
|
||||
/**
|
||||
* PUT /api/users/123/2fa/enable
|
||||
*
|
||||
* Verify code and enable 2FA
|
||||
*/
|
||||
.put(async (req, res, next) => {
|
||||
try {
|
||||
const userId = Number.parseInt(req.params.user_id, 10);
|
||||
const access = res.locals.access;
|
||||
|
||||
// Users can only enable their own 2FA
|
||||
if (access.token.getUserId() !== userId) {
|
||||
throw new errs.PermissionError("Cannot enable 2FA for other users");
|
||||
}
|
||||
|
||||
const { code } = req.body;
|
||||
if (!code) {
|
||||
throw new errs.ValidationError("Verification code is required");
|
||||
}
|
||||
|
||||
const result = await internal2FA.enable(userId, code);
|
||||
res.status(200).send(result);
|
||||
} catch (err) {
|
||||
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* User 2FA backup codes
|
||||
*
|
||||
* /api/users/123/2fa/backup-codes
|
||||
*/
|
||||
router
|
||||
.route("/:user_id/2fa/backup-codes")
|
||||
.options((_, res) => {
|
||||
res.sendStatus(204);
|
||||
})
|
||||
.all(jwtdecode())
|
||||
.all(userIdFromMe)
|
||||
|
||||
/**
|
||||
* POST /api/users/123/2fa/backup-codes
|
||||
*
|
||||
* Regenerate backup codes
|
||||
*/
|
||||
.post(async (req, res, next) => {
|
||||
try {
|
||||
const userId = Number.parseInt(req.params.user_id, 10);
|
||||
const access = res.locals.access;
|
||||
|
||||
// Users can only regenerate their own backup codes
|
||||
if (access.token.getUserId() !== userId) {
|
||||
throw new errs.PermissionError("Cannot regenerate backup codes for other users");
|
||||
}
|
||||
|
||||
const { code } = req.body;
|
||||
if (!code) {
|
||||
throw new errs.ValidationError("Verification code is required");
|
||||
}
|
||||
|
||||
const result = await internal2FA.regenerateBackupCodes(userId, code);
|
||||
res.status(200).send(result);
|
||||
} catch (err) {
|
||||
debug(logger, `${req.method.toUpperCase()} ${req.path}: ${err}`);
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -71,6 +71,11 @@
|
||||
"propagation_seconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"key_type": {
|
||||
"type": "string",
|
||||
"enum": ["rsa", "ecdsa"],
|
||||
"default": "rsa"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
|
||||
@@ -12,6 +12,9 @@ server {
|
||||
|
||||
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
|
||||
|
||||
access_log /data/logs/stream-{{ id }}_access.log stream;
|
||||
error_log /data/logs/stream-{{ id }}_error.log warn;
|
||||
|
||||
# Custom
|
||||
include /data/nginx/custom/server_stream[.]conf;
|
||||
include /data/nginx/custom/server_stream_tcp[.]conf;
|
||||
@@ -25,6 +28,9 @@ server {
|
||||
|
||||
proxy_pass {{ forwarding_host }}:{{ forwarding_port }};
|
||||
|
||||
access_log /data/logs/stream-{{ id }}_access.log stream;
|
||||
error_log /data/logs/stream-{{ id }}_error.log warn;
|
||||
|
||||
# Custom
|
||||
include /data/nginx/custom/server_stream[.]conf;
|
||||
include /data/nginx/custom/server_stream_udp[.]conf;
|
||||
|
||||
@@ -138,6 +138,44 @@
|
||||
mkdirp "^1.0.4"
|
||||
rimraf "^3.0.2"
|
||||
|
||||
"@otplib/core@^12.0.1":
|
||||
version "12.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/core/-/core-12.0.1.tgz#73720a8cedce211fe5b3f683cd5a9c098eaf0f8d"
|
||||
integrity sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==
|
||||
|
||||
"@otplib/plugin-crypto@^12.0.1":
|
||||
version "12.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz#2b42c624227f4f9303c1c041fca399eddcbae25e"
|
||||
integrity sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==
|
||||
dependencies:
|
||||
"@otplib/core" "^12.0.1"
|
||||
|
||||
"@otplib/plugin-thirty-two@^12.0.1":
|
||||
version "12.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz#5cc9b56e6e89f2a1fe4a2b38900ca4e11c87aa9e"
|
||||
integrity sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==
|
||||
dependencies:
|
||||
"@otplib/core" "^12.0.1"
|
||||
thirty-two "^1.0.2"
|
||||
|
||||
"@otplib/preset-default@^12.0.1":
|
||||
version "12.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/preset-default/-/preset-default-12.0.1.tgz#cb596553c08251e71b187ada4a2246ad2a3165ba"
|
||||
integrity sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==
|
||||
dependencies:
|
||||
"@otplib/core" "^12.0.1"
|
||||
"@otplib/plugin-crypto" "^12.0.1"
|
||||
"@otplib/plugin-thirty-two" "^12.0.1"
|
||||
|
||||
"@otplib/preset-v11@^12.0.1":
|
||||
version "12.0.1"
|
||||
resolved "https://registry.yarnpkg.com/@otplib/preset-v11/-/preset-v11-12.0.1.tgz#4c7266712e7230500b421ba89252963c838fc96d"
|
||||
integrity sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==
|
||||
dependencies:
|
||||
"@otplib/core" "^12.0.1"
|
||||
"@otplib/plugin-crypto" "^12.0.1"
|
||||
"@otplib/plugin-thirty-two" "^12.0.1"
|
||||
|
||||
"@tootallnate/once@1":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82"
|
||||
@@ -1978,6 +2016,15 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0:
|
||||
dependencies:
|
||||
wrappy "1"
|
||||
|
||||
otplib@^12.0.1:
|
||||
version "12.0.1"
|
||||
resolved "https://registry.yarnpkg.com/otplib/-/otplib-12.0.1.tgz#c1d3060ab7aadf041ed2960302f27095777d1f73"
|
||||
integrity sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==
|
||||
dependencies:
|
||||
"@otplib/core" "^12.0.1"
|
||||
"@otplib/preset-default" "^12.0.1"
|
||||
"@otplib/preset-v11" "^12.0.1"
|
||||
|
||||
p-limit@^1.1.0:
|
||||
version "1.3.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-1.3.0.tgz#b86bd5f0c25690911c7590fcbfc2010d54b3ccb8"
|
||||
@@ -2736,6 +2783,11 @@ temp-write@^4.0.0:
|
||||
temp-dir "^1.0.0"
|
||||
uuid "^3.3.2"
|
||||
|
||||
thirty-two@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/thirty-two/-/thirty-two-1.0.2.tgz#4ca2fffc02a51290d2744b9e3f557693ca6b627a"
|
||||
integrity sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==
|
||||
|
||||
tildify@2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/tildify/-/tildify-2.0.0.tgz#f205f3674d677ce698b7067a99e949ce03b4754a"
|
||||
|
||||
@@ -8,8 +8,8 @@ server {
|
||||
set $port "80";
|
||||
|
||||
server_name localhost-nginx-proxy-manager;
|
||||
access_log /data/logs/fallback_access.log standard;
|
||||
error_log /data/logs/fallback_error.log warn;
|
||||
access_log /data/logs/fallback_http_access.log standard;
|
||||
error_log /data/logs/fallback_http_error.log warn;
|
||||
include conf.d/include/assets.conf;
|
||||
include conf.d/include/block-exploits.conf;
|
||||
include conf.d/include/letsencrypt-acme-challenge.conf;
|
||||
@@ -30,7 +30,7 @@ server {
|
||||
set $port "443";
|
||||
|
||||
server_name localhost;
|
||||
access_log /data/logs/fallback_access.log standard;
|
||||
access_log /data/logs/fallback_http_access.log standard;
|
||||
error_log /dev/null crit;
|
||||
include conf.d/include/ssl-ciphers.conf;
|
||||
ssl_reject_handshake on;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
log_format proxy '[$time_local] $upstream_cache_status $upstream_status $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] [Sent-to $server] "$http_user_agent" "$http_referer"';
|
||||
log_format standard '[$time_local] $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] "$http_user_agent" "$http_referer"';
|
||||
|
||||
access_log /data/logs/fallback_access.log proxy;
|
||||
access_log /data/logs/fallback_http_access.log proxy;
|
||||
3
docker/rootfs/etc/nginx/conf.d/include/log-stream.conf
Normal file
3
docker/rootfs/etc/nginx/conf.d/include/log-stream.conf
Normal file
@@ -0,0 +1,3 @@
|
||||
log_format stream '[$time_local] [Client $remote_addr:$remote_port] $protocol $status $bytes_sent $bytes_received $session_time [Sent-to $upstream_addr] [Sent $upstream_bytes_sent] [Received $upstream_bytes_received] [Time $upstream_connect_time] $ssl_protocol $ssl_cipher';
|
||||
|
||||
access_log /data/logs/fallback_stream_access.log stream;
|
||||
@@ -47,7 +47,7 @@ http {
|
||||
proxy_cache_path /var/lib/nginx/cache/private levels=1:2 keys_zone=private-cache:5m max_size=1024m;
|
||||
|
||||
# Log format and fallback log file
|
||||
include /etc/nginx/conf.d/include/log.conf;
|
||||
include /etc/nginx/conf.d/include/log-proxy.conf;
|
||||
|
||||
# Dynamically generated resolvers file
|
||||
include /etc/nginx/conf.d/include/resolvers.conf;
|
||||
@@ -85,6 +85,9 @@ http {
|
||||
}
|
||||
|
||||
stream {
|
||||
# Log format and fallback log file
|
||||
include /etc/nginx/conf.d/include/log-stream.conf;
|
||||
|
||||
# Files generated by NPM
|
||||
include /data/nginx/stream/*.conf;
|
||||
|
||||
|
||||
176
docs/src/2fa-implementation.md
Normal file
176
docs/src/2fa-implementation.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Two-Factor Authentication Implementation
|
||||
|
||||
> **Note:** This document should be deleted after PR approval. It serves as a reference for reviewers to understand the scope of the contribution.
|
||||
|
||||
---
|
||||
|
||||
**Acknowledgments**
|
||||
|
||||
Thanks to all contributors and authors from the Inte.Team for the great work on Nginx Proxy Manager. It saves us time and effort, and we're happy to contribute back to the project.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Add TOTP-based two-factor authentication to the login flow. Users can enable 2FA from their profile settings, scan a QR code with any authenticator app (Google Authenticator, Authy, etc.), and will be required to enter a 6-digit code on login.
|
||||
|
||||
## Current Authentication Flow
|
||||
|
||||
```
|
||||
POST /tokens {identity, secret}
|
||||
-> Validate user exists and is not disabled
|
||||
-> Verify password against auth.secret
|
||||
-> Return JWT token
|
||||
```
|
||||
|
||||
## Proposed 2FA Flow
|
||||
|
||||
```
|
||||
POST /tokens {identity, secret}
|
||||
-> Validate user exists and is not disabled
|
||||
-> Verify password against auth.secret
|
||||
-> If 2FA enabled:
|
||||
Return {requires_2fa: true, challenge_token: <short-lived JWT>}
|
||||
-> Else:
|
||||
Return {token: <JWT>, expires: <timestamp>}
|
||||
|
||||
POST /tokens/2fa {challenge_token, code}
|
||||
-> Validate challenge_token
|
||||
-> Verify TOTP code against user's secret
|
||||
-> Return {token: <JWT>, expires: <timestamp>}
|
||||
```
|
||||
|
||||
## Database Changes
|
||||
|
||||
Extend the existing `auth.meta` JSON column to store 2FA data:
|
||||
|
||||
```json
|
||||
{
|
||||
"totp_secret": "<encrypted-secret>",
|
||||
"totp_enabled": true,
|
||||
"totp_enabled_at": "<timestamp>",
|
||||
"backup_codes": ["<hashed-code-1>", "<hashed-code-2>", ...]
|
||||
}
|
||||
```
|
||||
|
||||
No new tables required. The `auth.meta` column is already designed for this purpose.
|
||||
|
||||
## Backend Changes
|
||||
|
||||
### New Files
|
||||
|
||||
1. `backend/internal/2fa.js` - Core 2FA logic
|
||||
- `generateSecret()` - Generate TOTP secret
|
||||
- `generateQRCodeURL(user, secret)` - Generate otpauth URL
|
||||
- `verifyCode(secret, code)` - Verify TOTP code
|
||||
- `generateBackupCodes()` - Generate 8 backup codes
|
||||
- `verifyBackupCode(user, code)` - Verify and consume backup code
|
||||
|
||||
2. `backend/routes/2fa.js` - 2FA management endpoints
|
||||
- `GET /users/:id/2fa` - Get 2FA status
|
||||
- `POST /users/:id/2fa/setup` - Start 2FA setup, return QR code
|
||||
- `PUT /users/:id/2fa/enable` - Verify code and enable 2FA
|
||||
- `DELETE /users/:id/2fa` - Disable 2FA (requires code)
|
||||
- `GET /users/:id/2fa/backup-codes` - View remaining backup codes count
|
||||
- `POST /users/:id/2fa/backup-codes` - Regenerate backup codes
|
||||
|
||||
### Modified Files
|
||||
|
||||
1. `backend/internal/token.js`
|
||||
- Update `getTokenFromEmail()` to check for 2FA
|
||||
- Add `verifyTwoFactorChallenge()` function
|
||||
- Add `createChallengeToken()` for short-lived 2FA tokens
|
||||
|
||||
2. `backend/routes/tokens.js`
|
||||
- Add `POST /tokens/2fa` endpoint
|
||||
|
||||
3. `backend/index.js`
|
||||
- Register new 2FA routes
|
||||
|
||||
### Dependencies
|
||||
|
||||
Add to `package.json`:
|
||||
```json
|
||||
"otplib": "^12.0.1"
|
||||
```
|
||||
|
||||
## Frontend Changes
|
||||
|
||||
### New Files
|
||||
|
||||
1. `frontend/src/pages/Login2FA/index.tsx` - 2FA code entry page
|
||||
2. `frontend/src/modals/TwoFactorSetupModal.tsx` - Setup wizard modal
|
||||
3. `frontend/src/api/backend/twoFactor.ts` - 2FA API functions
|
||||
4. `frontend/src/api/backend/verify2FA.ts` - Token verification
|
||||
|
||||
### Modified Files
|
||||
|
||||
1. `frontend/src/api/backend/responseTypes.ts`
|
||||
- Add `TwoFactorChallengeResponse` type
|
||||
- Add `TwoFactorStatusResponse` type
|
||||
|
||||
2. `frontend/src/context/AuthContext.tsx`
|
||||
- Add `twoFactorRequired` state
|
||||
- Add `challengeToken` state
|
||||
- Update `login()` to handle 2FA response
|
||||
- Add `verify2FA()` function
|
||||
|
||||
3. `frontend/src/pages/Login/index.tsx`
|
||||
- Handle 2FA challenge response
|
||||
- Redirect to 2FA entry when required
|
||||
|
||||
4. `frontend/src/pages/Settings/` (or user profile)
|
||||
- Add 2FA enable/disable section
|
||||
|
||||
### Dependencies
|
||||
|
||||
Add to `package.json`:
|
||||
```json
|
||||
"qrcode.react": "^3.1.0"
|
||||
```
|
||||
|
||||
## API Endpoints Summary
|
||||
|
||||
| Method | Endpoint | Auth | Description |
|
||||
|--------|----------|------|-------------|
|
||||
| POST | /tokens | No | Login (returns challenge if 2FA) |
|
||||
| POST | /tokens/2fa | Challenge | Complete 2FA login |
|
||||
| GET | /users/:id/2fa | JWT | Get 2FA status |
|
||||
| POST | /users/:id/2fa/setup | JWT | Start setup, get QR code |
|
||||
| PUT | /users/:id/2fa/enable | JWT | Verify and enable |
|
||||
| DELETE | /users/:id/2fa | JWT | Disable (requires code) |
|
||||
| GET | /users/:id/2fa/backup-codes | JWT | Get backup codes count |
|
||||
| POST | /users/:id/2fa/backup-codes | JWT | Regenerate codes |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. Challenge tokens expire in 5 minutes
|
||||
2. TOTP secrets encrypted at rest
|
||||
3. Backup codes hashed with bcrypt
|
||||
4. Rate limit on 2FA attempts (5 attempts, 15 min lockout)
|
||||
5. Backup codes single-use only
|
||||
6. 2FA disable requires valid TOTP code
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Backend: Add `otplib` dependency
|
||||
2. Backend: Create `internal/2fa.js` module
|
||||
3. Backend: Update `internal/token.js` for challenge flow
|
||||
4. Backend: Add `POST /tokens/2fa` route
|
||||
5. Backend: Create `routes/2fa.js` for management
|
||||
6. Frontend: Add `qrcode.react` dependency
|
||||
7. Frontend: Update API types and functions
|
||||
8. Frontend: Update AuthContext for 2FA state
|
||||
9. Frontend: Create Login2FA page
|
||||
10. Frontend: Update Login to handle 2FA
|
||||
11. Frontend: Add 2FA settings UI
|
||||
|
||||
## Testing
|
||||
|
||||
1. Enable 2FA for user
|
||||
2. Login with password only - should get challenge
|
||||
3. Submit correct TOTP - should get token
|
||||
4. Submit wrong TOTP - should fail
|
||||
5. Use backup code - should work once
|
||||
6. Disable 2FA - should require valid code
|
||||
7. Login after disable - should work without 2FA
|
||||
@@ -20,6 +20,7 @@ const allLocales = [
|
||||
["zh", "zh-CN"],
|
||||
["ko", "ko-KR"],
|
||||
["bg", "bg-BG"],
|
||||
["id", "id-ID"],
|
||||
];
|
||||
|
||||
const ignoreUnused = [
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import * as api from "./base";
|
||||
import type { TokenResponse } from "./responseTypes";
|
||||
import type { TokenResponse, TwoFactorChallengeResponse } from "./responseTypes";
|
||||
|
||||
export async function getToken(identity: string, secret: string): Promise<TokenResponse> {
|
||||
export type LoginResponse = TokenResponse | TwoFactorChallengeResponse;
|
||||
|
||||
export function isTwoFactorChallenge(response: LoginResponse): response is TwoFactorChallengeResponse {
|
||||
return "requires2fa" in response && response.requires2fa === true;
|
||||
}
|
||||
|
||||
export async function getToken(identity: string, secret: string): Promise<LoginResponse> {
|
||||
return await api.post({
|
||||
url: "/tokens",
|
||||
data: { identity, secret },
|
||||
});
|
||||
}
|
||||
|
||||
export async function verify2FA(challengeToken: string, code: string): Promise<TokenResponse> {
|
||||
return await api.post({
|
||||
url: "/tokens/2fa",
|
||||
data: { challengeToken, code },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -60,3 +60,4 @@ export * from "./updateStream";
|
||||
export * from "./updateUser";
|
||||
export * from "./uploadCertificate";
|
||||
export * from "./validateCertificate";
|
||||
export * from "./twoFactor";
|
||||
|
||||
@@ -25,3 +25,22 @@ export interface VersionCheckResponse {
|
||||
latest: string | null;
|
||||
updateAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface TwoFactorChallengeResponse {
|
||||
requires2fa: boolean;
|
||||
challengeToken: string;
|
||||
}
|
||||
|
||||
export interface TwoFactorStatusResponse {
|
||||
enabled: boolean;
|
||||
backupCodesRemaining: number;
|
||||
}
|
||||
|
||||
export interface TwoFactorSetupResponse {
|
||||
secret: string;
|
||||
otpauthUrl: string;
|
||||
}
|
||||
|
||||
export interface TwoFactorEnableResponse {
|
||||
backupCodes: string[];
|
||||
}
|
||||
|
||||
58
frontend/src/api/backend/twoFactor.ts
Normal file
58
frontend/src/api/backend/twoFactor.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { camelizeKeys, decamelizeKeys } from "humps";
|
||||
import AuthStore from "src/modules/AuthStore";
|
||||
import type {
|
||||
TwoFactorEnableResponse,
|
||||
TwoFactorSetupResponse,
|
||||
TwoFactorStatusResponse,
|
||||
} from "./responseTypes";
|
||||
import * as api from "./base";
|
||||
|
||||
export async function get2FAStatus(userId: number | "me"): Promise<TwoFactorStatusResponse> {
|
||||
return await api.get({
|
||||
url: `/users/${userId}/2fa`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function start2FASetup(userId: number | "me"): Promise<TwoFactorSetupResponse> {
|
||||
return await api.post({
|
||||
url: `/users/${userId}/2fa/setup`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function enable2FA(userId: number | "me", code: string): Promise<TwoFactorEnableResponse> {
|
||||
return await api.put({
|
||||
url: `/users/${userId}/2fa/enable`,
|
||||
data: { code },
|
||||
});
|
||||
}
|
||||
|
||||
export async function disable2FA(userId: number | "me", code: string): Promise<{ success: boolean }> {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (AuthStore.token) {
|
||||
headers.Authorization = `Bearer ${AuthStore.token.token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/users/${userId}/2fa`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
body: JSON.stringify(decamelizeKeys({ code })),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error?.messageI18n || payload.error?.message || "Failed to disable 2FA");
|
||||
}
|
||||
return camelizeKeys(payload) as { success: boolean };
|
||||
}
|
||||
|
||||
export async function regenerateBackupCodes(
|
||||
userId: number | "me",
|
||||
code: string,
|
||||
): Promise<TwoFactorEnableResponse> {
|
||||
return await api.post({
|
||||
url: `/users/${userId}/2fa/backup-codes`,
|
||||
data: { code },
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { IconLock, IconLogout, IconUser } from "@tabler/icons-react";
|
||||
import { IconLock, IconLogout, IconShieldLock, IconUser } from "@tabler/icons-react";
|
||||
import { LocalePicker, NavLink, ThemeSwitcher } from "src/components";
|
||||
import { useAuthState } from "src/context";
|
||||
import { useUser } from "src/hooks";
|
||||
import { T } from "src/locale";
|
||||
import { showChangePasswordModal, showUserModal } from "src/modals";
|
||||
import { showChangePasswordModal, showTwoFactorModal, showUserModal } from "src/modals";
|
||||
import styles from "./SiteHeader.module.css";
|
||||
|
||||
export function SiteHeader() {
|
||||
@@ -108,6 +108,17 @@ export function SiteHeader() {
|
||||
<IconLock width={18} />
|
||||
<T id="user.change-password" />
|
||||
</a>
|
||||
<a
|
||||
href="?"
|
||||
className="dropdown-item"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
showTwoFactorModal("me");
|
||||
}}
|
||||
>
|
||||
<IconShieldLock width={18} />
|
||||
<T id="user.two-factor" />
|
||||
</a>
|
||||
<div className="dropdown-divider" />
|
||||
<a
|
||||
href="?"
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { createContext, type ReactNode, useContext, useState } from "react";
|
||||
import { useIntervalWhen } from "rooks";
|
||||
import { getToken, loginAsUser, refreshToken, type TokenResponse } from "src/api/backend";
|
||||
import {
|
||||
getToken,
|
||||
isTwoFactorChallenge,
|
||||
loginAsUser,
|
||||
refreshToken,
|
||||
verify2FA,
|
||||
type TokenResponse,
|
||||
} from "src/api/backend";
|
||||
import AuthStore from "src/modules/AuthStore";
|
||||
|
||||
// 2FA challenge state
|
||||
export interface TwoFactorChallenge {
|
||||
challengeToken: string;
|
||||
}
|
||||
|
||||
// Context
|
||||
export interface AuthContextType {
|
||||
authenticated: boolean;
|
||||
twoFactorChallenge: TwoFactorChallenge | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
verifyTwoFactor: (code: string) => Promise<void>;
|
||||
cancelTwoFactor: () => void;
|
||||
loginAs: (id: number) => Promise<void>;
|
||||
logout: () => void;
|
||||
token?: string;
|
||||
@@ -24,17 +39,35 @@ interface Props {
|
||||
function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [authenticated, setAuthenticated] = useState(AuthStore.hasActiveToken());
|
||||
const [twoFactorChallenge, setTwoFactorChallenge] = useState<TwoFactorChallenge | null>(null);
|
||||
|
||||
const handleTokenUpdate = (response: TokenResponse) => {
|
||||
AuthStore.set(response);
|
||||
setAuthenticated(true);
|
||||
setTwoFactorChallenge(null);
|
||||
};
|
||||
|
||||
const login = async (identity: string, secret: string) => {
|
||||
const response = await getToken(identity, secret);
|
||||
if (isTwoFactorChallenge(response)) {
|
||||
setTwoFactorChallenge({ challengeToken: response.challengeToken });
|
||||
return;
|
||||
}
|
||||
handleTokenUpdate(response);
|
||||
};
|
||||
|
||||
const verifyTwoFactor = async (code: string) => {
|
||||
if (!twoFactorChallenge) {
|
||||
throw new Error("No 2FA challenge pending");
|
||||
}
|
||||
const response = await verify2FA(twoFactorChallenge.challengeToken, code);
|
||||
handleTokenUpdate(response);
|
||||
};
|
||||
|
||||
const cancelTwoFactor = () => {
|
||||
setTwoFactorChallenge(null);
|
||||
};
|
||||
|
||||
const loginAs = async (id: number) => {
|
||||
const response = await loginAsUser(id);
|
||||
AuthStore.add(response);
|
||||
@@ -69,7 +102,15 @@ function AuthProvider({ children, tokenRefreshInterval = 5 * 60 * 1000 }: Props)
|
||||
true,
|
||||
);
|
||||
|
||||
const value = { authenticated, login, logout, loginAs };
|
||||
const value = {
|
||||
authenticated,
|
||||
twoFactorChallenge,
|
||||
login,
|
||||
verifyTwoFactor,
|
||||
cancelTwoFactor,
|
||||
loginAs,
|
||||
logout,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import langVi from "./lang/vi.json";
|
||||
import langZh from "./lang/zh.json";
|
||||
import langKo from "./lang/ko.json";
|
||||
import langBg from "./lang/bg.json";
|
||||
import langId from "./lang/id.json";
|
||||
|
||||
// first item of each array should be the language code,
|
||||
// not the country code
|
||||
@@ -33,6 +34,7 @@ const localeOptions = [
|
||||
["zh", "zh-CN", langZh],
|
||||
["ko", "ko-KR", langKo],
|
||||
["bg", "bg-BG", langBg],
|
||||
["id", "id-ID", langId],
|
||||
];
|
||||
|
||||
const loadMessages = (locale?: string): typeof langList & typeof langEn => {
|
||||
|
||||
7
frontend/src/locale/src/HelpDoc/id/AccessLists.md
Normal file
7
frontend/src/locale/src/HelpDoc/id/AccessLists.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## Apa itu Daftar Akses?
|
||||
|
||||
Daftar Akses menyediakan daftar hitam atau daftar putih alamat IP klien tertentu beserta autentikasi untuk Host Proxy melalui Autentikasi HTTP Basic.
|
||||
|
||||
Anda dapat mengonfigurasi beberapa aturan klien, nama pengguna, dan kata sandi untuk satu Daftar Akses lalu menerapkannya ke satu atau lebih _Host Proxy_.
|
||||
|
||||
Ini paling berguna untuk layanan web yang diteruskan yang tidak memiliki mekanisme autentikasi bawaan atau ketika Anda ingin melindungi dari klien yang tidak dikenal.
|
||||
32
frontend/src/locale/src/HelpDoc/id/Certificates.md
Normal file
32
frontend/src/locale/src/HelpDoc/id/Certificates.md
Normal file
@@ -0,0 +1,32 @@
|
||||
## Bantuan Sertifikat
|
||||
|
||||
### Sertifikat HTTP
|
||||
|
||||
Sertifikat yang divalidasi HTTP berarti server Let's Encrypt akan
|
||||
mencoba menjangkau domain Anda melalui HTTP (bukan HTTPS!) dan jika berhasil, mereka
|
||||
akan menerbitkan sertifikat Anda.
|
||||
|
||||
Untuk metode ini, Anda harus membuat _Host Proxy_ untuk domain Anda yang
|
||||
dapat diakses dengan HTTP dan mengarah ke instalasi Nginx ini. Setelah sertifikat
|
||||
diberikan, Anda dapat mengubah _Host Proxy_ agar juga menggunakan sertifikat ini untuk HTTPS
|
||||
koneksi. Namun, _Host Proxy_ tetap perlu dikonfigurasi untuk akses HTTP
|
||||
agar sertifikat dapat diperpanjang.
|
||||
|
||||
Proses ini _tidak_ mendukung domain wildcard.
|
||||
|
||||
### Sertifikat DNS
|
||||
|
||||
Sertifikat yang divalidasi DNS mengharuskan Anda menggunakan plugin Penyedia DNS. Penyedia DNS ini
|
||||
akan digunakan untuk membuat record sementara pada domain Anda dan kemudian Let's
|
||||
Encrypt akan menanyakan record tersebut untuk memastikan Anda pemiliknya dan jika berhasil, mereka
|
||||
akan menerbitkan sertifikat Anda.
|
||||
|
||||
Anda tidak perlu membuat _Host Proxy_ sebelum meminta jenis sertifikat ini.
|
||||
Anda juga tidak perlu mengonfigurasi _Host Proxy_ untuk akses HTTP.
|
||||
|
||||
Proses ini _mendukung_ domain wildcard.
|
||||
|
||||
### Sertifikat Kustom
|
||||
|
||||
Gunakan opsi ini untuk mengunggah Sertifikat SSL Anda sendiri, sebagaimana disediakan oleh
|
||||
Certificate Authority Anda.
|
||||
10
frontend/src/locale/src/HelpDoc/id/DeadHosts.md
Normal file
10
frontend/src/locale/src/HelpDoc/id/DeadHosts.md
Normal file
@@ -0,0 +1,10 @@
|
||||
## Apa itu Host 404?
|
||||
|
||||
Host 404 adalah konfigurasi host yang menampilkan halaman 404.
|
||||
|
||||
Ini dapat berguna ketika domain Anda terindeks di mesin pencari dan Anda ingin
|
||||
menyediakan halaman error yang lebih baik atau secara khusus memberi tahu pengindeks pencarian bahwa
|
||||
halaman domain tersebut sudah tidak ada.
|
||||
|
||||
Manfaat lain memiliki host ini adalah melacak log untuk akses ke host tersebut dan
|
||||
melihat perujuk.
|
||||
7
frontend/src/locale/src/HelpDoc/id/ProxyHosts.md
Normal file
7
frontend/src/locale/src/HelpDoc/id/ProxyHosts.md
Normal file
@@ -0,0 +1,7 @@
|
||||
## Apa itu Host Proxy?
|
||||
|
||||
Host Proxy adalah endpoint masuk untuk layanan web yang ingin Anda teruskan.
|
||||
|
||||
Host ini menyediakan terminasi SSL opsional untuk layanan Anda yang mungkin tidak memiliki dukungan SSL bawaan.
|
||||
|
||||
Host Proxy adalah penggunaan paling umum untuk Nginx Proxy Manager.
|
||||
5
frontend/src/locale/src/HelpDoc/id/RedirectionHosts.md
Normal file
5
frontend/src/locale/src/HelpDoc/id/RedirectionHosts.md
Normal file
@@ -0,0 +1,5 @@
|
||||
## Apa itu Host Pengalihan?
|
||||
|
||||
Host Pengalihan akan mengalihkan permintaan dari domain masuk dan mengarahkan pengunjung ke domain lain.
|
||||
|
||||
Alasan paling umum menggunakan jenis host ini adalah ketika situs Anda berpindah domain tetapi masih ada tautan mesin pencari atau perujuk yang mengarah ke domain lama.
|
||||
6
frontend/src/locale/src/HelpDoc/id/Streams.md
Normal file
6
frontend/src/locale/src/HelpDoc/id/Streams.md
Normal file
@@ -0,0 +1,6 @@
|
||||
## Apa itu Stream?
|
||||
|
||||
Fitur yang relatif baru untuk Nginx, Stream berfungsi untuk meneruskan trafik TCP/UDP
|
||||
langsung ke komputer lain di jaringan.
|
||||
|
||||
Jika Anda menjalankan server game, FTP, atau SSH, ini bisa sangat membantu.
|
||||
6
frontend/src/locale/src/HelpDoc/id/index.ts
Normal file
6
frontend/src/locale/src/HelpDoc/id/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * as AccessLists from "./AccessLists.md";
|
||||
export * as Certificates from "./Certificates.md";
|
||||
export * as DeadHosts from "./DeadHosts.md";
|
||||
export * as ProxyHosts from "./ProxyHosts.md";
|
||||
export * as RedirectionHosts from "./RedirectionHosts.md";
|
||||
export * as Streams from "./Streams.md";
|
||||
@@ -1,5 +1,7 @@
|
||||
import * as de from "./de/index";
|
||||
import * as en from "./en/index";
|
||||
import * as ga from './ga/index'
|
||||
import * as id from "./id/index";
|
||||
import * as it from "./it/index";
|
||||
import * as ja from "./ja/index";
|
||||
import * as nl from "./nl/index";
|
||||
@@ -10,9 +12,8 @@ import * as vi from "./vi/index";
|
||||
import * as zh from "./zh/index";
|
||||
import * as ko from "./ko/index";
|
||||
import * as bg from "./bg/index";
|
||||
import * as ga from './ga/index'
|
||||
|
||||
const items: any = { en, de, ja, sk, zh, pl, ru, it, vi, nl, bg, ko, ga }
|
||||
const items: any = { en, de, ja, sk, zh, pl, ru, it, vi, nl, bg, ko, ga, id }
|
||||
|
||||
const fallbackLang = "en";
|
||||
|
||||
|
||||
@@ -170,6 +170,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "Тези домейни трябва вече да сочат към тази инсталация."
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "Тип ключ"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA е широко съвместим, ECDSA е по-бърз и по-сигурен, но може да не се поддържа от по-стари системи"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "с Let's Encrypt"
|
||||
},
|
||||
|
||||
@@ -155,6 +155,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "Diese Domänen müssen bereits so konfiguriert sein, dass sie auf diese Installation verweisen."
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "Schlüsseltyp"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA ist weit verbreitet, ECDSA ist schneller und sicherer, wird aber möglicherweise von älteren Systemen nicht unterstützt"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "Über Let's Encrypt"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,61 @@
|
||||
{
|
||||
"2fa.backup-codes-remaining": {
|
||||
"defaultMessage": "Backup codes remaining: {count}"
|
||||
},
|
||||
"2fa.backup-warning": {
|
||||
"defaultMessage": "Save these backup codes in a secure place. Each code can only be used once."
|
||||
},
|
||||
"2fa.disable": {
|
||||
"defaultMessage": "Disable Two-Factor Authentication"
|
||||
},
|
||||
"2fa.disable-confirm": {
|
||||
"defaultMessage": "Disable 2FA"
|
||||
},
|
||||
"2fa.disable-warning": {
|
||||
"defaultMessage": "Disabling two-factor authentication will make your account less secure."
|
||||
},
|
||||
"2fa.disabled": {
|
||||
"defaultMessage": "Disabled"
|
||||
},
|
||||
"2fa.done": {
|
||||
"defaultMessage": "I have saved my backup codes"
|
||||
},
|
||||
"2fa.enable": {
|
||||
"defaultMessage": "Enable Two-Factor Authentication"
|
||||
},
|
||||
"2fa.enabled": {
|
||||
"defaultMessage": "Enabled"
|
||||
},
|
||||
"2fa.enter-code": {
|
||||
"defaultMessage": "Enter verification code"
|
||||
},
|
||||
"2fa.enter-code-disable": {
|
||||
"defaultMessage": "Enter verification code to disable"
|
||||
},
|
||||
"2fa.regenerate": {
|
||||
"defaultMessage": "Regenerate"
|
||||
},
|
||||
"2fa.regenerate-backup": {
|
||||
"defaultMessage": "Regenerate Backup Codes"
|
||||
},
|
||||
"2fa.regenerate-instructions": {
|
||||
"defaultMessage": "Enter a verification code to generate new backup codes. Your old codes will be invalidated."
|
||||
},
|
||||
"2fa.secret-key": {
|
||||
"defaultMessage": "Secret Key"
|
||||
},
|
||||
"2fa.setup-instructions": {
|
||||
"defaultMessage": "Scan this QR code with your authenticator app, or enter the secret manually."
|
||||
},
|
||||
"2fa.status": {
|
||||
"defaultMessage": "Status"
|
||||
},
|
||||
"2fa.title": {
|
||||
"defaultMessage": "Two-Factor Authentication"
|
||||
},
|
||||
"2fa.verify-enable": {
|
||||
"defaultMessage": "Verify and Enable"
|
||||
},
|
||||
"access-list": {
|
||||
"defaultMessage": "Access List"
|
||||
},
|
||||
@@ -170,6 +227,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "These domains must be already configured to point to this installation."
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "Key Type"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA is widely compatible, ECDSA is faster and more secure but may not be supported by older systems"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "with Let's Encrypt"
|
||||
},
|
||||
@@ -386,6 +455,21 @@
|
||||
"loading": {
|
||||
"defaultMessage": "Loading…"
|
||||
},
|
||||
"login.2fa-code": {
|
||||
"defaultMessage": "Verification Code"
|
||||
},
|
||||
"login.2fa-code-placeholder": {
|
||||
"defaultMessage": "Enter code"
|
||||
},
|
||||
"login.2fa-description": {
|
||||
"defaultMessage": "Enter the code from your authenticator app"
|
||||
},
|
||||
"login.2fa-title": {
|
||||
"defaultMessage": "Two-Factor Authentication"
|
||||
},
|
||||
"login.2fa-verify": {
|
||||
"defaultMessage": "Verify"
|
||||
},
|
||||
"login.title": {
|
||||
"defaultMessage": "Login to your account"
|
||||
},
|
||||
@@ -674,6 +758,9 @@
|
||||
"user.switch-light": {
|
||||
"defaultMessage": "Switch to Light mode"
|
||||
},
|
||||
"user.two-factor": {
|
||||
"defaultMessage": "Two-Factor Auth"
|
||||
},
|
||||
"username": {
|
||||
"defaultMessage": "Username"
|
||||
},
|
||||
|
||||
@@ -170,6 +170,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "Estos dominios ya deben estar configurados para apuntar a esta instalación."
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "Tipo de Clave"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA es ampliamente compatible, ECDSA es más rápido y seguro pero puede no ser compatible con sistemas antiguos"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "con Let's Encrypt"
|
||||
},
|
||||
|
||||
683
frontend/src/locale/src/id.json
Normal file
683
frontend/src/locale/src/id.json
Normal file
@@ -0,0 +1,683 @@
|
||||
{
|
||||
"access-list": {
|
||||
"defaultMessage": "Daftar Akses"
|
||||
},
|
||||
"access-list.access-count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Aturan} other {Aturan}}"
|
||||
},
|
||||
"access-list.auth-count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Pengguna} other {Pengguna}}"
|
||||
},
|
||||
"access-list.help-rules-last": {
|
||||
"defaultMessage": "Jika setidaknya 1 aturan ada, aturan tolak semua ini akan ditambahkan paling akhir"
|
||||
},
|
||||
"access-list.help.rules-order": {
|
||||
"defaultMessage": "Perhatikan bahwa direktif izinkan dan tolak akan diterapkan sesuai urutan yang didefinisikan."
|
||||
},
|
||||
"access-list.pass-auth": {
|
||||
"defaultMessage": "Teruskan Auth ke Upstream"
|
||||
},
|
||||
"access-list.public": {
|
||||
"defaultMessage": "Dapat Diakses Publik"
|
||||
},
|
||||
"access-list.public.subtitle": {
|
||||
"defaultMessage": "Tidak perlu basic auth"
|
||||
},
|
||||
"access-list.rule-source.placeholder": {
|
||||
"defaultMessage": "192.168.1.100 atau 192.168.1.0/24 atau 2001:0db8::/32"
|
||||
},
|
||||
"access-list.satisfy-any": {
|
||||
"defaultMessage": "Penuhi Salah Satu"
|
||||
},
|
||||
"access-list.subtitle": {
|
||||
"defaultMessage": "{users} {users, plural, one {Pengguna} other {Pengguna}}, {rules} {rules, plural, one {Aturan} other {Aturan}} - Dibuat: {date}"
|
||||
},
|
||||
"access-lists": {
|
||||
"defaultMessage": "Daftar Akses"
|
||||
},
|
||||
"action.add": {
|
||||
"defaultMessage": "Tambah"
|
||||
},
|
||||
"action.add-location": {
|
||||
"defaultMessage": "Tambah Lokasi"
|
||||
},
|
||||
"action.allow": {
|
||||
"defaultMessage": "Izinkan"
|
||||
},
|
||||
"action.close": {
|
||||
"defaultMessage": "Tutup"
|
||||
},
|
||||
"action.delete": {
|
||||
"defaultMessage": "Hapus"
|
||||
},
|
||||
"action.deny": {
|
||||
"defaultMessage": "Tolak"
|
||||
},
|
||||
"action.disable": {
|
||||
"defaultMessage": "Nonaktifkan"
|
||||
},
|
||||
"action.download": {
|
||||
"defaultMessage": "Unduh"
|
||||
},
|
||||
"action.edit": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
"action.enable": {
|
||||
"defaultMessage": "Aktifkan"
|
||||
},
|
||||
"action.permissions": {
|
||||
"defaultMessage": "Izin"
|
||||
},
|
||||
"action.renew": {
|
||||
"defaultMessage": "Perpanjang"
|
||||
},
|
||||
"action.view-details": {
|
||||
"defaultMessage": "Lihat Detail"
|
||||
},
|
||||
"auditlogs": {
|
||||
"defaultMessage": "Log Audit"
|
||||
},
|
||||
"auto": {
|
||||
"defaultMessage": "Otomatis"
|
||||
},
|
||||
"cancel": {
|
||||
"defaultMessage": "Batal"
|
||||
},
|
||||
"certificate": {
|
||||
"defaultMessage": "Sertifikat"
|
||||
},
|
||||
"certificate.custom-certificate": {
|
||||
"defaultMessage": "Sertifikat"
|
||||
},
|
||||
"certificate.custom-certificate-key": {
|
||||
"defaultMessage": "Kunci Sertifikat"
|
||||
},
|
||||
"certificate.custom-intermediate": {
|
||||
"defaultMessage": "Sertifikat Intermediate"
|
||||
},
|
||||
"certificate.in-use": {
|
||||
"defaultMessage": "Digunakan"
|
||||
},
|
||||
"certificate.none.subtitle": {
|
||||
"defaultMessage": "Tidak ada sertifikat yang ditetapkan"
|
||||
},
|
||||
"certificate.none.subtitle.for-http": {
|
||||
"defaultMessage": "Host ini tidak akan menggunakan HTTPS"
|
||||
},
|
||||
"certificate.none.title": {
|
||||
"defaultMessage": "Tidak Ada"
|
||||
},
|
||||
"certificate.not-in-use": {
|
||||
"defaultMessage": "Tidak Digunakan"
|
||||
},
|
||||
"certificate.renew": {
|
||||
"defaultMessage": "Perpanjang Sertifikat"
|
||||
},
|
||||
"certificates": {
|
||||
"defaultMessage": "Sertifikat"
|
||||
},
|
||||
"certificates.custom": {
|
||||
"defaultMessage": "Sertifikat Kustom"
|
||||
},
|
||||
"certificates.custom.warning": {
|
||||
"defaultMessage": "Berkas kunci yang dilindungi frasa sandi tidak didukung."
|
||||
},
|
||||
"certificates.dns.credentials": {
|
||||
"defaultMessage": "Konten File Kredensial"
|
||||
},
|
||||
"certificates.dns.credentials-note": {
|
||||
"defaultMessage": "Plugin ini memerlukan file konfigurasi yang berisi token API atau kredensial lain untuk penyedia Anda"
|
||||
},
|
||||
"certificates.dns.credentials-warning": {
|
||||
"defaultMessage": "Data ini akan disimpan sebagai teks biasa di database dan dalam file!"
|
||||
},
|
||||
"certificates.dns.propagation-seconds": {
|
||||
"defaultMessage": "Detik Propagasi"
|
||||
},
|
||||
"certificates.dns.propagation-seconds-note": {
|
||||
"defaultMessage": "Biarkan kosong untuk menggunakan nilai baku plugin. Jumlah detik menunggu propagasi DNS."
|
||||
},
|
||||
"certificates.dns.provider": {
|
||||
"defaultMessage": "Penyedia DNS"
|
||||
},
|
||||
"certificates.dns.provider.placeholder": {
|
||||
"defaultMessage": "Pilih Penyedia..."
|
||||
},
|
||||
"certificates.dns.warning": {
|
||||
"defaultMessage": "Bagian ini memerlukan pengetahuan tentang Certbot dan plugin DNS-nya. Silakan merujuk dokumentasi plugin terkait."
|
||||
},
|
||||
"certificates.http.reachability-404": {
|
||||
"defaultMessage": "Ada server yang ditemukan pada domain ini tetapi tampaknya bukan Nginx Proxy Manager. Pastikan domain Anda mengarah ke IP tempat instance NPM berjalan."
|
||||
},
|
||||
"certificates.http.reachability-failed-to-check": {
|
||||
"defaultMessage": "Gagal memeriksa keterjangkauan karena kesalahan komunikasi dengan site24x7.com."
|
||||
},
|
||||
"certificates.http.reachability-not-resolved": {
|
||||
"defaultMessage": "Tidak ada server yang tersedia pada domain ini. Pastikan domain Anda ada dan mengarah ke IP tempat instance NPM berjalan dan bila perlu port 80 diteruskan di router Anda."
|
||||
},
|
||||
"certificates.http.reachability-ok": {
|
||||
"defaultMessage": "Server Anda dapat dijangkau dan pembuatan sertifikat seharusnya memungkinkan."
|
||||
},
|
||||
"certificates.http.reachability-other": {
|
||||
"defaultMessage": "Ada server yang ditemukan pada domain ini tetapi mengembalikan kode status tak terduga {code}. Apakah itu server NPM? Pastikan domain Anda mengarah ke IP tempat instance NPM berjalan."
|
||||
},
|
||||
"certificates.http.reachability-wrong-data": {
|
||||
"defaultMessage": "Ada server yang ditemukan pada domain ini tetapi mengembalikan data yang tidak terduga. Apakah itu server NPM? Pastikan domain Anda mengarah ke IP tempat instance NPM berjalan."
|
||||
},
|
||||
"certificates.http.test-results": {
|
||||
"defaultMessage": "Hasil Uji"
|
||||
},
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "Domain ini harus sudah dikonfigurasi agar mengarah ke instalasi ini."
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "dengan Let's Encrypt"
|
||||
},
|
||||
"certificates.request.title": {
|
||||
"defaultMessage": "Minta Sertifikat Baru"
|
||||
},
|
||||
"column.access": {
|
||||
"defaultMessage": "Akses"
|
||||
},
|
||||
"column.authorization": {
|
||||
"defaultMessage": "Otorisasi"
|
||||
},
|
||||
"column.authorizations": {
|
||||
"defaultMessage": "Otorisasi"
|
||||
},
|
||||
"column.custom-locations": {
|
||||
"defaultMessage": "Lokasi Kustom"
|
||||
},
|
||||
"column.destination": {
|
||||
"defaultMessage": "Tujuan"
|
||||
},
|
||||
"column.details": {
|
||||
"defaultMessage": "Detail"
|
||||
},
|
||||
"column.email": {
|
||||
"defaultMessage": "Email"
|
||||
},
|
||||
"column.event": {
|
||||
"defaultMessage": "Peristiwa"
|
||||
},
|
||||
"column.expires": {
|
||||
"defaultMessage": "Kedaluwarsa"
|
||||
},
|
||||
"column.http-code": {
|
||||
"defaultMessage": "Kode HTTP"
|
||||
},
|
||||
"column.incoming-port": {
|
||||
"defaultMessage": "Port Masuk"
|
||||
},
|
||||
"column.name": {
|
||||
"defaultMessage": "Nama"
|
||||
},
|
||||
"column.protocol": {
|
||||
"defaultMessage": "Protokol"
|
||||
},
|
||||
"column.provider": {
|
||||
"defaultMessage": "Penyedia"
|
||||
},
|
||||
"column.roles": {
|
||||
"defaultMessage": "Peran"
|
||||
},
|
||||
"column.rules": {
|
||||
"defaultMessage": "Aturan"
|
||||
},
|
||||
"column.satisfy": {
|
||||
"defaultMessage": "Pemenuhan"
|
||||
},
|
||||
"column.satisfy-all": {
|
||||
"defaultMessage": "Semua"
|
||||
},
|
||||
"column.satisfy-any": {
|
||||
"defaultMessage": "Salah Satu"
|
||||
},
|
||||
"column.scheme": {
|
||||
"defaultMessage": "Skema"
|
||||
},
|
||||
"column.source": {
|
||||
"defaultMessage": "Sumber"
|
||||
},
|
||||
"column.ssl": {
|
||||
"defaultMessage": "SSL"
|
||||
},
|
||||
"column.status": {
|
||||
"defaultMessage": "Status"
|
||||
},
|
||||
"created-on": {
|
||||
"defaultMessage": "Dibuat: {date}"
|
||||
},
|
||||
"dashboard": {
|
||||
"defaultMessage": "Dasbor"
|
||||
},
|
||||
"dead-host": {
|
||||
"defaultMessage": "Host 404"
|
||||
},
|
||||
"dead-hosts": {
|
||||
"defaultMessage": "Host 404"
|
||||
},
|
||||
"dead-hosts.count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Host 404} other {Host 404}}"
|
||||
},
|
||||
"disabled": {
|
||||
"defaultMessage": "Nonaktif"
|
||||
},
|
||||
"domain-names": {
|
||||
"defaultMessage": "Nama Domain"
|
||||
},
|
||||
"domain-names.max": {
|
||||
"defaultMessage": "Maksimum {count} nama domain"
|
||||
},
|
||||
"domain-names.placeholder": {
|
||||
"defaultMessage": "Mulai mengetik untuk menambahkan domain..."
|
||||
},
|
||||
"domain-names.wildcards-not-permitted": {
|
||||
"defaultMessage": "Wildcard tidak diizinkan untuk tipe ini"
|
||||
},
|
||||
"domain-names.wildcards-not-supported": {
|
||||
"defaultMessage": "Wildcard tidak didukung untuk CA ini"
|
||||
},
|
||||
"domains.force-ssl": {
|
||||
"defaultMessage": "Paksa SSL"
|
||||
},
|
||||
"domains.hsts-enabled": {
|
||||
"defaultMessage": "HSTS Diaktifkan"
|
||||
},
|
||||
"domains.hsts-subdomains": {
|
||||
"defaultMessage": "HSTS Subdomain"
|
||||
},
|
||||
"domains.http2-support": {
|
||||
"defaultMessage": "Dukungan HTTP/2"
|
||||
},
|
||||
"domains.use-dns": {
|
||||
"defaultMessage": "Gunakan DNS Challenge"
|
||||
},
|
||||
"email-address": {
|
||||
"defaultMessage": "Alamat email"
|
||||
},
|
||||
"empty-search": {
|
||||
"defaultMessage": "Tidak ada hasil"
|
||||
},
|
||||
"empty-subtitle": {
|
||||
"defaultMessage": "Mengapa tidak membuatnya?"
|
||||
},
|
||||
"enabled": {
|
||||
"defaultMessage": "Aktif"
|
||||
},
|
||||
"error.access.at-least-one": {
|
||||
"defaultMessage": "Setidaknya satu Otorisasi atau satu Aturan Akses diperlukan"
|
||||
},
|
||||
"error.access.duplicate-usernames": {
|
||||
"defaultMessage": "Nama pengguna otorisasi harus unik"
|
||||
},
|
||||
"error.invalid-auth": {
|
||||
"defaultMessage": "Email atau kata sandi tidak valid"
|
||||
},
|
||||
"error.invalid-domain": {
|
||||
"defaultMessage": "Domain tidak valid: {domain}"
|
||||
},
|
||||
"error.invalid-email": {
|
||||
"defaultMessage": "Alamat email tidak valid"
|
||||
},
|
||||
"error.max-character-length": {
|
||||
"defaultMessage": "Panjang maksimum adalah {max} karakter{max, plural, one {} other {}}"
|
||||
},
|
||||
"error.max-domains": {
|
||||
"defaultMessage": "Terlalu banyak domain, maksimum {max}"
|
||||
},
|
||||
"error.maximum": {
|
||||
"defaultMessage": "Maksimum adalah {max}"
|
||||
},
|
||||
"error.min-character-length": {
|
||||
"defaultMessage": "Panjang minimum adalah {min} karakter{min, plural, one {} other {}}"
|
||||
},
|
||||
"error.minimum": {
|
||||
"defaultMessage": "Minimum adalah {min}"
|
||||
},
|
||||
"error.passwords-must-match": {
|
||||
"defaultMessage": "Kata sandi harus cocok"
|
||||
},
|
||||
"error.required": {
|
||||
"defaultMessage": "Ini wajib diisi"
|
||||
},
|
||||
"expires.on": {
|
||||
"defaultMessage": "Kedaluwarsa: {date}"
|
||||
},
|
||||
"footer.github-fork": {
|
||||
"defaultMessage": "Fork saya di GitHub"
|
||||
},
|
||||
"host.flags.block-exploits": {
|
||||
"defaultMessage": "Blokir Eksploit Umum"
|
||||
},
|
||||
"host.flags.cache-assets": {
|
||||
"defaultMessage": "Cache Aset"
|
||||
},
|
||||
"host.flags.preserve-path": {
|
||||
"defaultMessage": "Pertahankan Path"
|
||||
},
|
||||
"host.flags.protocols": {
|
||||
"defaultMessage": "Protokol"
|
||||
},
|
||||
"host.flags.websockets-upgrade": {
|
||||
"defaultMessage": "Dukungan Websocket"
|
||||
},
|
||||
"host.forward-port": {
|
||||
"defaultMessage": "Port Terusan"
|
||||
},
|
||||
"host.forward-scheme": {
|
||||
"defaultMessage": "Skema"
|
||||
},
|
||||
"hosts": {
|
||||
"defaultMessage": "Host"
|
||||
},
|
||||
"http-only": {
|
||||
"defaultMessage": "HTTP Saja"
|
||||
},
|
||||
"lets-encrypt": {
|
||||
"defaultMessage": "Let's Encrypt"
|
||||
},
|
||||
"lets-encrypt-via-dns": {
|
||||
"defaultMessage": "Let's Encrypt via DNS"
|
||||
},
|
||||
"lets-encrypt-via-http": {
|
||||
"defaultMessage": "Let's Encrypt via HTTP"
|
||||
},
|
||||
"loading": {
|
||||
"defaultMessage": "Memuat…"
|
||||
},
|
||||
"login.title": {
|
||||
"defaultMessage": "Masuk ke akun Anda"
|
||||
},
|
||||
"nginx-config.label": {
|
||||
"defaultMessage": "Konfigurasi Nginx Kustom"
|
||||
},
|
||||
"nginx-config.placeholder": {
|
||||
"defaultMessage": "# Masukkan konfigurasi Nginx kustom Anda di sini dengan risiko Anda sendiri!"
|
||||
},
|
||||
"no-permission-error": {
|
||||
"defaultMessage": "Anda tidak memiliki akses untuk melihat ini."
|
||||
},
|
||||
"notfound.action": {
|
||||
"defaultMessage": "Bawa saya pulang"
|
||||
},
|
||||
"notfound.content": {
|
||||
"defaultMessage": "Maaf, halaman yang Anda cari tidak ditemukan"
|
||||
},
|
||||
"notfound.title": {
|
||||
"defaultMessage": "Ups… Anda baru saja menemukan halaman error"
|
||||
},
|
||||
"notification.error": {
|
||||
"defaultMessage": "Kesalahan"
|
||||
},
|
||||
"notification.object-deleted": {
|
||||
"defaultMessage": "{object} telah dihapus"
|
||||
},
|
||||
"notification.object-disabled": {
|
||||
"defaultMessage": "{object} telah dinonaktifkan"
|
||||
},
|
||||
"notification.object-enabled": {
|
||||
"defaultMessage": "{object} telah diaktifkan"
|
||||
},
|
||||
"notification.object-renewed": {
|
||||
"defaultMessage": "{object} telah diperpanjang"
|
||||
},
|
||||
"notification.object-saved": {
|
||||
"defaultMessage": "{object} telah disimpan"
|
||||
},
|
||||
"notification.success": {
|
||||
"defaultMessage": "Berhasil"
|
||||
},
|
||||
"object.actions-title": {
|
||||
"defaultMessage": "{object} #{id}"
|
||||
},
|
||||
"object.add": {
|
||||
"defaultMessage": "Tambah {object}"
|
||||
},
|
||||
"object.delete": {
|
||||
"defaultMessage": "Hapus {object}"
|
||||
},
|
||||
"object.delete.content": {
|
||||
"defaultMessage": "Apakah Anda yakin ingin menghapus {object} ini?"
|
||||
},
|
||||
"object.edit": {
|
||||
"defaultMessage": "Edit {object}"
|
||||
},
|
||||
"object.empty": {
|
||||
"defaultMessage": "Tidak ada {objects}"
|
||||
},
|
||||
"object.event.created": {
|
||||
"defaultMessage": "{object} dibuat"
|
||||
},
|
||||
"object.event.deleted": {
|
||||
"defaultMessage": "{object} dihapus"
|
||||
},
|
||||
"object.event.disabled": {
|
||||
"defaultMessage": "{object} dinonaktifkan"
|
||||
},
|
||||
"object.event.enabled": {
|
||||
"defaultMessage": "{object} diaktifkan"
|
||||
},
|
||||
"object.event.renewed": {
|
||||
"defaultMessage": "{object} diperpanjang"
|
||||
},
|
||||
"object.event.updated": {
|
||||
"defaultMessage": "{object} diperbarui"
|
||||
},
|
||||
"offline": {
|
||||
"defaultMessage": "Offline"
|
||||
},
|
||||
"online": {
|
||||
"defaultMessage": "Online"
|
||||
},
|
||||
"options": {
|
||||
"defaultMessage": "Opsi"
|
||||
},
|
||||
"password": {
|
||||
"defaultMessage": "Kata sandi"
|
||||
},
|
||||
"password.generate": {
|
||||
"defaultMessage": "Buat kata sandi acak"
|
||||
},
|
||||
"password.hide": {
|
||||
"defaultMessage": "Sembunyikan Kata Sandi"
|
||||
},
|
||||
"password.show": {
|
||||
"defaultMessage": "Tampilkan Kata Sandi"
|
||||
},
|
||||
"permissions.hidden": {
|
||||
"defaultMessage": "Tersembunyi"
|
||||
},
|
||||
"permissions.manage": {
|
||||
"defaultMessage": "Kelola"
|
||||
},
|
||||
"permissions.view": {
|
||||
"defaultMessage": "Hanya Lihat"
|
||||
},
|
||||
"permissions.visibility.all": {
|
||||
"defaultMessage": "Semua Item"
|
||||
},
|
||||
"permissions.visibility.title": {
|
||||
"defaultMessage": "Visibilitas Item"
|
||||
},
|
||||
"permissions.visibility.user": {
|
||||
"defaultMessage": "Hanya Item yang Dibuat"
|
||||
},
|
||||
"proxy-host": {
|
||||
"defaultMessage": "Host Proxy"
|
||||
},
|
||||
"proxy-host.forward-host": {
|
||||
"defaultMessage": "Hostname / IP Terusan"
|
||||
},
|
||||
"proxy-hosts": {
|
||||
"defaultMessage": "Host Proxy"
|
||||
},
|
||||
"proxy-hosts.count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Host Proxy} other {Host Proxy}}"
|
||||
},
|
||||
"public": {
|
||||
"defaultMessage": "Publik"
|
||||
},
|
||||
"redirection-host": {
|
||||
"defaultMessage": "Host Pengalihan"
|
||||
},
|
||||
"redirection-host.forward-domain": {
|
||||
"defaultMessage": "Domain Terusan"
|
||||
},
|
||||
"redirection-host.forward-http-code": {
|
||||
"defaultMessage": "Kode HTTP"
|
||||
},
|
||||
"redirection-hosts": {
|
||||
"defaultMessage": "Host Pengalihan"
|
||||
},
|
||||
"redirection-hosts.count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Host Pengalihan} other {Host Pengalihan}}"
|
||||
},
|
||||
"redirection-hosts.http-code.300": {
|
||||
"defaultMessage": "300 Banyak Pilihan"
|
||||
},
|
||||
"redirection-hosts.http-code.301": {
|
||||
"defaultMessage": "301 Pindah permanen"
|
||||
},
|
||||
"redirection-hosts.http-code.302": {
|
||||
"defaultMessage": "302 Pindah sementara"
|
||||
},
|
||||
"redirection-hosts.http-code.303": {
|
||||
"defaultMessage": "303 Lihat lainnya"
|
||||
},
|
||||
"redirection-hosts.http-code.307": {
|
||||
"defaultMessage": "307 Pengalihan sementara"
|
||||
},
|
||||
"redirection-hosts.http-code.308": {
|
||||
"defaultMessage": "308 Pengalihan permanen"
|
||||
},
|
||||
"role.admin": {
|
||||
"defaultMessage": "Administrator"
|
||||
},
|
||||
"role.standard-user": {
|
||||
"defaultMessage": "Pengguna Standar"
|
||||
},
|
||||
"save": {
|
||||
"defaultMessage": "Simpan"
|
||||
},
|
||||
"setting": {
|
||||
"defaultMessage": "Pengaturan"
|
||||
},
|
||||
"settings": {
|
||||
"defaultMessage": "Pengaturan"
|
||||
},
|
||||
"settings.default-site": {
|
||||
"defaultMessage": "Situs Default"
|
||||
},
|
||||
"settings.default-site.404": {
|
||||
"defaultMessage": "Halaman 404"
|
||||
},
|
||||
"settings.default-site.444": {
|
||||
"defaultMessage": "Tidak Ada Respons (444)"
|
||||
},
|
||||
"settings.default-site.congratulations": {
|
||||
"defaultMessage": "Halaman Ucapan Selamat"
|
||||
},
|
||||
"settings.default-site.description": {
|
||||
"defaultMessage": "Apa yang ditampilkan saat Nginx diakses dengan Host yang tidak dikenal"
|
||||
},
|
||||
"settings.default-site.html": {
|
||||
"defaultMessage": "HTML Kustom"
|
||||
},
|
||||
"settings.default-site.html.placeholder": {
|
||||
"defaultMessage": "<!-- Masukkan konten HTML kustom Anda di sini -->"
|
||||
},
|
||||
"settings.default-site.redirect": {
|
||||
"defaultMessage": "Alihkan"
|
||||
},
|
||||
"setup.preamble": {
|
||||
"defaultMessage": "Mulai dengan membuat akun admin Anda."
|
||||
},
|
||||
"setup.title": {
|
||||
"defaultMessage": "Selamat datang!"
|
||||
},
|
||||
"sign-in": {
|
||||
"defaultMessage": "Masuk"
|
||||
},
|
||||
"ssl-certificate": {
|
||||
"defaultMessage": "Sertifikat SSL"
|
||||
},
|
||||
"stream": {
|
||||
"defaultMessage": "Stream"
|
||||
},
|
||||
"stream.forward-host": {
|
||||
"defaultMessage": "Host Terusan"
|
||||
},
|
||||
"stream.forward-host.placeholder": {
|
||||
"defaultMessage": "example.com atau 10.0.0.1 atau 2001:db8:3333:4444:5555:6666:7777:8888"
|
||||
},
|
||||
"stream.incoming-port": {
|
||||
"defaultMessage": "Port Masuk"
|
||||
},
|
||||
"streams": {
|
||||
"defaultMessage": "Stream"
|
||||
},
|
||||
"streams.count": {
|
||||
"defaultMessage": "{count} {count, plural, one {Stream} other {Stream}}"
|
||||
},
|
||||
"streams.tcp": {
|
||||
"defaultMessage": "TCP"
|
||||
},
|
||||
"streams.udp": {
|
||||
"defaultMessage": "UDP"
|
||||
},
|
||||
"test": {
|
||||
"defaultMessage": "Uji"
|
||||
},
|
||||
"update-available": {
|
||||
"defaultMessage": "Pembaruan Tersedia: {latestVersion}"
|
||||
},
|
||||
"user": {
|
||||
"defaultMessage": "Pengguna"
|
||||
},
|
||||
"user.change-password": {
|
||||
"defaultMessage": "Ubah Kata Sandi"
|
||||
},
|
||||
"user.confirm-password": {
|
||||
"defaultMessage": "Konfirmasi Kata Sandi"
|
||||
},
|
||||
"user.current-password": {
|
||||
"defaultMessage": "Kata Sandi Saat Ini"
|
||||
},
|
||||
"user.edit-profile": {
|
||||
"defaultMessage": "Edit Profil"
|
||||
},
|
||||
"user.full-name": {
|
||||
"defaultMessage": "Nama Lengkap"
|
||||
},
|
||||
"user.login-as": {
|
||||
"defaultMessage": "Masuk sebagai {name}"
|
||||
},
|
||||
"user.logout": {
|
||||
"defaultMessage": "Keluar"
|
||||
},
|
||||
"user.new-password": {
|
||||
"defaultMessage": "Kata Sandi Baru"
|
||||
},
|
||||
"user.nickname": {
|
||||
"defaultMessage": "Nama Panggilan"
|
||||
},
|
||||
"user.set-password": {
|
||||
"defaultMessage": "Atur Kata Sandi"
|
||||
},
|
||||
"user.set-permissions": {
|
||||
"defaultMessage": "Atur Izin untuk {name}"
|
||||
},
|
||||
"user.switch-dark": {
|
||||
"defaultMessage": "Beralih ke mode gelap"
|
||||
},
|
||||
"user.switch-light": {
|
||||
"defaultMessage": "Beralih ke mode terang"
|
||||
},
|
||||
"username": {
|
||||
"defaultMessage": "Nama pengguna"
|
||||
},
|
||||
"users": {
|
||||
"defaultMessage": "Pengguna"
|
||||
}
|
||||
}
|
||||
@@ -155,6 +155,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "Questi domini devono già essere configurati per puntare a questa installazione."
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "Tipo di Chiave"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA è ampiamente compatibile, ECDSA è più veloce e sicuro ma potrebbe non essere supportato da sistemi più vecchi"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "con Let's Encrypt"
|
||||
},
|
||||
|
||||
@@ -155,6 +155,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "これらのドメインは、すでにこのインストール先を指すように設定されている必要がありますあ."
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "鍵タイプ"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSAは広く互換性があり、ECDSAはより高速で安全ですが、古いシステムではサポートされていない場合があります"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "Let's Encryptを使用する"
|
||||
},
|
||||
|
||||
@@ -170,6 +170,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "도메인이 이 서버를 가리키도록 설정되어 있어야 합니다."
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "키 유형"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA는 호환성이 넓고, ECDSA는 더 빠르고 안전하지만 오래된 시스템에서 지원되지 않을 수 있습니다"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "Let's Encrypt 사용"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
"locale-de-DE": {
|
||||
"defaultMessage": "German"
|
||||
},
|
||||
"locale-id-ID": {
|
||||
"defaultMessage": "Bahasa Indonesia"
|
||||
}
|
||||
"locale-ja-JP": {
|
||||
"defaultMessage": "日本語"
|
||||
},
|
||||
|
||||
@@ -155,6 +155,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "Deze domeinen moeten al worden geconfigureerd om naar deze installatie te wijzen."
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "Sleuteltype"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA is breed compatibel, ECDSA is sneller en veiliger maar wordt mogelijk niet ondersteund door oudere systemen"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "met Let's Encrypt"
|
||||
},
|
||||
|
||||
@@ -155,6 +155,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "Te domeny muszą być już skonfigurowane tak, aby wskazywały na ten serwer www"
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "Typ klucza"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA jest szeroko kompatybilny, ECDSA jest szybszy i bezpieczniejszy, ale może nie być obsługiwany przez starsze systemy"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "z Let's Encrypt"
|
||||
},
|
||||
|
||||
@@ -155,6 +155,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "Эти домены должны быть настроены и указывать на этот экземпляр."
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "Тип ключа"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA широко совместим, ECDSA быстрее и безопаснее, но может не поддерживаться старыми системами"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "через Let's Encrypt"
|
||||
},
|
||||
|
||||
@@ -155,6 +155,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "Tieto domény musia byť už nakonfigurované tak, aby smerovali na túto inštaláciu."
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "Typ kľúča"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA je široko kompatibilný, ECDSA je rýchlejší a bezpečnejší, ale nemusí byť podporovaný staršími systémami"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "pomocou Let's Encrypt"
|
||||
},
|
||||
|
||||
@@ -155,6 +155,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "Các miền này phải được cấu hình sẵn để trỏ đến cài đặt này."
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "Loại khóa"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA tương thích rộng rãi, ECDSA nhanh hơn và an toàn hơn nhưng có thể không được hỗ trợ bởi các hệ thống cũ"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "bằng Let's Encrypt"
|
||||
},
|
||||
|
||||
@@ -155,6 +155,18 @@
|
||||
"certificates.http.warning": {
|
||||
"defaultMessage": "这些域名必须配置为指向本设备。"
|
||||
},
|
||||
"certificates.key-type": {
|
||||
"defaultMessage": "密钥类型"
|
||||
},
|
||||
"certificates.key-type-description": {
|
||||
"defaultMessage": "RSA 兼容性更好,ECDSA 更快更安全但旧系统可能不支持"
|
||||
},
|
||||
"certificates.key-type-ecdsa": {
|
||||
"defaultMessage": "ECDSA 256"
|
||||
},
|
||||
"certificates.key-type-rsa": {
|
||||
"defaultMessage": "RSA 2048"
|
||||
},
|
||||
"certificates.request.subtitle": {
|
||||
"defaultMessage": "使用 Let's Encrypt"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import EasyModal, { type InnerModalProps } from "ez-modal-react";
|
||||
import { Form, Formik } from "formik";
|
||||
import { Form, Formik, Field } from "formik";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Alert } from "react-bootstrap";
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
@@ -44,6 +44,7 @@ const DNSCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPro
|
||||
provider: "letsencrypt",
|
||||
meta: {
|
||||
dnsChallenge: true,
|
||||
keyType: "ecdsa",
|
||||
},
|
||||
} as any
|
||||
}
|
||||
@@ -63,6 +64,30 @@ const DNSCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPro
|
||||
<div className="card m-0 border-0">
|
||||
<div className="card-body">
|
||||
<DomainNamesField isWildcardPermitted dnsProviderWildcardSupported />
|
||||
<Field name="meta.keyType">
|
||||
{({ field }: any) => (
|
||||
<div className="mb-3">
|
||||
<label htmlFor="keyType" className="form-label">
|
||||
<T id="certificates.key-type" />
|
||||
</label>
|
||||
<select
|
||||
id="keyType"
|
||||
className="form-select"
|
||||
{...field}
|
||||
>
|
||||
<option value="rsa">
|
||||
<T id="certificates.key-type-rsa" />
|
||||
</option>
|
||||
<option value="ecdsa">
|
||||
<T id="certificates.key-type-ecdsa" />
|
||||
</option>
|
||||
</select>
|
||||
<small className="form-text text-muted">
|
||||
<T id="certificates.key-type-description" />
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
<DNSProviderFields />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import EasyModal, { type InnerModalProps } from "ez-modal-react";
|
||||
import { Form, Formik } from "formik";
|
||||
import { Form, Formik, Field } from "formik";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Alert } from "react-bootstrap";
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
@@ -115,6 +115,9 @@ const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPr
|
||||
{
|
||||
domainNames: [],
|
||||
provider: "letsencrypt",
|
||||
meta: {
|
||||
keyType: "ecdsa",
|
||||
},
|
||||
} as any
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
@@ -142,6 +145,30 @@ const HTTPCertificateModal = EasyModal.create(({ visible, remove }: InnerModalPr
|
||||
setTestResults(null);
|
||||
}}
|
||||
/>
|
||||
<Field name="meta.keyType">
|
||||
{({ field }: any) => (
|
||||
<div className="mb-3">
|
||||
<label htmlFor="keyType" className="form-label">
|
||||
<T id="certificates.key-type" />
|
||||
</label>
|
||||
<select
|
||||
id="keyType"
|
||||
className="form-select"
|
||||
{...field}
|
||||
>
|
||||
<option value="rsa">
|
||||
<T id="certificates.key-type-rsa" />
|
||||
</option>
|
||||
<option value="ecdsa">
|
||||
<T id="certificates.key-type-ecdsa" />
|
||||
</option>
|
||||
</select>
|
||||
<small className="form-text text-muted">
|
||||
<T id="certificates.key-type-description" />
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
{testResults ? (
|
||||
<div className="card-footer">
|
||||
|
||||
368
frontend/src/modals/TwoFactorModal.tsx
Normal file
368
frontend/src/modals/TwoFactorModal.tsx
Normal file
@@ -0,0 +1,368 @@
|
||||
import EasyModal, { type InnerModalProps } from "ez-modal-react";
|
||||
import { Field, Form, Formik } from "formik";
|
||||
import { type ReactNode, useCallback, useEffect, useState } from "react";
|
||||
import { Alert } from "react-bootstrap";
|
||||
import Modal from "react-bootstrap/Modal";
|
||||
import {
|
||||
disable2FA,
|
||||
enable2FA,
|
||||
get2FAStatus,
|
||||
regenerateBackupCodes,
|
||||
start2FASetup,
|
||||
} from "src/api/backend";
|
||||
import { Button } from "src/components";
|
||||
import { T } from "src/locale";
|
||||
import { validateString } from "src/modules/Validations";
|
||||
|
||||
type Step = "loading" | "status" | "setup" | "verify" | "backup" | "disable";
|
||||
|
||||
const showTwoFactorModal = (id: number | "me") => {
|
||||
EasyModal.show(TwoFactorModal, { id });
|
||||
};
|
||||
|
||||
interface Props extends InnerModalProps {
|
||||
id: number | "me";
|
||||
}
|
||||
|
||||
const TwoFactorModal = EasyModal.create(({ id, visible, remove }: Props) => {
|
||||
const [error, setError] = useState<ReactNode | null>(null);
|
||||
const [step, setStep] = useState<Step>("loading");
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [backupCodesRemaining, setBackupCodesRemaining] = useState(0);
|
||||
const [setupData, setSetupData] = useState<{ secret: string; otpauthUrl: string } | null>(null);
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
try {
|
||||
const status = await get2FAStatus(id);
|
||||
setIsEnabled(status.enabled);
|
||||
setBackupCodesRemaining(status.backupCodesRemaining);
|
||||
setStep("status");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to load 2FA status");
|
||||
setStep("status");
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
loadStatus();
|
||||
}, [loadStatus]);
|
||||
|
||||
const handleStartSetup = async () => {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const data = await start2FASetup(id);
|
||||
setSetupData(data);
|
||||
setStep("setup");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to start 2FA setup");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleVerify = async (values: { code: string }) => {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await enable2FA(id, values.code);
|
||||
setBackupCodes(result.backupCodes);
|
||||
setStep("backup");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to enable 2FA");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleDisable = async (values: { code: string }) => {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await disable2FA(id, values.code);
|
||||
setIsEnabled(false);
|
||||
setStep("status");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to disable 2FA");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleRegenerateBackup = async (values: { code: string }) => {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await regenerateBackupCodes(id, values.code);
|
||||
setBackupCodes(result.backupCodes);
|
||||
setStep("backup");
|
||||
} catch (err: any) {
|
||||
setError(err.message || "Failed to regenerate backup codes");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
const handleBackupDone = () => {
|
||||
setIsEnabled(true);
|
||||
setBackupCodes([]);
|
||||
loadStatus();
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (step === "loading") {
|
||||
return (
|
||||
<div className="text-center py-4">
|
||||
<div className="spinner-border" role="status">
|
||||
<span className="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "status") {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<div className="mb-4">
|
||||
<div className="d-flex align-items-center justify-content-between mb-2">
|
||||
<span className="fw-bold">
|
||||
<T id="2fa.status" />
|
||||
</span>
|
||||
<span className={`badge text-white ${isEnabled ? "bg-success" : "bg-secondary"}`}>
|
||||
{isEnabled ? <T id="2fa.enabled" /> : <T id="2fa.disabled" />}
|
||||
</span>
|
||||
</div>
|
||||
{isEnabled && (
|
||||
<p className="text-muted small mb-0">
|
||||
<T id="2fa.backup-codes-remaining" data={{ count: backupCodesRemaining }} />
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{!isEnabled ? (
|
||||
<Button
|
||||
fullWidth
|
||||
color="azure"
|
||||
onClick={handleStartSetup}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
<T id="2fa.enable" />
|
||||
</Button>
|
||||
) : (
|
||||
<div className="d-flex flex-column gap-2">
|
||||
<Button fullWidth onClick={() => setStep("disable")}>
|
||||
<T id="2fa.disable" />
|
||||
</Button>
|
||||
<Button fullWidth onClick={() => setStep("verify")}>
|
||||
<T id="2fa.regenerate-backup" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "setup" && setupData) {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<p className="text-muted mb-3">
|
||||
<T id="2fa.setup-instructions" />
|
||||
</p>
|
||||
<div className="text-center mb-3">
|
||||
<img
|
||||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(setupData.otpauthUrl)}`}
|
||||
alt="QR Code"
|
||||
className="img-fluid"
|
||||
style={{ maxWidth: "200px" }}
|
||||
/>
|
||||
</div>
|
||||
<label className="mb-3 d-block">
|
||||
<span className="form-label small text-muted">
|
||||
<T id="2fa.secret-key" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="form-control font-monospace"
|
||||
value={setupData.secret}
|
||||
readOnly
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
</label>
|
||||
<Formik initialValues={{ code: "" }} onSubmit={handleVerify}>
|
||||
{() => (
|
||||
<Form>
|
||||
<Field name="code" validate={validateString(6, 6)}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="mb-3 d-block">
|
||||
<span className="form-label">
|
||||
<T id="2fa.enter-code" />
|
||||
</span>
|
||||
<input
|
||||
{...field}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.code}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
<div className="d-flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
onClick={() => setStep("status")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<T id="cancel" />
|
||||
</Button>
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="2fa.verify-enable" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "backup") {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<Alert variant="warning">
|
||||
<T id="2fa.backup-warning" />
|
||||
</Alert>
|
||||
<div className="mb-3">
|
||||
<div className="row g-2">
|
||||
{backupCodes.map((code, index) => (
|
||||
<div key={index} className="col-6">
|
||||
<code className="d-block p-2 bg-light rounded text-center">{code}</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Button fullWidth color="azure" onClick={handleBackupDone}>
|
||||
<T id="2fa.done" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "disable") {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<Alert variant="warning">
|
||||
<T id="2fa.disable-warning" />
|
||||
</Alert>
|
||||
<Formik initialValues={{ code: "" }} onSubmit={handleDisable}>
|
||||
{() => (
|
||||
<Form>
|
||||
<Field name="code" validate={validateString(6, 6)}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="mb-3 d-block">
|
||||
<span className="form-label">
|
||||
<T id="2fa.enter-code-disable" />
|
||||
</span>
|
||||
<input
|
||||
{...field}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.code}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
<div className="d-flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
onClick={() => setStep("status")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<T id="cancel" />
|
||||
</Button>
|
||||
<Button type="submit" fullWidth color="red" isLoading={isSubmitting}>
|
||||
<T id="2fa.disable-confirm" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (step === "verify") {
|
||||
return (
|
||||
<div className="py-2">
|
||||
<p className="text-muted mb-3">
|
||||
<T id="2fa.regenerate-instructions" />
|
||||
</p>
|
||||
<Formik initialValues={{ code: "" }} onSubmit={handleRegenerateBackup}>
|
||||
{() => (
|
||||
<Form>
|
||||
<Field name="code" validate={validateString(6, 6)}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="mb-3 d-block">
|
||||
<span className="form-label">
|
||||
<T id="2fa.enter-code" />
|
||||
</span>
|
||||
<input
|
||||
{...field}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.code}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
<div className="d-flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
onClick={() => setStep("status")}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<T id="cancel" />
|
||||
</Button>
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="2fa.regenerate" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal show={visible} onHide={remove}>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>
|
||||
<T id="2fa.title" />
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Alert variant="danger" show={!!error} onClose={() => setError(null)} dismissible>
|
||||
{error}
|
||||
</Alert>
|
||||
{renderContent()}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
export { showTwoFactorModal };
|
||||
@@ -13,4 +13,5 @@ export * from "./RedirectionHostModal";
|
||||
export * from "./RenewCertificateModal";
|
||||
export * from "./SetPasswordModal";
|
||||
export * from "./StreamModal";
|
||||
export * from "./TwoFactorModal";
|
||||
export * from "./UserModal";
|
||||
|
||||
@@ -8,8 +8,77 @@ import { intl, T } from "src/locale";
|
||||
import { validateEmail, validateString } from "src/modules/Validations";
|
||||
import styles from "./index.module.css";
|
||||
|
||||
export default function Login() {
|
||||
const emailRef = useRef(null);
|
||||
function TwoFactorForm() {
|
||||
const codeRef = useRef<HTMLInputElement>(null);
|
||||
const [formErr, setFormErr] = useState("");
|
||||
const { verifyTwoFactor, cancelTwoFactor } = useAuthState();
|
||||
|
||||
const onSubmit = async (values: any, { setSubmitting }: any) => {
|
||||
setFormErr("");
|
||||
try {
|
||||
await verifyTwoFactor(values.code);
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
setFormErr(err.message);
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
codeRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="h2 text-center mb-4">
|
||||
<T id="login.2fa-title" />
|
||||
</h2>
|
||||
<p className="text-secondary text-center mb-4">
|
||||
<T id="login.2fa-description" />
|
||||
</p>
|
||||
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
|
||||
<Formik initialValues={{ code: "" }} onSubmit={onSubmit}>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<div className="mb-3">
|
||||
<Field name="code" validate={validateString(6, 20)}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="form-label">
|
||||
<T id="login.2fa-code" />
|
||||
<input
|
||||
{...field}
|
||||
ref={codeRef}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
required
|
||||
maxLength={20}
|
||||
className={`form-control ${form.errors.code && form.touched.code ? "is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "login.2fa-code-placeholder" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.code}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="form-footer d-flex gap-2">
|
||||
<Button type="button" fullWidth onClick={cancelTwoFactor} disabled={isSubmitting}>
|
||||
<T id="cancel" />
|
||||
</Button>
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="login.2fa-verify" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const [formErr, setFormErr] = useState("");
|
||||
const { login } = useAuthState();
|
||||
|
||||
@@ -26,10 +95,79 @@ export default function Login() {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error ts-migrate(2531) FIXME: Object is possibly 'null'.
|
||||
emailRef.current.focus();
|
||||
emailRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="h2 text-center mb-4">
|
||||
<T id="login.title" />
|
||||
</h2>
|
||||
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
|
||||
<Formik
|
||||
initialValues={
|
||||
{
|
||||
email: "",
|
||||
password: "",
|
||||
} as any
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<div className="mb-3">
|
||||
<Field name="email" validate={validateEmail()}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="form-label">
|
||||
<T id="email-address" />
|
||||
<input
|
||||
{...field}
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
className={`form-control ${form.errors.email && form.touched.email ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "email-address" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.email}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<Field name="password" validate={validateString(8, 255)}>
|
||||
{({ field, form }: any) => (
|
||||
<>
|
||||
<label className="form-label">
|
||||
<T id="password" />
|
||||
<input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
maxLength={255}
|
||||
className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "password" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.password}</div>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="sign-in" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const { twoFactorChallenge } = useAuthState();
|
||||
const health = useHealth();
|
||||
|
||||
const getVersion = () => {
|
||||
@@ -56,68 +194,7 @@ export default function Login() {
|
||||
</div>
|
||||
<div className="card card-md">
|
||||
<div className="card-body">
|
||||
<h2 className="h2 text-center mb-4">
|
||||
<T id="login.title" />
|
||||
</h2>
|
||||
{formErr !== "" && <Alert variant="danger">{formErr}</Alert>}
|
||||
<Formik
|
||||
initialValues={
|
||||
{
|
||||
email: "",
|
||||
password: "",
|
||||
} as any
|
||||
}
|
||||
onSubmit={onSubmit}
|
||||
>
|
||||
{({ isSubmitting }) => (
|
||||
<Form>
|
||||
<div className="mb-3">
|
||||
<Field name="email" validate={validateEmail()}>
|
||||
{({ field, form }: any) => (
|
||||
<label className="form-label">
|
||||
<T id="email-address" />
|
||||
<input
|
||||
{...field}
|
||||
ref={emailRef}
|
||||
type="email"
|
||||
required
|
||||
className={`form-control ${form.errors.email && form.touched.email ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "email-address" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.email}</div>
|
||||
</label>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<Field name="password" validate={validateString(8, 255)}>
|
||||
{({ field, form }: any) => (
|
||||
<>
|
||||
<label className="form-label">
|
||||
<T id="password" />
|
||||
<input
|
||||
{...field}
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
maxLength={255}
|
||||
className={`form-control ${form.errors.password && form.touched.password ? " is-invalid" : ""}`}
|
||||
placeholder={intl.formatMessage({ id: "password" })}
|
||||
/>
|
||||
<div className="invalid-feedback">{form.errors.password}</div>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="form-footer">
|
||||
<Button type="submit" fullWidth color="azure" isLoading={isSubmitting}>
|
||||
<T id="sign-in" />
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
{twoFactorChallenge ? <TwoFactorForm /> : <LoginForm />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center text-secondary mt-3">{getVersion()}</div>
|
||||
|
||||
Reference in New Issue
Block a user