108 lines
3.6 KiB
TypeScript
108 lines
3.6 KiB
TypeScript
import { computed, onScopeDispose, ref } from 'vue';
|
|
import { useLocalization } from '@/composables/useLocalization';
|
|
import { qrCode, recoveryCodes, secretKey } from '@/routes/two-factor';
|
|
|
|
/** Keep secrets within the consuming component and discard late responses after hiding them. */
|
|
export function useTwoFactorAuth() {
|
|
const { t } = useLocalization();
|
|
const errors = ref<string[]>([]);
|
|
const manualSetupKey = ref<string | null>(null);
|
|
const qrCodeSvg = ref<string | null>(null);
|
|
const recoveryCodesList = ref<string[]>([]);
|
|
const loading = ref(false);
|
|
let pending: AbortController | null = null;
|
|
|
|
async function fetchJson<T>(url: string, signal: AbortSignal): Promise<T> {
|
|
const response = await fetch(url, {
|
|
headers: { Accept: 'application/json' },
|
|
credentials: 'same-origin',
|
|
signal,
|
|
});
|
|
if (!response.ok || response.redirected) {
|
|
throw new Error(String(response.status));
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
function clearErrors(): void {
|
|
errors.value = [];
|
|
}
|
|
|
|
function clearTwoFactorAuthData(): void {
|
|
pending?.abort();
|
|
pending = null;
|
|
loading.value = false;
|
|
manualSetupKey.value = null;
|
|
qrCodeSvg.value = null;
|
|
recoveryCodesList.value = [];
|
|
clearErrors();
|
|
}
|
|
|
|
async function load(kind: 'setup' | 'recovery'): Promise<void> {
|
|
clearTwoFactorAuthData();
|
|
const request = new AbortController();
|
|
pending = request;
|
|
loading.value = true;
|
|
try {
|
|
if (kind === 'setup') {
|
|
const [qr, key] = await Promise.all([
|
|
fetchJson<{ svg: string }>(qrCode.url(), request.signal),
|
|
fetchJson<{ secretKey: string }>(
|
|
secretKey.url(),
|
|
request.signal,
|
|
),
|
|
]);
|
|
if (pending !== request) {
|
|
return;
|
|
}
|
|
qrCodeSvg.value = qr.svg;
|
|
manualSetupKey.value = key.secretKey;
|
|
} else {
|
|
const codes = await fetchJson<string[]>(
|
|
recoveryCodes.url(),
|
|
request.signal,
|
|
);
|
|
if (pending === request) {
|
|
recoveryCodesList.value = codes;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (pending === request && !request.signal.aborted) {
|
|
const status = error instanceof Error ? error.message : '';
|
|
errors.value = [
|
|
t(
|
|
status === '423' || status === '401' || status === '419'
|
|
? 'security.confirmAgain'
|
|
: kind === 'setup'
|
|
? 'security.setupError'
|
|
: 'security.recoveryError',
|
|
),
|
|
];
|
|
}
|
|
} finally {
|
|
if (pending === request) {
|
|
pending = null;
|
|
loading.value = false;
|
|
}
|
|
}
|
|
}
|
|
|
|
onScopeDispose(clearTwoFactorAuthData);
|
|
return {
|
|
qrCodeSvg,
|
|
manualSetupKey,
|
|
recoveryCodesList,
|
|
errors,
|
|
loading,
|
|
hasSetupData: computed(() =>
|
|
Boolean(qrCodeSvg.value && manualSetupKey.value),
|
|
),
|
|
clearSetupData: clearTwoFactorAuthData,
|
|
clearRecoveryCodes: clearTwoFactorAuthData,
|
|
clearTwoFactorAuthData,
|
|
clearErrors,
|
|
fetchSetupData: () => load('setup'),
|
|
fetchRecoveryCodes: () => load('recovery'),
|
|
};
|
|
}
|