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:
@@ -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 사용"
|
||||
},
|
||||
@@ -218,7 +230,7 @@
|
||||
"column.provider": {
|
||||
"defaultMessage": "공급자"
|
||||
},
|
||||
"column.roles": {
|
||||
"column.roles": {
|
||||
"defaultMessage": "권한"
|
||||
},
|
||||
"column.rules": {
|
||||
|
||||
@@ -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