import {
html, useState, useEffect, useRef, useCallback,
} from './vendor/htm-preact.js';
import { t } from './i18n.js';
import {
hubFetch, navigate, session, HUB, loadAuth, _storeRecoveryKey,
} from './hub-client.js';
import * as platform from './platform.js';
import { Icon } from './icon.js';
import { loadPending } from './invite-link.js';
const PASSWORD_MIN_BITS = 60;
const PASSWORD_MIN_LEN = 12;
// The hub's USERNAME_MIN_LEN (api/users.py), checked here so the refusal comes
// before a passphrase derivation rather than after it.
const USERNAME_MIN_LEN = 8;
// ── Passphrase field ─────────────────────────────────────────────────────────
//
// A passphrase this long is mistyped often enough that checking it is worth a
// control, and the alternative people reach for otherwise is typing it into
// the username box to read it back. The button is out of the tab order
// (`tabindex="-1"`): everyone who is not reaching for it would pay a keystroke
// between the passphrase and the submit button, and it does nothing a keyboard
// user cannot do by other means. `type="button"` matters — a bare button in a
// form submits it, which here would try to sign in on the first click.
function PasswordInput({
value, onInput, placeholder, autocomplete,
minlength = null, autofocus = false,
}) {
const [shown, setShown] = useState(false);
const label = t(shown ? 'login.hide_password' : 'login.show_password');
return html`
`;
}
// What MeshBay is, beside the sign-in form. Browser only: inside the desktop
// application the reader has already downloaded it, and the links point at the
// project's own site rather than at whichever hub the application is set to.
//
// Written for somebody who is not in IT: what it does for them first, how it
// works in three steps, and privacy said once, plainly. Every sentence is held
// to MESHBAY_DESIGN.md §2.3 — "your content never passes through meshbay.org"
// is a claim the design makes; "meshbay.org cannot read anything" is one it
// forbids (T3). "Encrypted all the way" means device to node, as §2.3 defines.
const WELCOME_APPS = [
['chat', 'welcome.app_chat'], ['image', 'welcome.app_photos'],
['video', 'welcome.app_media'], ['play', 'welcome.app_video'],
['music', 'welcome.app_music'],
];
const WELCOME_STEPS = [
['home', 'welcome.step_home'], ['globe', 'welcome.step_anywhere'],
['user', 'welcome.step_share'],
];
const WELCOME_USES = [
['chat', 'welcome.use_chat'], ['image', 'welcome.use_photos'],
['cast', 'welcome.use_media'], ['pencil', 'welcome.use_apps'],
];
const WELCOME_BADGES = ['welcome.badge_free', 'welcome.badge_open',
'welcome.badge_no_ads', 'welcome.badge_no_tracking'];
const REPO = 'https://git.meshbay.org/meshbay.git/about/';
const WELCOME_DOCS = [
['folder', 'welcome.docs_source', [['welcome.docs_repo', REPO]]],
['user', 'welcome.docs_user', [
['welcome.docs_quickstart', `${REPO}docs/QUICKSTART.md`],
['welcome.docs_userguide', `${REPO}docs/USERGUIDE.md`]]],
['gear', 'welcome.docs_devel', [
['welcome.docs_design', `${REPO}docs/MESHBAY_DESIGN.md`],
['welcome.docs_protocol', `${REPO}docs/MESHBAY_NODE_PROTOCOL.md`]]],
];
// Under the sign-in form rather than at the foot of the text: on a desktop the
// text column runs far below the form, and these were the last thing on it.
function WelcomeLinks() {
return html`
`;
}
// The sign-in page's dark gradient backdrop and frosted card, without the
// pitch beside it — so Register (and its verify/recovery/done steps) sits on
// the same background and reads in the same dark theme as Login. A lone
// `.welcome-side` is centred by `.welcome`'s `justify-content`.
function AuthShell({ children }) {
return html`
${children}
`;
}
export function RegisterPage() {
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [error, setError] = useState('');
const [phase, setPhase] = useState('form'); // form | recovery | verify | done
const [loading, setLoading] = useState(false);
const [code, setCode] = useState('');
const [verifying, setVerifying] = useState(false);
const [resent, setResent] = useState(false);
const [recoveryMnemonic, setRecoveryMnemonic] = useState('');
const [recoverySaved, setRecoverySaved] = useState(false);
const [recoveryCopied, setRecoveryCopied] = useState(false);
// Off by default: mailing the recovery key is opt-in — the key is shown on
// screen to save, and sending a copy is the user's own choice to make.
const [emailRecovery, setEmailRecovery] = useState(false);
const captcha = useCaptcha();
const onSubmit = async (e) => {
e.preventDefault();
const name = username.trim();
if (name.length < USERNAME_MIN_LEN) {
setError(t('register.err_username_len', { n: USERNAME_MIN_LEN })); return;
}
if (password !== confirm) { setError(t('register.err_mismatch')); return; }
if (password.length < PASSWORD_MIN_LEN) {
setError(t('register.err_min_len', { n: PASSWORD_MIN_LEN })); return;
}
if (passwordBits(password) < PASSWORD_MIN_BITS) {
setError(t('register.err_too_weak')); return;
}
setError('');
setLoading(true);
try {
if (window.MeshBayKeys) {
// The account recovery key (docs/MESHBAY_DESIGN.md §3.6): generated
// here, shown once on the next screen. When the user leaves "e-mail it"
// checked, the mnemonic goes in the register body so the hub appends it
// to the verification e-mail (and stores it nowhere); otherwise it is
// screen-only. The derived key is kept in `session.recoveryKey` and
// persisted, so groups joined later — this session or a future one —
// still leave a recovery-wrapped copy on their node.
const rk = window.MeshBayKeys.generateRecoveryKey();
// `name` (trimmed), not the raw field: the hub stores the trimmed
// username and every key derivation must fold in the same string.
// `captcha.token` rides along — the submit button is already disabled
// until it is set when a captcha is configured (see the form below).
await window.MeshBayKeys.registerUser(
name, email, password, emailRecovery ? rk.mnemonic : null,
captcha.token);
setRecoveryMnemonic(rk.mnemonic);
session.recoveryKey =
await window.MeshBayKeys.deriveRecoveryKey(rk.mnemonic, name);
await _storeRecoveryKey(session.recoveryKey);
setPhase('recovery');
} else {
await hubFetch('/v1/users/register', {
method: 'POST',
body: {
username: name, email, password,
pk_user_ed25519: '', pk_user_x25519: '',
captcha_token: captcha.token,
},
});
setPhase('verify');
}
} catch (err) {
setError(err.message);
// A reCAPTCHA token is single-use: after a failed attempt (name taken,
// e-mail in use…) it is spent, so clear it and make the user solve a
// fresh one before the next try. No-op when no captcha is configured.
captcha.reset();
} finally {
setLoading(false);
}
};
const onVerify = async (e) => {
e.preventDefault();
if (!code.trim()) return;
setError('');
setVerifying(true);
try {
await hubFetch('/v1/users/verify-email', {
method: 'POST',
body: { email, code: code.trim() },
});
setPhase('done');
} catch (err) {
setError(err.message);
} finally {
setVerifying(false);
}
};
const onResend = async () => {
setError('');
setResent(false);
try {
await hubFetch('/v1/users/register', {
method: 'POST',
body: {
username: username.trim(), email, password,
pk_user_ed25519: '', pk_user_x25519: '',
},
});
setResent(true);
} catch (err) {
setError(err.message);
}
};
if (phase === 'done') {
return html`
<${AuthShell}>