Use URL-based state management

This commit is contained in:
Quantum 2025-02-10 11:45:17 -05:00
parent 51df02bf3f
commit 230326ab9c
4 changed files with 87 additions and 11 deletions

View file

@ -53,7 +53,7 @@
</ul> </ul>
<p class="card-text">What this tool <strong>can't</strong> do:</p> <p class="card-text">What this tool <strong>can't</strong> do:</p>
<ul class="card-text"> <ul class="card-text">
<li>Store your TOTP secrets;</li> <li>Store your TOTP secrets (you can try bookmarking this page with the secret in the URL, but it's not secure);</li>
<li>Act as your general purpose authenticator app; and</li> <li>Act as your general purpose authenticator app; and</li>
<li>Scan TOTP QR codes.</li> <li>Scan TOTP QR codes.</li>
</ul> </ul>

View file

@ -3,16 +3,22 @@ import {NumberInput, TextInput} from './Input';
import OTPOutput, {HashAlgorithm} from './OTPOutput'; import OTPOutput, {HashAlgorithm} from './OTPOutput';
import Select from './Select'; import Select from './Select';
import Collapsible from './Collapsible'; import Collapsible from './Collapsible';
import ActionLink from './ActionLink.tsx'; import ActionLink from './ActionLink';
import {type State, defaults, serializeState, deserializeState} from './state';
function parseState() {
if (window.location.hash.startsWith('#!')) {
return deserializeState(window.location.hash.slice(2));
}
return null;
}
function App() { function App() {
const [advanced, setAdvanced] = useState(false); const [advanced, setAdvanced] = useState(false);
const [secret, setSecret] = React.useState(''); const [state, setState] = React.useState(() => parseState() || defaults);
const [step, setStep] = React.useState(30); const {secret, step, digits, algorithm} = state;
const [offset, setOffset] = React.useState(0); const [offset, setOffset] = React.useState(0);
const [digits, setDigits] = React.useState(6);
const [algorithm, setAlgorithm] = React.useState<HashAlgorithm>('sha1');
const validStep = step > 0; const validStep = step > 0;
const validDigits = digits > 0 && digits <= 10; const validDigits = digits > 0 && digits <= 10;
@ -41,12 +47,47 @@ function App() {
setAdvanced(false); setAdvanced(false);
}, []); }, []);
const onReset = React.useCallback(() => { const setSecret = React.useCallback(
setStep(30); (secret: string) => setState((state) => ({...state, secret})),
setDigits(6); [],
setAlgorithm('sha1'); );
const setStep = React.useCallback(
(step: number) => setState((state) => ({...state, step})),
[],
);
const setDigits = React.useCallback(
(digits: number) => setState((state) => ({...state, digits})),
[],
);
const setAlgorithm = React.useCallback(
(algorithm: string) => setState((state) => ({...state, algorithm})),
[],
);
const onReset = React.useCallback(
() => setState((state) => ({...state, step: 30, digits: 6, algorithm: 'sha1'})),
[],
);
React.useEffect(() => {
const value = serializeState(state);
console.log(value);
history.replaceState(null, '', window.location.pathname + window.location.search + (value && `#!${value}`));
}, [state]);
const onHashChange = React.useCallback(() => {
const state = parseState();
state && setState(state);
}, []); }, []);
React.useEffect(() => {
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, [onHashChange]);
return ( return (
<div className="totp-app"> <div className="totp-app">
<div className="totp-settings"> <div className="totp-settings">

View file

@ -4,7 +4,7 @@ import {createDigest} from '@otplib/plugin-crypto-js';
import classNames from 'classnames'; import classNames from 'classnames';
import CopyButton from './CopyButton.tsx'; import CopyButton from './CopyButton.tsx';
const ALGORITHMS = { export const ALGORITHMS = {
sha1: HashAlgorithms.SHA1, sha1: HashAlgorithms.SHA1,
sha256: HashAlgorithms.SHA256, sha256: HashAlgorithms.SHA256,
sha512: HashAlgorithms.SHA512, sha512: HashAlgorithms.SHA512,

35
src/state.tsx Normal file
View file

@ -0,0 +1,35 @@
import {ALGORITHMS, HashAlgorithm} from './OTPOutput';
export type State = {
secret: string;
step: number;
digits: number;
algorithm: HashAlgorithm;
};
export const defaults: State = {
secret: '',
step: 30,
digits: 6,
algorithm: 'sha1',
} as const;
export function serializeState(state: State): string {
const values = ['secret', 'step', 'digits', 'algorithm'].map(
(key) => state[key] !== defaults[key] ? encodeURIComponent(state[key]) : '',
);
while (values[values.length - 1] === '') {
values.pop();
}
return values.join('/');
}
export function deserializeState(data: string): State {
const values = data.split('/').map(decodeURIComponent);
return {
secret: values[0] || defaults.secret,
step: +values[1] || defaults.step,
digits: +values[2] || defaults.digits,
algorithm: ALGORITHMS[values[3]] !== undefined ? values[3] : defaults.algorithm,
};
}