Merge branch 'develop' into develop

This commit is contained in:
jc21
2026-01-13 14:46:35 +10:00
committed by GitHub
51 changed files with 2575 additions and 83 deletions

View File

@@ -20,6 +20,7 @@ const allLocales = [
["zh", "zh-CN"],
["ko", "ko-KR"],
["bg", "bg-BG"],
["id", "id-ID"],
];
const ignoreUnused = [

View File

@@ -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 },
});
}

View File

@@ -60,3 +60,4 @@ export * from "./updateStream";
export * from "./updateUser";
export * from "./uploadCertificate";
export * from "./validateCertificate";
export * from "./twoFactor";

View File

@@ -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[];
}

View 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 },
});
}

View File

@@ -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="?"

View File

@@ -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>;
}

View File

@@ -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 => {

View 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.

View 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.

View 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.

View 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.

View 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.

View 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.

View 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";

View File

@@ -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";

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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"
},

View 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"
}
}

View File

@@ -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"
},

View File

@@ -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を使用する"
},

View File

@@ -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": {

View File

@@ -11,6 +11,9 @@
"locale-de-DE": {
"defaultMessage": "German"
},
"locale-id-ID": {
"defaultMessage": "Bahasa Indonesia"
}
"locale-ja-JP": {
"defaultMessage": "日本語"
},

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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"
},

View File

@@ -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>

View File

@@ -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">

View 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 };

View File

@@ -13,4 +13,5 @@ export * from "./RedirectionHostModal";
export * from "./RenewCertificateModal";
export * from "./SetPasswordModal";
export * from "./StreamModal";
export * from "./TwoFactorModal";
export * from "./UserModal";

View File

@@ -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>