feat(react/settings): port totp settings

This commit is contained in:
Elian Doran 2025-08-19 10:37:14 +03:00
parent 73ff41f2b2
commit cfb3607052
No known key found for this signature in database
7 changed files with 229 additions and 349 deletions

View File

@ -25,6 +25,7 @@ export interface PromptDialogOptions {
defaultValue?: string;
shown?: PromptShownDialogCallback;
callback?: (value: string | null) => void;
readOnly?: boolean;
}
function PromptDialogComponent() {
@ -77,7 +78,9 @@ function PromptDialogComponent() {
<FormTextBox
name="prompt-dialog-answer"
inputRef={answerRef}
currentValue={value} onChange={setValue} />
currentValue={value} onChange={setValue}
readOnly={opts.current?.readOnly}
/>
</FormGroup>
</Modal>
);

View File

@ -1,7 +1,7 @@
import { ComponentChildren } from "preact";
interface AdmonitionProps {
type: "warning";
type: "warning" | "note" | "caution";
children: ComponentChildren;
}

View File

@ -1,5 +1,4 @@
import TypeWidget from "./type_widget.js";
import MultiFactorAuthenticationOptions from './options/multi_factor_authentication.js';
import AiSettingsOptions from "./options/ai_settings.js";
import type FNote from "../../entities/fnote.js";
import type NoteContextAwareWidget from "../note_context_aware_widget.js";
@ -21,7 +20,7 @@ import TextNoteSettings from "./options/text_notes.jsx";
import CodeNoteSettings from "./options/code_notes.jsx";
import OtherSettings from "./options/other.jsx";
import BackendLogWidget from "./content/backend_log.js";
import { unmountComponentAtNode } from "preact/compat";
import MultiFactorAuthenticationSettings from "./options/multi_factor_authentication.js";
const TPL = /*html*/`<div class="note-detail-content-widget note-detail-printable">
<style>
@ -56,7 +55,7 @@ const CONTENT_WIDGETS: Record<OptionPages | "_backendLog", ((typeof NoteContextA
_optionsImages: <ImageSettings />,
_optionsSpellcheck: <SpellcheckSettings />,
_optionsPassword: <PasswordSettings />,
_optionsMFA: [MultiFactorAuthenticationOptions],
_optionsMFA: <MultiFactorAuthenticationSettings />,
_optionsEtapi: <EtapiSettings />,
_optionsBackup: <BackupSettings />,
_optionsSync: <SyncOptions />,

View File

@ -9,9 +9,9 @@ interface OptionsSectionProps {
className?: string;
}
export default function OptionsSection({ title, children, noCard, ...rest }: OptionsSectionProps) {
export default function OptionsSection({ title, children, noCard, className, ...rest }: OptionsSectionProps) {
return (
<div className={`options-section ${noCard && "tn-no-card"}`} {...rest}>
<div className={`options-section ${noCard && "tn-no-card"} ${className ?? ""}`} {...rest}>
{title && <h4>{title}</h4>}
{children}
</div>

View File

@ -1,342 +0,0 @@
import server from "../../../services/server.js";
import toastService from "../../../services/toast.js";
import OptionsWidget from "./options_widget.js";
import type { OptionMap } from "@triliumnext/commons";
import { t } from "../../../services/i18n.js";
import utils from "../../../services/utils.js";
import dialogService from "../../../services/dialog.js";
const TPL = /*html*/`
<div class="options-section">
<h4>${t("multi_factor_authentication.title")}</h4>
<p class="form-text">${t("multi_factor_authentication.description")}</p>
<div class="col-md-6 side-checkbox">
<label class="form-check tn-checkbox">
<input type="checkbox" class="mfa-enabled-checkbox form-check-input" />
${t("multi_factor_authentication.mfa_enabled")}
</label>
</div>
<hr />
<div class="mfa-options" style="display: none;">
<label class="form-label"><b>${t("multi_factor_authentication.mfa_method")}</b></label>
<div role="group">
<label class="tn-radio">
<input class="mfa-method-radio" type="radio" name="mfaMethod" value="totp" />
<b>${t("multi_factor_authentication.totp_title")}</b>
</label>
<label class="tn-radio">
<input class="mfa-method-radio" type="radio" name="mfaMethod" value="oauth" />
<b>${t("multi_factor_authentication.oauth_title")}</b>
</label>
</div>
<div class="totp-options" style="display: none;">
<p class="form-text">${t("multi_factor_authentication.totp_description")}</p>
<hr />
<h5>${t("multi_factor_authentication.totp_secret_title")}</h5>
<div class="admonition note no-totp-secret" role="alert">
${t("multi_factor_authentication.no_totp_secret_warning")}
</div>
<div class="admonition warning" role="alert">
${t("multi_factor_authentication.totp_secret_description_warning")}
</div>
<button class="generate-totp btn btn-primary">
${t("multi_factor_authentication.totp_secret_generate")}
</button>
<hr />
<h5>${t("multi_factor_authentication.recovery_keys_title")}</h5>
<p class="form-text">${t("multi_factor_authentication.recovery_keys_description")}</p>
<div class="admonition caution">
${t("multi_factor_authentication.recovery_keys_description_warning")}
</div>
<table style="border: 0px solid white">
<tbody>
<tr>
<td class="key_0"></td>
<td style="width: 20px" />
<td class="key_1"></td>
</tr>
<tr>
<td class="key_2"></td>
<td />
<td class="key_3"></td>
</tr>
<tr>
<td class="key_4"></td>
<td />
<td class="key_5"></td>
</tr>
<tr>
<td class="key_6"></td>
<td />
<td class="key_7"></td>
</tr>
</tbody>
</table>
<br>
<button class="generate-recovery-code btn btn-primary"> ${t("multi_factor_authentication.recovery_keys_generate")} </button>
</div>
<div class="oauth-options" style="display: none;">
<p class="form-text">${t("multi_factor_authentication.oauth_description")}</p>
<div class="admonition note oauth-warning" role="alert">
${t("multi_factor_authentication.oauth_description_warning")}
</div>
<div class="admonition caution missing-vars" role="alert" style="display: none;"></div>
<hr />
<div class="col-md-6">
<span><b>${t("multi_factor_authentication.oauth_user_account")}</b></span><span class="user-account-name">${t("multi_factor_authentication.oauth_user_not_logged_in")}</span>
<br>
<span><b>${t("multi_factor_authentication.oauth_user_email")}</b></span><span class="user-account-email">${t("multi_factor_authentication.oauth_user_not_logged_in")}</span>
</div>
</div>
</div>
</div>
`;
const TPL_ELECTRON = `
<div class="options-section">
<h4>${t("multi_factor_authentication.title")}</h4>
<p class="form-text">${t("multi_factor_authentication.electron_disabled")}</p>
</div>
`;
interface OAuthStatus {
enabled: boolean;
name?: string;
email?: string;
missingVars?: string[];
}
interface TOTPStatus {
set: boolean;
}
interface RecoveryKeysResponse {
success: boolean;
recoveryCodes?: string[];
keysExist?: boolean;
usedRecoveryCodes?: string[];
}
export default class MultiFactorAuthenticationOptions extends OptionsWidget {
private $mfaEnabledCheckbox!: JQuery<HTMLElement>;
private $mfaOptions!: JQuery<HTMLElement>;
private $mfaMethodRadios!: JQuery<HTMLElement>;
private $totpOptions!: JQuery<HTMLElement>;
private $noTotpSecretWarning!: JQuery<HTMLElement>;
private $generateTotpButton!: JQuery<HTMLElement>;
private $generateRecoveryCodeButton!: JQuery<HTMLElement>;
private $recoveryKeys: JQuery<HTMLElement>[] = [];
private $oauthOptions!: JQuery<HTMLElement>;
private $UserAccountName!: JQuery<HTMLElement>;
private $UserAccountEmail!: JQuery<HTMLElement>;
private $oauthWarning!: JQuery<HTMLElement>;
private $missingVars!: JQuery<HTMLElement>;
doRender() {
const template = utils.isElectron() ? TPL_ELECTRON : TPL;
this.$widget = $(template);
if (!utils.isElectron()) {
this.$mfaEnabledCheckbox = this.$widget.find(".mfa-enabled-checkbox");
this.$mfaOptions = this.$widget.find(".mfa-options");
this.$mfaMethodRadios = this.$widget.find(".mfa-method-radio");
this.$totpOptions = this.$widget.find(".totp-options");
this.$noTotpSecretWarning = this.$widget.find(".no-totp-secret");
this.$generateTotpButton = this.$widget.find(".generate-totp");
this.$generateRecoveryCodeButton = this.$widget.find(".generate-recovery-code");
this.$oauthOptions = this.$widget.find(".oauth-options");
this.$UserAccountName = this.$widget.find(".user-account-name");
this.$UserAccountEmail = this.$widget.find(".user-account-email");
this.$oauthWarning = this.$widget.find(".oauth-warning");
this.$missingVars = this.$widget.find(".missing-vars");
this.$recoveryKeys = [];
for (let i = 0; i < 8; i++) {
this.$recoveryKeys.push(this.$widget.find(".key_" + i));
}
this.$generateRecoveryCodeButton.on("click", async () => {
await this.setRecoveryKeys();
});
this.$generateTotpButton.on("click", async () => {
await this.generateKey();
});
this.displayRecoveryKeys();
this.$mfaEnabledCheckbox.on("change", () => {
const isChecked = this.$mfaEnabledCheckbox.is(":checked");
this.$mfaOptions.toggle(isChecked);
if (!isChecked) {
this.$totpOptions.hide();
this.$oauthOptions.hide();
} else {
this.$mfaMethodRadios.filter('[value="totp"]').prop("checked", true);
this.$totpOptions.show();
this.$oauthOptions.hide();
}
this.updateCheckboxOption("mfaEnabled", this.$mfaEnabledCheckbox);
});
this.$mfaMethodRadios.on("change", () => {
const selectedMethod = this.$mfaMethodRadios.filter(":checked").val();
this.$totpOptions.toggle(selectedMethod === "totp");
this.$oauthOptions.toggle(selectedMethod === "oauth");
this.updateOption("mfaMethod", selectedMethod);
});
}
}
async setRecoveryKeys() {
const result = await server.get<RecoveryKeysResponse>("totp_recovery/generate");
if (!result.success) {
toastService.showError(t("multi_factor_authentication.recovery_keys_error"));
return;
}
if (result.recoveryCodes) {
this.keyFiller(result.recoveryCodes);
await server.post("totp_recovery/set", {
recoveryCodes: result.recoveryCodes,
});
}
}
async displayRecoveryKeys() {
const result = await server.get<RecoveryKeysResponse>("totp_recovery/enabled");
if (!result.success) {
this.fillKeys(t("multi_factor_authentication.recovery_keys_error"));
return;
}
if (!result.keysExist) {
this.fillKeys(t("multi_factor_authentication.recovery_keys_no_key_set"));
this.$generateRecoveryCodeButton.text(t("multi_factor_authentication.recovery_keys_generate"));
return;
}
const usedResult = await server.get<RecoveryKeysResponse>("totp_recovery/used");
if (usedResult.usedRecoveryCodes) {
this.keyFiller(usedResult.usedRecoveryCodes);
this.$generateRecoveryCodeButton.text(t("multi_factor_authentication.recovery_keys_regenerate"));
} else {
this.fillKeys(t("multi_factor_authentication.recovery_keys_no_key_set"));
}
}
private keyFiller(values: string[]) {
this.fillKeys("");
values.forEach((key, index) => {
if (typeof key === 'string') {
const date = new Date(key.replace(/\//g, '-'));
if (isNaN(date.getTime())) {
this.$recoveryKeys[index].text(key);
} else {
this.$recoveryKeys[index].text(t("multi_factor_authentication.recovery_keys_used", { date: key.replace(/\//g, '-') }));
}
} else {
this.$recoveryKeys[index].text(t("multi_factor_authentication.recovery_keys_unused", { index: key }));
}
});
}
private fillKeys(message: string) {
for (let i = 0; i < 8; i++) {
this.$recoveryKeys[i].text(message);
}
}
async generateKey() {
const totpStatus = await server.get<TOTPStatus>("totp/status");
if (totpStatus.set) {
const confirmed = await dialogService.confirm(t("multi_factor_authentication.totp_secret_regenerate_confirm"));
if (!confirmed) {
return;
}
}
const result = await server.get<{ success: boolean; message: string }>("totp/generate");
if (result.success) {
await dialogService.prompt({
title: t("multi_factor_authentication.totp_secret_generated"),
message: t("multi_factor_authentication.totp_secret_warning"),
defaultValue: result.message,
shown: ({ $answer }) => {
if ($answer) {
$answer.prop('readonly', true);
}
}
});
this.$generateTotpButton.text(t("multi_factor_authentication.totp_secret_regenerate"));
await this.setRecoveryKeys();
} else {
toastService.showError(result.message);
}
}
optionsLoaded(options: OptionMap) {
if (!utils.isElectron()) {
this.$mfaEnabledCheckbox.prop("checked", options.mfaEnabled === "true");
this.$mfaOptions.toggle(options.mfaEnabled === "true");
if (options.mfaEnabled === "true") {
const savedMethod = options.mfaMethod || "totp";
this.$mfaMethodRadios.filter(`[value="${savedMethod}"]`).prop("checked", true);
this.$totpOptions.toggle(savedMethod === "totp");
this.$oauthOptions.toggle(savedMethod === "oauth");
} else {
this.$totpOptions.hide();
this.$oauthOptions.hide();
}
server.get<OAuthStatus>("oauth/status").then((result) => {
if (result.enabled) {
if (result.name) this.$UserAccountName.text(result.name);
if (result.email) this.$UserAccountEmail.text(result.email);
this.$oauthWarning.hide();
this.$missingVars.hide();
} else {
this.$UserAccountName.text(t("multi_factor_authentication.oauth_user_not_logged_in"));
this.$UserAccountEmail.text(t("multi_factor_authentication.oauth_user_not_logged_in"));
this.$oauthWarning.show();
if (result.missingVars && result.missingVars.length > 0) {
this.$missingVars.show();
const missingVarsList = result.missingVars.map(v => `"${v}"`);
this.$missingVars.html(t("multi_factor_authentication.oauth_missing_vars", { variables: missingVarsList.join(", ") }));
}
}
});
server.get<TOTPStatus>("totp/status").then((result) => {
if (result.set) {
this.$generateTotpButton.text(t("multi_factor_authentication.totp_secret_regenerate"));
this.$noTotpSecretWarning.hide();
} else {
this.$noTotpSecretWarning.show();
}
});
}
}
}

View File

@ -0,0 +1,204 @@
import { Trans } from "react-i18next"
import { t } from "../../../services/i18n"
import FormText from "../../react/FormText"
import OptionsSection from "./components/OptionsSection"
import FormCheckbox from "../../react/FormCheckbox"
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks"
import FormGroup from "../../react/FormGroup"
import { FormInlineRadioGroup } from "../../react/FormRadioGroup"
import Admonition from "../../react/Admonition"
import { useCallback, useEffect, useMemo, useState } from "preact/hooks"
import { TOTPGenerate, TOTPRecoveryKeysResponse, TOTPStatus } from "@triliumnext/commons"
import server from "../../../services/server"
import Button from "../../react/Button"
import dialog from "../../../services/dialog"
import toast from "../../../services/toast"
export default function MultiFactorAuthenticationSettings() {
const [ mfaEnabled, setMfaEnabled ] = useTriliumOptionBool("mfaEnabled");
return (
<>
<EnableMultiFactor mfaEnabled={mfaEnabled} setMfaEnabled={setMfaEnabled} />
<MultiFactorMethod />
</>
)
}
function EnableMultiFactor({ mfaEnabled, setMfaEnabled }: { mfaEnabled: boolean, setMfaEnabled: (newValue: boolean) => Promise<void>}) {
return (
<OptionsSection title={t("multi_factor_authentication.title")}>
<FormText><Trans i18nKey="multi_factor_authentication.description" /></FormText>
<FormCheckbox
name="mfa-enabled"
label={t("multi_factor_authentication.mfa_enabled")}
currentValue={mfaEnabled} onChange={setMfaEnabled}
/>
</OptionsSection>
)
}
function MultiFactorMethod() {
const [ mfaMethod, setMfaMethod ] = useTriliumOption("mfaMethod");
return (
<>
<OptionsSection className="mfa-options" title={t("multi_factor_authentication.mfa_method")}>
<FormInlineRadioGroup
name="mfaMethod"
currentValue={mfaMethod} onChange={setMfaMethod}
values={[
{ value: "totp", label: t("multi_factor_authentication.totp_title") },
{ value: "oauth", label: t("multi_factor_authentication.oauth_title") }
]}
/>
<FormText>
{ mfaMethod === "totp"
? t("multi_factor_authentication.totp_description")
: ""}
</FormText>
</OptionsSection>
{ mfaMethod === "totp" && <TotpSettings />}
</>
)
}
function TotpSettings() {
return (
<div class="totp-options">
<TotpGenerateSecret />
</div>
);
}
function TotpGenerateSecret() {
const [ totpStatus, setTotpStatus ] = useState<TOTPStatus>();
const [ recoveryKeys, setRecoveryKeys ] = useState<string[]>();
const refreshTotpStatus = useCallback(() => {
server.get<TOTPStatus>("totp/status").then(setTotpStatus);
}, [])
const refreshRecoveryKeys = useCallback(async () => {
const result = await server.get<TOTPRecoveryKeysResponse>("totp_recovery/enabled");
if (!result.success) {
toast.showError(t("multi_factor_authentication.recovery_keys_error"));
return;
}
if (!result.keysExist) {
setRecoveryKeys(undefined);
return;
}
const usedResult = await server.get<TOTPRecoveryKeysResponse>("totp_recovery/used");
setRecoveryKeys(usedResult.usedRecoveryCodes);
}, []);
const generateRecoveryKeys = useCallback(async () => {
const result = await server.get<TOTPRecoveryKeysResponse>("totp_recovery/generate");
if (!result.success) {
toast.showError(t("multi_factor_authentication.recovery_keys_error"));
return;
}
if (result.recoveryCodes) {
setRecoveryKeys(result.recoveryCodes);
}
await server.post("totp_recovery/set", {
recoveryCodes: result.recoveryCodes,
});
}, []);
useEffect(() => {
refreshTotpStatus();
refreshRecoveryKeys();
}, []);
return (<>
<OptionsSection title={t("multi_factor_authentication.totp_secret_title")}>
{totpStatus?.set
? <Admonition type="warning">{t("multi_factor_authentication.totp_secret_description_warning")}</Admonition>
: <Admonition type="note">{t("multi_factor_authentication.no_totp_secret_warning")}</Admonition>
}
<Button
text={totpStatus?.set
? t("multi_factor_authentication.totp_secret_regenerate")
: t("multi_factor_authentication.totp_secret_generate")}
onClick={async () => {
if (totpStatus?.set && !await dialog.confirm(t("multi_factor_authentication.totp_secret_regenerate_confirm"))) {
return;
}
const result = await server.get<TOTPGenerate>("totp/generate");
if (!result.success) {
toast.showError(result.message);
return;
}
await dialog.prompt({
title: t("multi_factor_authentication.totp_secret_generated"),
message: t("multi_factor_authentication.totp_secret_warning"),
defaultValue: result.message,
readOnly: true
});
refreshTotpStatus();
await generateRecoveryKeys();
}}
/>
</OptionsSection>
<TotpRecoveryKeys values={recoveryKeys} generateRecoveryKeys={generateRecoveryKeys} />
</>)
}
function TotpRecoveryKeys({ values, generateRecoveryKeys }: { values?: string[], generateRecoveryKeys: () => Promise<void> }) {
return (
<OptionsSection title={t("multi_factor_authentication.recovery_keys_title")}>
<FormText>{t("multi_factor_authentication.recovery_keys_description")}</FormText>
{values ? (
<>
<Admonition type="caution">
<Trans i18nKey={t("multi_factor_authentication.recovery_keys_description_warning")} />
</Admonition>
<ol style={{ columnCount: 2 }}>
{values.map(key => {
let text = "";
if (typeof key === 'string') {
const date = new Date(key.replace(/\//g, '-'));
if (isNaN(date.getTime())) {
return <li><code>{key}</code></li>
} else {
text = t("multi_factor_authentication.recovery_keys_used", { date: key.replace(/\//g, '-') });
}
} else {
text = t("multi_factor_authentication.recovery_keys_unused", { index: key });
}
return <li>{text}</li>
})}
</ol>
</>
) : (
<p>{t("multi_factor_authentication.recovery_keys_no_key_set")}</p>
)}
<Button
text={!values
? t("multi_factor_authentication.recovery_keys_generate")
: t("multi_factor_authentication.recovery_keys_regenerate")
}
onClick={generateRecoveryKeys}
/>
</OptionsSection>
);
}

View File

@ -109,3 +109,19 @@ export interface DatabaseBackup {
}
export type ChangePasswordResponse = Response;
export interface TOTPStatus {
set: boolean;
}
export interface TOTPGenerate {
success: boolean;
message: string;
}
export interface TOTPRecoveryKeysResponse {
success: boolean;
recoveryCodes?: string[];
keysExist?: boolean;
usedRecoveryCodes?: string[];
}