Shared wireCodeMask helper: uppercase, dash every 4 chars, raw-length cap, caret preserved, backspace over a dash deletes the char before it.
165 lines
5.1 KiB
JavaScript
165 lines
5.1 KiB
JavaScript
// Login view — Mullvad-style account tokens (no username/password).
|
|
// Two panels: create a new account (one-time token reveal) or log in with an
|
|
// existing account number.
|
|
import { api } from '../api.js';
|
|
import { el, mount, toast, wireCodeMask } from '../dom.js';
|
|
import { groupCode, normalizeCode } from '../format.js';
|
|
|
|
export function renderAuth(container, ctx) {
|
|
let mode = 'create'; // 'create' | 'login'
|
|
|
|
function draw() {
|
|
const card = el(
|
|
'div',
|
|
{ class: 'auth-card card' },
|
|
el(
|
|
'div',
|
|
{ class: 'auth-head' },
|
|
el('div', { class: 'brand-mark brand-mark-lg' }, '🧭'),
|
|
el('h1', {}, 'Trip Plan'),
|
|
el('p', { class: 'muted' }, 'Plan trips together, day by day.'),
|
|
),
|
|
el(
|
|
'div',
|
|
{ class: 'auth-tabs' },
|
|
tab('Create account', mode === 'create', () => setMode('create')),
|
|
tab('Log in', mode === 'login', () => setMode('login')),
|
|
),
|
|
mode === 'create' ? createPanel() : loginPanel(),
|
|
);
|
|
mount(container, el('div', { class: 'auth-wrap' }, card));
|
|
}
|
|
|
|
function setMode(next) {
|
|
if (mode !== next) {
|
|
mode = next;
|
|
draw();
|
|
}
|
|
}
|
|
|
|
function tab(label, active, onClick) {
|
|
return el('button', { class: `auth-tab${active ? ' active' : ''}`, type: 'button', onClick }, label);
|
|
}
|
|
|
|
// ----- Create account -----
|
|
function createPanel() {
|
|
const errorEl = el('p', { class: 'form-error' });
|
|
const createBtn = el('button', { class: 'btn btn-primary btn-block', type: 'button' }, 'Create account');
|
|
|
|
async function onCreate() {
|
|
errorEl.textContent = '';
|
|
createBtn.disabled = true;
|
|
createBtn.textContent = 'Creating…';
|
|
try {
|
|
const data = await api.auth.createAccount();
|
|
showToken(data.token, data.user);
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
createBtn.disabled = false;
|
|
createBtn.textContent = 'Create account';
|
|
}
|
|
}
|
|
createBtn.addEventListener('click', onCreate);
|
|
|
|
return el(
|
|
'div',
|
|
{ class: 'auth-panel' },
|
|
el('p', { class: 'auth-lead' }, 'No email, no password. We generate a private account number — it is your only key to your trips.'),
|
|
errorEl,
|
|
createBtn,
|
|
);
|
|
}
|
|
|
|
function showToken(token, user) {
|
|
const grouped = groupCode(token, 4);
|
|
const copyBtn = el('button', { class: 'btn', type: 'button' }, '📋 Copy');
|
|
copyBtn.addEventListener('click', async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(grouped);
|
|
toast('Account number copied', 'success');
|
|
} catch {
|
|
toast('Copy failed — select and copy it manually');
|
|
}
|
|
});
|
|
|
|
const panel = el(
|
|
'div',
|
|
{ class: 'auth-panel token-reveal' },
|
|
el('div', { class: 'token-warning' },
|
|
el('strong', {}, 'This is your only credential.'),
|
|
' Save it now — it will never be shown again. Anyone with it can access your trips.'),
|
|
el('div', { class: 'token-box' }, grouped),
|
|
el('div', { class: 'token-actions' }, copyBtn),
|
|
el(
|
|
'button',
|
|
{
|
|
class: 'btn btn-primary btn-block',
|
|
type: 'button',
|
|
onClick: () => {
|
|
ctx.state.user = user;
|
|
ctx.navigate('#/trips');
|
|
},
|
|
},
|
|
"I've saved it — continue",
|
|
),
|
|
);
|
|
|
|
mount(container, el('div', { class: 'auth-wrap' }, el('div', { class: 'auth-card card' },
|
|
el('div', { class: 'auth-head' },
|
|
el('div', { class: 'brand-mark brand-mark-lg' }, '🎉'),
|
|
el('h1', {}, 'Account created'),
|
|
el('p', { class: 'muted' }, `You're ${user.display_name}. You can rename yourself later.`)),
|
|
panel,
|
|
)));
|
|
}
|
|
|
|
// ----- Log in -----
|
|
function loginPanel() {
|
|
const errorEl = el('p', { class: 'form-error' });
|
|
const input = el('input', {
|
|
class: 'input token-input',
|
|
type: 'text',
|
|
autocomplete: 'off',
|
|
autocapitalize: 'characters',
|
|
spellcheck: 'false',
|
|
placeholder: 'XXXX-XXXX-XXXX-XXXX',
|
|
maxlength: '19',
|
|
'aria-label': 'Account number',
|
|
});
|
|
wireCodeMask(input, 16);
|
|
const submitBtn = el('button', { class: 'btn btn-primary btn-block', type: 'submit' }, 'Log in');
|
|
|
|
async function onSubmit(e) {
|
|
e.preventDefault();
|
|
errorEl.textContent = '';
|
|
const token = normalizeCode(input.value);
|
|
if (token.length < 8) {
|
|
errorEl.textContent = 'Enter your full account number.';
|
|
return;
|
|
}
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = 'Signing in…';
|
|
try {
|
|
const data = await api.auth.login(token);
|
|
ctx.state.user = data.user;
|
|
ctx.navigate('#/trips');
|
|
} catch (err) {
|
|
errorEl.textContent = err.message;
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = 'Log in';
|
|
}
|
|
}
|
|
|
|
return el(
|
|
'form',
|
|
{ class: 'auth-panel auth-form', onSubmit },
|
|
el('label', { class: 'field' }, el('span', { class: 'field-label' }, 'Account number'), input),
|
|
el('p', { class: 'hint muted' }, 'Dashes, spaces and letter case do not matter.'),
|
|
errorEl,
|
|
submitBtn,
|
|
);
|
|
}
|
|
|
|
draw();
|
|
}
|