Wrong server was published before. This is the one that runs on the hosting account the video was made on: FiveRP - two-step registration (account, then an identity printed onto a passport), login that returns you to your resident, and a loading screen driven by the game's own streaming events. resources/[local]/fiverp-auth NUI + scrypt hashes + oxmysql resources/[local]/fiverp-characters identity, character load, spawn resources/[local]/fiverp-loadscreen loading screen sql/schema.sql accounts, characters docs/DESIGN.md the design system every screen follows docs/screens/ shot from the live server Only our own code is in here. cfx-server-data and oxmysql are fetched by install.sh instead of being vendored, which keeps the repo at 3.7 MB. install.sh does the whole box in one command: recommended FXServer build, cfx-server-data, oxmysql, MariaDB with the schema, resources, server.cfg (mode 600 - it carries the key and the database password), boot entry, then it waits for the Cfx registration. Tested end to end on a spare install root: 29 resources scanned, database and schema created, and it stopped exactly where a wrong licence key should stop it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
392 lines
13 KiB
JavaScript
392 lines
13 KiB
JavaScript
(() => {
|
||
'use strict';
|
||
|
||
const RESOURCE = 'fiverp-auth';
|
||
|
||
const stage = document.getElementById('stage');
|
||
const slab = document.getElementById('slab');
|
||
const railFill = document.getElementById('railFill');
|
||
const permit = document.getElementById('permit');
|
||
const sheen = document.getElementById('sheen');
|
||
const seal = document.getElementById('seal');
|
||
|
||
const cardNo = document.getElementById('cardNo');
|
||
const cardFirst = document.getElementById('cardFirst');
|
||
const cardLast = document.getElementById('cardLast');
|
||
const cardDob = document.getElementById('cardDob');
|
||
const cardIssue = document.getElementById('cardIssue');
|
||
const cardExpiry = document.getElementById('cardExpiry');
|
||
const cardSign = document.getElementById('cardSign');
|
||
const mrz1 = document.getElementById('mrz1');
|
||
const mrz2 = document.getElementById('mrz2');
|
||
const stampDate = document.getElementById('stampDate');
|
||
|
||
const views = {
|
||
login: document.getElementById('formLogin'),
|
||
account: document.getElementById('formAccount'),
|
||
identity: document.getElementById('formIdentity')
|
||
};
|
||
|
||
let current = 'login';
|
||
let busy = false;
|
||
// Set when sign-in finds an account whose registration never reached step 2.
|
||
let resuming = false;
|
||
|
||
// ------------------------------------------------------------------ post
|
||
function post(endpoint, data) {
|
||
return fetch(`https://${RESOURCE}/${endpoint}`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json; charset=UTF-8' },
|
||
body: JSON.stringify(data || {})
|
||
}).catch(() => {});
|
||
}
|
||
|
||
// ------------------------------------------------------------------ views
|
||
function show(view) {
|
||
current = view;
|
||
slab.dataset.view = view;
|
||
|
||
Object.entries(views).forEach(([name, form]) => {
|
||
form.classList.toggle('is-current', name === view);
|
||
});
|
||
|
||
document.querySelectorAll('.rail-step').forEach((step) => {
|
||
const which = step.dataset.step;
|
||
step.classList.toggle('is-active', which === view);
|
||
step.classList.toggle('is-done', which === 'account' && view === 'identity');
|
||
});
|
||
railFill.classList.toggle('is-full', view === 'identity');
|
||
|
||
const first = views[view].querySelector('input');
|
||
if (first) setTimeout(() => first.focus(), 240);
|
||
}
|
||
|
||
function notice(view, message, good) {
|
||
const el = views[view].querySelector('[data-notice]');
|
||
if (!message) {
|
||
el.classList.remove('is-shown', 'is-good');
|
||
el.textContent = '';
|
||
return;
|
||
}
|
||
el.textContent = message;
|
||
el.classList.add('is-shown');
|
||
el.classList.toggle('is-good', !!good);
|
||
}
|
||
|
||
function setBusy(view, state) {
|
||
busy = state;
|
||
const btn = views[view].querySelector('.btn');
|
||
btn.classList.toggle('is-busy', state);
|
||
btn.disabled = state;
|
||
}
|
||
|
||
function values(view) {
|
||
const out = {};
|
||
views[view].querySelectorAll('input').forEach((input) => {
|
||
out[input.name] = input.value.trim();
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function markBad(view, names) {
|
||
views[view].querySelectorAll('input').forEach((input) => {
|
||
input.classList.toggle('is-bad', names.includes(input.name));
|
||
});
|
||
}
|
||
|
||
// =========================================================== the passport
|
||
// Everything on the data page is derived from the name, so the same resident
|
||
// always gets the same document — it reads as a record, not a random mock-up.
|
||
|
||
function seedOf(text) {
|
||
let hash = 0;
|
||
for (let i = 0; i < text.length; i++) hash = (hash * 31 + text.charCodeAt(i)) >>> 0;
|
||
return hash;
|
||
}
|
||
|
||
function passportNumber(seed) {
|
||
return String(seed % 1000000000).padStart(9, '0');
|
||
}
|
||
|
||
const MONTHS = ['JAN','FEB','MAR','APR','MAY','JUN','JUL','AUG','SEP','OCT','NOV','DEC'];
|
||
|
||
function human(date) {
|
||
return String(date.getDate()).padStart(2, '0') + ' ' +
|
||
MONTHS[date.getMonth()] + ' ' + date.getFullYear();
|
||
}
|
||
|
||
// MRZ dates are YYMMDD.
|
||
function mrzDate(date) {
|
||
return String(date.getFullYear() % 100).padStart(2, '0') +
|
||
String(date.getMonth() + 1).padStart(2, '0') +
|
||
String(date.getDate()).padStart(2, '0');
|
||
}
|
||
|
||
// ICAO 9303 check digit: weights cycle 7-3-1 over the field.
|
||
function checkDigit(field) {
|
||
const WEIGHTS = [7, 3, 1];
|
||
let sum = 0;
|
||
for (let i = 0; i < field.length; i++) {
|
||
const c = field[i];
|
||
let v;
|
||
if (c >= '0' && c <= '9') v = c.charCodeAt(0) - 48;
|
||
else if (c >= 'A' && c <= 'Z') v = c.charCodeAt(0) - 55;
|
||
else v = 0; // '<' filler
|
||
sum += v * WEIGHTS[i % 3];
|
||
}
|
||
return String(sum % 10);
|
||
}
|
||
|
||
function pad(text, length) {
|
||
return (text + '<'.repeat(length)).slice(0, length);
|
||
}
|
||
|
||
function buildDocument(firstName, lastName) {
|
||
const seed = seedOf((firstName + lastName).toLowerCase());
|
||
const number = passportNumber(seed);
|
||
|
||
const now = new Date();
|
||
const issue = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||
const expiry = new Date(issue.getFullYear() + 10, issue.getMonth(), issue.getDate());
|
||
// A plausible adult birth date, stable for this name.
|
||
const dob = new Date(
|
||
now.getFullYear() - (21 + (seed % 34)),
|
||
(seed >>> 7) % 12,
|
||
1 + ((seed >>> 13) % 28)
|
||
);
|
||
|
||
const surname = lastName.toUpperCase();
|
||
const given = firstName.toUpperCase();
|
||
|
||
const line1 = pad('P<USA' + surname + '<<' + given, 44);
|
||
|
||
const numCd = checkDigit(number);
|
||
const dobCd = checkDigit(mrzDate(dob));
|
||
const expCd = checkDigit(mrzDate(expiry));
|
||
const personal = '<'.repeat(14);
|
||
const personalCd = checkDigit(personal);
|
||
const composite = number + numCd + mrzDate(dob) + dobCd +
|
||
mrzDate(expiry) + expCd + personal + personalCd;
|
||
|
||
const line2 = number + numCd + 'USA' + mrzDate(dob) + dobCd + 'X' +
|
||
mrzDate(expiry) + expCd + personal + personalCd +
|
||
checkDigit(composite);
|
||
|
||
return { number, issue, expiry, dob, line1, line2 };
|
||
}
|
||
|
||
function printName(el, text) {
|
||
if (!text) { el.textContent = ''; return; }
|
||
if (el.textContent === text) return;
|
||
el.textContent = '';
|
||
const span = document.createElement('span');
|
||
span.className = 'printed';
|
||
span.textContent = text;
|
||
el.appendChild(span);
|
||
}
|
||
|
||
const BLANK1 = 'P<USA' + '<'.repeat(39);
|
||
const BLANK2 = '<'.repeat(10) + 'USA' + '<'.repeat(31);
|
||
|
||
function fillPassport(firstName, lastName) {
|
||
printName(cardFirst, firstName.toUpperCase());
|
||
printName(cardLast, lastName.toUpperCase());
|
||
|
||
// Below two letters there is not enough to derive a document from.
|
||
if (firstName.length < 2 || lastName.length < 2) {
|
||
cardNo.textContent = '—';
|
||
cardDob.textContent = '—';
|
||
cardIssue.textContent = '—';
|
||
cardExpiry.textContent = '—';
|
||
cardSign.textContent = '';
|
||
mrz1.textContent = BLANK1;
|
||
mrz2.textContent = BLANK2;
|
||
return;
|
||
}
|
||
|
||
const doc = buildDocument(firstName, lastName);
|
||
cardNo.textContent = doc.number;
|
||
cardDob.textContent = human(doc.dob);
|
||
cardIssue.textContent = human(doc.issue);
|
||
cardExpiry.textContent = human(doc.expiry);
|
||
cardSign.textContent = firstName + ' ' + lastName;
|
||
mrz1.textContent = doc.line1;
|
||
mrz2.textContent = doc.line2;
|
||
stampDate.textContent = human(doc.issue);
|
||
}
|
||
|
||
function syncPassport() {
|
||
const { firstName, lastName } = values('identity');
|
||
fillPassport(firstName, lastName);
|
||
}
|
||
|
||
function issuePassport() {
|
||
permit.classList.add('is-issued');
|
||
seal.classList.add('is-stamped');
|
||
sheen.style.opacity = '0.85';
|
||
}
|
||
|
||
// Laminate glare tracks the cursor across the page.
|
||
permit.addEventListener('pointermove', (e) => {
|
||
const box = permit.getBoundingClientRect();
|
||
sheen.style.setProperty('--mx', `${((e.clientX - box.left) / box.width) * 100}%`);
|
||
sheen.style.setProperty('--my', `${((e.clientY - box.top) / box.height) * 100}%`);
|
||
});
|
||
|
||
// --------------------------------------------------------- password meter
|
||
const pwInput = views.account.querySelector('input[name="password"]');
|
||
const meter = views.account.querySelector('[data-meter]');
|
||
|
||
pwInput.addEventListener('input', () => {
|
||
const v = pwInput.value;
|
||
meter.classList.toggle('is-live', v.length > 0);
|
||
let level = 1;
|
||
if (v.length >= 8 && /[A-Za-z]/.test(v) && /\d/.test(v)) level = 2;
|
||
if (v.length >= 12 && /[A-Za-z]/.test(v) && /\d/.test(v) && /[^A-Za-z0-9]/.test(v)) level = 3;
|
||
meter.dataset.level = String(level);
|
||
meter.querySelector('i').style.width = `${level * 33.4}%`;
|
||
});
|
||
|
||
views.identity.querySelectorAll('input').forEach((input) => {
|
||
input.addEventListener('input', () => {
|
||
// Keep the document honest: only English letters ever reach it.
|
||
input.value = input.value.replace(/[^A-Za-z]/g, '');
|
||
syncPassport();
|
||
});
|
||
});
|
||
|
||
// ------------------------------------------------------------ validation
|
||
const RE_EMAIL = /^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i;
|
||
const RE_USERNAME = /^[A-Za-z0-9_]{3,32}$/;
|
||
const RE_NAME = /^[A-Za-z]{2,24}$/;
|
||
|
||
function checkAccount(v) {
|
||
if (!RE_EMAIL.test(v.email)) return ['Enter a valid email address.', ['email']];
|
||
if (!RE_USERNAME.test(v.username)) return ['Username must be 3–32 characters, letters, numbers or underscore.', ['username']];
|
||
if (v.password.length < 8) return ['Password must be at least 8 characters.', ['password']];
|
||
return null;
|
||
}
|
||
|
||
function checkIdentity(v) {
|
||
if (!RE_NAME.test(v.firstName)) return ['First name must be 2–24 English letters.', ['firstName']];
|
||
if (!RE_NAME.test(v.lastName)) return ['Last name must be 2–24 English letters.', ['lastName']];
|
||
return null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- submits
|
||
views.login.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
if (busy) return;
|
||
const v = values('login');
|
||
if (!v.username || !v.password) {
|
||
markBad('login', [!v.username ? 'username' : 'password']);
|
||
return notice('login', 'Enter your username and password.');
|
||
}
|
||
markBad('login', []);
|
||
notice('login', '');
|
||
setBusy('login', true);
|
||
post('login', v);
|
||
});
|
||
|
||
views.account.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
if (busy) return;
|
||
const v = values('account');
|
||
const bad = checkAccount(v);
|
||
if (bad) { markBad('account', bad[1]); return notice('account', bad[0]); }
|
||
markBad('account', []);
|
||
notice('account', '');
|
||
setBusy('account', true);
|
||
post('registerAccount', v);
|
||
});
|
||
|
||
views.identity.addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
if (busy) return;
|
||
const v = values('identity');
|
||
const bad = checkIdentity(v);
|
||
if (bad) { markBad('identity', bad[1]); return notice('identity', bad[0]); }
|
||
markBad('identity', []);
|
||
notice('identity', '');
|
||
setBusy('identity', true);
|
||
post(resuming ? 'resumeIdentity' : 'registerIdentity', v);
|
||
});
|
||
|
||
document.querySelectorAll('[data-goto]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
if (busy) return;
|
||
notice(current, '');
|
||
show(btn.dataset.goto);
|
||
});
|
||
});
|
||
|
||
// ------------------------------------------------------ messages from Lua
|
||
window.addEventListener('message', (event) => {
|
||
const msg = event.data || {};
|
||
const p = msg.payload || {};
|
||
|
||
switch (msg.action) {
|
||
case 'open':
|
||
stage.classList.add('is-open');
|
||
stage.setAttribute('aria-hidden', 'false');
|
||
show('login');
|
||
break;
|
||
|
||
case 'close':
|
||
stage.classList.remove('is-open');
|
||
stage.setAttribute('aria-hidden', 'true');
|
||
break;
|
||
|
||
case 'accountResult':
|
||
setBusy('account', false);
|
||
if (p.ok) {
|
||
notice('account', '');
|
||
show('identity');
|
||
syncPassport();
|
||
} else {
|
||
notice('account', p.error || 'Something went wrong.');
|
||
}
|
||
break;
|
||
|
||
case 'identityResult':
|
||
setBusy('identity', false);
|
||
if (p.ok) {
|
||
fillPassport(String(p.firstName), String(p.lastName));
|
||
issuePassport();
|
||
notice('identity', `Passport issued. Welcome to Los Santos, ${p.firstName}.`, true);
|
||
views.identity.querySelector('.btn span').textContent = 'Entering the city…';
|
||
views.identity.querySelector('.btn').disabled = true;
|
||
// Nothing left to go back to once the document exists.
|
||
views.identity.querySelector('.switch').style.display = 'none';
|
||
} else {
|
||
if (p.reset) { resuming = false; show('account'); }
|
||
notice(p.reset ? 'account' : 'identity', p.error || 'Something went wrong.');
|
||
}
|
||
break;
|
||
|
||
case 'loginResult':
|
||
setBusy('login', false);
|
||
if (p.ok && p.needsIdentity) {
|
||
// Account is real but has no resident yet — finish step 2.
|
||
resuming = true;
|
||
show('identity');
|
||
syncPassport();
|
||
notice('identity', 'Your file is open. Name your resident to finish.', true);
|
||
} else if (p.ok) {
|
||
notice('login', `Signed in. Returning to the city, ${p.firstName}.`, true);
|
||
views.login.querySelector('.btn span').textContent = 'Entering the city…';
|
||
views.login.querySelector('.btn').disabled = true;
|
||
} else {
|
||
markBad('login', ['username', 'password']);
|
||
notice('login', p.error || 'Something went wrong.');
|
||
}
|
||
break;
|
||
}
|
||
});
|
||
|
||
// Clear the error ring as soon as the player starts fixing the field.
|
||
document.querySelectorAll('input').forEach((input) => {
|
||
input.addEventListener('input', () => input.classList.remove('is-bad'));
|
||
});
|
||
})();
|