/* * Copyright (C) 2026 Fluxer Contributors * * This file is part of Fluxer. * * Fluxer is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * Fluxer is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with Fluxer. If not, see . */ import * as ModalActionCreators from '@app/actions/ModalActionCreators'; import {modal} from '@app/actions/ModalActionCreators'; import SudoVerificationModal from '@app/components/modals/SudoVerificationModal'; import {Endpoints} from '@app/Endpoints'; import HttpClient, {type HttpRequestConfig} from '@app/lib/HttpClient'; import type {HttpError} from '@app/lib/HttpError'; import {Logger} from '@app/lib/Logger'; import {makePersistent} from '@app/lib/MobXPersistence'; import type {SudoVerificationPayload} from '@app/types/Sudo'; import {getApiErrorMessage} from '@app/utils/ApiErrorUtils'; import {makeAutoObservable, runInAction} from 'mobx'; interface SudoRequestContext { method: string; url: string; } export enum SudoVerificationMethod { PASSWORD = 'password', TOTP = 'totp', SMS = 'sms', WEBAUTHN = 'webauthn', } export function isAbortError(error: unknown): boolean { if (error instanceof DOMException && error.name === 'AbortError') { return true; } if (error instanceof Error && error.name === 'AbortError') { return true; } return false; } const SUDO_MODAL_KEY = 'sudo-verification-modal'; class SudoPromptStore { private logger = new Logger('SudoPromptStore'); isOpen = false; isLoadingMethods = false; isVerifying = false; verificationError: string | null = null; rawError: HttpError | null = null; currentRequest: SudoRequestContext | null = null; availableMethods: {password: boolean; totp: boolean; sms: boolean; webauthn: boolean; has_mfa: boolean} = { password: true, totp: false, sms: false, webauthn: false, has_mfa: false, }; lastUsedMfaMethod: SudoVerificationPayload['mfa_method'] | null = null; private resolver: ((payload: SudoVerificationPayload) => void) | null = null; private rejecter: ((reason?: unknown) => void) | null = null; constructor() { makeAutoObservable(this, {}, {autoBind: true}); } init(): void { HttpClient.setSudoHandler(this.handleSudoRequest); HttpClient.setSudoFailureHandler(this.onSudoVerificationFailed); void makePersistent(this, 'SudoPromptStore', ['lastUsedMfaMethod']); } requestVerification(context: SudoRequestContext = {method: 'POST', url: ''}): Promise { return new Promise((resolve, reject) => { runInAction(() => { this.currentRequest = context; this.isOpen = true; this.resolver = resolve; this.rejecter = reject; this.pushModal(); }); }); } private pushModal(): void { ModalActionCreators.pushWithKey( modal(() => ), SUDO_MODAL_KEY, ); } private async handleSudoRequest(config: HttpRequestConfig): Promise { const request: SudoRequestContext = {method: config.method ?? 'GET', url: config.url}; return await new Promise((resolve, reject) => { runInAction(() => { this.currentRequest = request; this.isOpen = true; this.resolver = resolve; this.rejecter = reject; this.pushModal(); }); }); } async loadAvailableMethods(): Promise { runInAction(() => { this.isLoadingMethods = true; }); try { const response = await HttpClient.get<{totp: boolean; sms: boolean; webauthn: boolean; has_mfa: boolean}>({ url: Endpoints.SUDO_MFA_METHODS, }); runInAction(() => { const hasMfa = response.body.has_mfa; this.availableMethods = { password: !hasMfa, totp: response.body.totp, sms: response.body.sms, webauthn: response.body.webauthn, has_mfa: hasMfa, }; }); } catch (error) { this.logger.error('Failed to load sudo MFA methods', error); runInAction(() => { this.availableMethods = {password: true, totp: false, sms: false, webauthn: false, has_mfa: false}; }); } finally { runInAction(() => { this.isLoadingMethods = false; }); } } submit(payload: SudoVerificationPayload): void { if (payload.mfa_method === 'totp' || payload.mfa_method === 'sms' || payload.mfa_method === 'webauthn') { this.lastUsedMfaMethod = payload.mfa_method; } if (this.resolver) { this.isVerifying = true; this.verificationError = null; this.rawError = null; this.resolver(payload); } } reject(reason?: unknown): void { if (this.rejecter) { this.rejecter(reason); } this.cleanup(); } handleTokenReceived(_token: string | null): void { if (!this.isOpen && !this.isVerifying) { return; } this.cleanup(); } private onSudoVerificationFailed = (error: unknown): void => { runInAction(() => { this.isVerifying = false; if (isAbortError(error)) { this.cleanup(); return; } const httpError = error as HttpError | null; this.rawError = httpError; this.verificationError = getApiErrorMessage(error) ?? 'Verification failed'; }); }; private cleanup(): void { this.isOpen = false; this.isVerifying = false; this.verificationError = null; this.rawError = null; this.currentRequest = null; this.resolver = null; this.rejecter = null; ModalActionCreators.popWithKey(SUDO_MODAL_KEY); } } export default new SudoPromptStore();