Add shift planning, month closure + payroll, and a dark modern redesign
Ports two modules from the friend's Google Apps Script build (kept as reference in gscript/) onto the TypeScript stack, rewritten cleanly against this app's own data model rather than copied 1:1: - Shift planning: weekly template (Sun-Thu evening, Fri/Sat two slots), lazy idempotent generation per period (no cron needed), employee signup/cancel with collision + capacity checks, admin calendar view with slot editing and manual assignment. - Month closure + payroll: employee confirms the month (blocked while a shift is still open), admin locks and finalizes pay (base hours * rate + tips/bonus/other), reopen to undo a premature lock, mark paid. Pay rates are versioned by date, defaulting the first-ever rate to apply retroactively to the employee's whole history. - A shift left open more than 12h (forgotten clock-out) is auto-closed at clock_in + 12h, checked lazily on read instead of a background job. - Full dark, sharp-edged modern restyle (theme.css replaces tui.css) with an amber accent, keeping every existing class name so no component logic needed to change. Backend test coverage (jest) for all three workflows: shift planning, closure/payroll, and the forgotten-clock-out auto-close.
This commit is contained in:
377
gscript/Security.js
Normal file
377
gscript/Security.js
Normal file
@@ -0,0 +1,377 @@
|
||||
/* ============================================================
|
||||
EATME PORTÁL – SECURITY HELPERS
|
||||
============================================================ */
|
||||
|
||||
|
||||
/* ============================================================
|
||||
NÁHODNÝ AKTIVAČNÍ KÓD
|
||||
============================================================ */
|
||||
|
||||
function randomCode_() {
|
||||
|
||||
return String(
|
||||
Math.floor(
|
||||
100000 +
|
||||
Math.random() *
|
||||
900000
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
SESSION TOKEN
|
||||
============================================================ */
|
||||
|
||||
function randomToken_() {
|
||||
|
||||
return Utilities
|
||||
.base64EncodeWebSafe(
|
||||
Utilities.computeDigest(
|
||||
Utilities.DigestAlgorithm.SHA_256,
|
||||
Utilities.getUuid() +
|
||||
':' +
|
||||
new Date().getTime() +
|
||||
':' +
|
||||
Math.random()
|
||||
)
|
||||
)
|
||||
.replace(
|
||||
/=+$/,
|
||||
''
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
SHA-256
|
||||
============================================================ */
|
||||
|
||||
function hashText_(text) {
|
||||
|
||||
const bytes =
|
||||
Utilities.computeDigest(
|
||||
Utilities.DigestAlgorithm.SHA_256,
|
||||
String(
|
||||
text
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
return bytes
|
||||
.map(
|
||||
function(byte) {
|
||||
|
||||
return (
|
||||
'0' +
|
||||
(
|
||||
(
|
||||
byte < 0
|
||||
? byte + 256
|
||||
: byte
|
||||
)
|
||||
.toString(
|
||||
16
|
||||
)
|
||||
)
|
||||
).slice(
|
||||
-2
|
||||
);
|
||||
|
||||
}
|
||||
)
|
||||
.join(
|
||||
''
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
PASSWORD HASH
|
||||
|
||||
POZOR:
|
||||
Toto je původní algoritmus.
|
||||
Neměníme jej, aby fungovala současná hesla.
|
||||
============================================================ */
|
||||
|
||||
function passwordHash_(
|
||||
password,
|
||||
salt
|
||||
) {
|
||||
|
||||
const pepper =
|
||||
PropertiesService
|
||||
.getScriptProperties()
|
||||
.getProperty(
|
||||
'PASSWORD_PEPPER'
|
||||
) ||
|
||||
'';
|
||||
|
||||
|
||||
let value =
|
||||
String(
|
||||
password
|
||||
) +
|
||||
'|' +
|
||||
salt +
|
||||
'|' +
|
||||
pepper;
|
||||
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < CFG.PASSWORD_ROUNDS;
|
||||
i++
|
||||
) {
|
||||
|
||||
value =
|
||||
hashText_(
|
||||
value +
|
||||
'|' +
|
||||
i
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
return value;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
VALIDACE HESLA
|
||||
============================================================ */
|
||||
|
||||
function validatePassword_(password) {
|
||||
|
||||
const value =
|
||||
String(
|
||||
password ||
|
||||
''
|
||||
);
|
||||
|
||||
|
||||
if (
|
||||
value.length <
|
||||
10
|
||||
) {
|
||||
|
||||
throw new Error(
|
||||
'Heslo musí mít alespoň 10 znaků.'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
!/[A-Za-z]/.test(
|
||||
value
|
||||
) ||
|
||||
!/[0-9]/.test(
|
||||
value
|
||||
)
|
||||
) {
|
||||
|
||||
throw new Error(
|
||||
'Heslo musí obsahovat písmeno a číslo.'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
OVĚŘENÍ UŽIVATELE
|
||||
|
||||
Optimalizace:
|
||||
už při každém requestu NEZAPISUJEME last_seen_at.
|
||||
============================================================ */
|
||||
|
||||
function requireUser_(
|
||||
token,
|
||||
roles
|
||||
) {
|
||||
|
||||
const tokenHash =
|
||||
hashText_(
|
||||
String(
|
||||
token ||
|
||||
''
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
const session =
|
||||
findOne_(
|
||||
CFG.SHEETS.SESSIONS,
|
||||
function(row) {
|
||||
|
||||
return (
|
||||
|
||||
String(
|
||||
row.token_hash
|
||||
) ===
|
||||
tokenHash &&
|
||||
|
||||
new Date(
|
||||
row.expires_at
|
||||
).getTime() >
|
||||
Date.now()
|
||||
|
||||
);
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
if (
|
||||
!session
|
||||
) {
|
||||
|
||||
throw new Error(
|
||||
'Přihlášení vypršelo. Přihlas se znovu.'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
const user =
|
||||
findOne_(
|
||||
CFG.SHEETS.USERS,
|
||||
function(row) {
|
||||
|
||||
return (
|
||||
String(
|
||||
row.user_id
|
||||
) ===
|
||||
String(
|
||||
session.user_id
|
||||
)
|
||||
);
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
if (
|
||||
!user ||
|
||||
!(
|
||||
user.active ===
|
||||
true ||
|
||||
|
||||
String(
|
||||
user.active
|
||||
).toUpperCase() ===
|
||||
'TRUE'
|
||||
)
|
||||
) {
|
||||
|
||||
throw new Error(
|
||||
'Účet není aktivní.'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
if (
|
||||
roles &&
|
||||
roles.length &&
|
||||
!roles.includes(
|
||||
String(
|
||||
user.role
|
||||
)
|
||||
)
|
||||
) {
|
||||
|
||||
throw new Error(
|
||||
'Nemáš oprávnění.'
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
* PŮVODNĚ ZDE BYLO:
|
||||
*
|
||||
* updateBy_(
|
||||
* CFG.SHEETS.SESSIONS,
|
||||
* 'session_id',
|
||||
* session.session_id,
|
||||
* {last_seen_at:now_()}
|
||||
* );
|
||||
*
|
||||
* To způsobovalo zápis do Google Sheets při téměř
|
||||
* každém kliknutí v aplikaci.
|
||||
*/
|
||||
|
||||
|
||||
return user;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ============================================================
|
||||
AUDIT
|
||||
============================================================ */
|
||||
|
||||
function audit_(
|
||||
userId,
|
||||
action,
|
||||
entityType,
|
||||
entityId,
|
||||
oldValue,
|
||||
newValue
|
||||
) {
|
||||
|
||||
append_(
|
||||
CFG.SHEETS.AUDIT_LOG,
|
||||
{
|
||||
|
||||
audit_id:
|
||||
uuid_(
|
||||
'AUD'
|
||||
),
|
||||
|
||||
user_id:
|
||||
userId ||
|
||||
'SYSTEM',
|
||||
|
||||
action:
|
||||
action,
|
||||
|
||||
entity_type:
|
||||
entityType ||
|
||||
'',
|
||||
|
||||
entity_id:
|
||||
entityId ||
|
||||
'',
|
||||
|
||||
old_value:
|
||||
json_(
|
||||
oldValue
|
||||
),
|
||||
|
||||
new_value:
|
||||
json_(
|
||||
newValue
|
||||
),
|
||||
|
||||
created_at:
|
||||
now_()
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user