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.
1105 lines
15 KiB
JavaScript
1105 lines
15 KiB
JavaScript
/* ============================================================
|
||
EATME PORTÁL – AUTH SERVICE
|
||
============================================================ */
|
||
|
||
|
||
/* ============================================================
|
||
AKTIVAČNÍ KÓD
|
||
============================================================ */
|
||
|
||
function requestActivation(email) {
|
||
|
||
email =
|
||
emailNorm_(
|
||
email
|
||
);
|
||
|
||
|
||
const user =
|
||
findOne_(
|
||
CFG.SHEETS.USERS,
|
||
function(row) {
|
||
|
||
return (
|
||
emailNorm_(
|
||
row.email
|
||
) ===
|
||
email
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!user ||
|
||
!(
|
||
user.active === true ||
|
||
String(
|
||
user.active
|
||
).toUpperCase() ===
|
||
'TRUE'
|
||
)
|
||
) {
|
||
|
||
/*
|
||
* Neprozrazujeme, zda účet existuje.
|
||
*/
|
||
|
||
return {
|
||
ok:true
|
||
};
|
||
|
||
}
|
||
|
||
|
||
const code =
|
||
randomCode_();
|
||
|
||
|
||
append_(
|
||
CFG.SHEETS.LOGIN_CODES,
|
||
{
|
||
|
||
code_id:
|
||
uuid_(
|
||
'CODE'
|
||
),
|
||
|
||
email:
|
||
email,
|
||
|
||
purpose:
|
||
'ACTIVATE',
|
||
|
||
code_hash:
|
||
hashText_(
|
||
code
|
||
),
|
||
|
||
expires_at:
|
||
new Date(
|
||
Date.now() +
|
||
CFG.LOGIN_CODE_MINUTES *
|
||
60000
|
||
),
|
||
|
||
used_at:
|
||
'',
|
||
|
||
created_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
|
||
MailApp.sendEmail(
|
||
{
|
||
|
||
to:
|
||
email,
|
||
|
||
subject:
|
||
CFG.APP_NAME +
|
||
' – aktivace účtu',
|
||
|
||
body:
|
||
'Váš aktivační kód je: ' +
|
||
code +
|
||
'\nPlatí ' +
|
||
CFG.LOGIN_CODE_MINUTES +
|
||
' minut.',
|
||
|
||
htmlBody:
|
||
'<p>Váš aktivační kód:</p>' +
|
||
'<p style="font-size:28px;font-weight:bold;letter-spacing:4px">' +
|
||
code +
|
||
'</p>' +
|
||
'<p>Platí ' +
|
||
CFG.LOGIN_CODE_MINUTES +
|
||
' minut.</p>',
|
||
|
||
name:
|
||
CFG.APP_NAME
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return {
|
||
ok:true
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
AKTIVACE ÚČTU
|
||
============================================================ */
|
||
|
||
function activateAccount(
|
||
email,
|
||
code,
|
||
password
|
||
) {
|
||
|
||
email =
|
||
emailNorm_(
|
||
email
|
||
);
|
||
|
||
|
||
validatePassword_(
|
||
password
|
||
);
|
||
|
||
|
||
const user =
|
||
findOne_(
|
||
CFG.SHEETS.USERS,
|
||
function(row) {
|
||
|
||
return (
|
||
emailNorm_(
|
||
row.email
|
||
) ===
|
||
email
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!user
|
||
) {
|
||
|
||
throw new Error(
|
||
'Aktivaci nelze dokončit.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const candidates =
|
||
rows_(
|
||
CFG.SHEETS.LOGIN_CODES
|
||
)
|
||
.filter(
|
||
function(row) {
|
||
|
||
return (
|
||
emailNorm_(
|
||
row.email
|
||
) ===
|
||
email &&
|
||
|
||
String(
|
||
row.purpose
|
||
) ===
|
||
'ACTIVATE' &&
|
||
|
||
!row.used_at
|
||
);
|
||
|
||
}
|
||
)
|
||
.sort(
|
||
function(a,b) {
|
||
|
||
return (
|
||
new Date(
|
||
b.created_at
|
||
) -
|
||
new Date(
|
||
a.created_at
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const record =
|
||
candidates[0];
|
||
|
||
|
||
if (
|
||
!record ||
|
||
new Date(
|
||
record.expires_at
|
||
).getTime() <
|
||
Date.now() ||
|
||
String(
|
||
record.code_hash
|
||
) !==
|
||
hashText_(
|
||
code
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Kód je neplatný nebo vypršel.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const salt =
|
||
Utilities
|
||
.getUuid()
|
||
.replace(
|
||
/-/g,
|
||
''
|
||
);
|
||
|
||
|
||
updateBy_(
|
||
CFG.SHEETS.USERS,
|
||
'user_id',
|
||
user.user_id,
|
||
{
|
||
|
||
password_salt:
|
||
salt,
|
||
|
||
password_hash:
|
||
passwordHash_(
|
||
password,
|
||
salt
|
||
)
|
||
|
||
}
|
||
);
|
||
|
||
|
||
updateBy_(
|
||
CFG.SHEETS.LOGIN_CODES,
|
||
'code_id',
|
||
record.code_id,
|
||
{
|
||
|
||
used_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'ACCOUNT_ACTIVATED',
|
||
'USER',
|
||
user.user_id,
|
||
'',
|
||
''
|
||
);
|
||
|
||
|
||
return login(
|
||
email,
|
||
password
|
||
);
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ZAPOMENUTÉ HESLO – POSLAT KÓD
|
||
|
||
Odpověď je vždy stejná. Neprozrazujeme existenci účtu.
|
||
============================================================ */
|
||
|
||
function requestPasswordReset(email) {
|
||
|
||
email =
|
||
emailNorm_(
|
||
email
|
||
);
|
||
|
||
|
||
const genericResult =
|
||
{
|
||
ok:true
|
||
};
|
||
|
||
|
||
if (
|
||
!email
|
||
) {
|
||
|
||
return genericResult;
|
||
|
||
}
|
||
|
||
|
||
const user =
|
||
findOne_(
|
||
CFG.SHEETS.USERS,
|
||
function(row) {
|
||
|
||
return (
|
||
emailNorm_(
|
||
row.email
|
||
) ===
|
||
email
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!user ||
|
||
!(
|
||
user.active === true ||
|
||
String(
|
||
user.active
|
||
).toUpperCase() ===
|
||
'TRUE'
|
||
)
|
||
) {
|
||
|
||
return genericResult;
|
||
|
||
}
|
||
|
||
|
||
const code =
|
||
randomCode_();
|
||
|
||
|
||
append_(
|
||
CFG.SHEETS.LOGIN_CODES,
|
||
{
|
||
|
||
code_id:
|
||
uuid_(
|
||
'CODE'
|
||
),
|
||
|
||
email:
|
||
email,
|
||
|
||
purpose:
|
||
'RESET_PASSWORD',
|
||
|
||
code_hash:
|
||
hashText_(
|
||
code
|
||
),
|
||
|
||
expires_at:
|
||
new Date(
|
||
Date.now() +
|
||
CFG.LOGIN_CODE_MINUTES *
|
||
60000
|
||
),
|
||
|
||
used_at:
|
||
'',
|
||
|
||
created_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
|
||
MailApp.sendEmail(
|
||
{
|
||
|
||
to:
|
||
email,
|
||
|
||
subject:
|
||
CFG.APP_NAME +
|
||
' – obnovení hesla',
|
||
|
||
body:
|
||
'Obdrželi jsme žádost o změnu hesla k účtu ' +
|
||
CFG.APP_NAME +
|
||
'.\n\nOvěřovací kód: ' +
|
||
code +
|
||
'\n\nKód platí ' +
|
||
CFG.LOGIN_CODE_MINUTES +
|
||
' minut.\n\nPokud jste o změnu hesla nežádali, tento e-mail ignorujte.',
|
||
|
||
htmlBody:
|
||
'<p>Obdrželi jsme žádost o změnu hesla k účtu <strong>' +
|
||
CFG.APP_NAME +
|
||
'</strong>.</p>' +
|
||
|
||
'<p>Ověřovací kód:</p>' +
|
||
|
||
'<p style="font-size:28px;font-weight:bold;letter-spacing:4px">' +
|
||
code +
|
||
'</p>' +
|
||
|
||
'<p>Kód platí ' +
|
||
CFG.LOGIN_CODE_MINUTES +
|
||
' minut.</p>' +
|
||
|
||
'<p style="color:#666">Pokud jste o změnu hesla nežádali, tento e-mail ignorujte.</p>',
|
||
|
||
name:
|
||
CFG.APP_NAME
|
||
|
||
}
|
||
);
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'PASSWORD_RESET_REQUESTED',
|
||
'USER',
|
||
user.user_id,
|
||
'',
|
||
''
|
||
);
|
||
|
||
|
||
return genericResult;
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
ZAPOMENUTÉ HESLO – NASTAVIT NOVÉ HESLO
|
||
============================================================ */
|
||
|
||
function resetPassword(
|
||
email,
|
||
code,
|
||
newPassword
|
||
) {
|
||
|
||
email =
|
||
emailNorm_(
|
||
email
|
||
);
|
||
|
||
|
||
validatePassword_(
|
||
newPassword
|
||
);
|
||
|
||
|
||
const user =
|
||
findOne_(
|
||
CFG.SHEETS.USERS,
|
||
function(row) {
|
||
|
||
return (
|
||
emailNorm_(
|
||
row.email
|
||
) ===
|
||
email
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/*
|
||
* Úmyslně používáme stejnou chybu pro neexistující účet
|
||
* i neplatný kód.
|
||
*/
|
||
|
||
if (
|
||
!user ||
|
||
!(
|
||
user.active === true ||
|
||
String(
|
||
user.active
|
||
).toUpperCase() ===
|
||
'TRUE'
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Kód je neplatný nebo vypršel.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const candidates =
|
||
rows_(
|
||
CFG.SHEETS.LOGIN_CODES
|
||
)
|
||
.filter(
|
||
function(row) {
|
||
|
||
return (
|
||
emailNorm_(
|
||
row.email
|
||
) ===
|
||
email &&
|
||
|
||
String(
|
||
row.purpose
|
||
) ===
|
||
'RESET_PASSWORD' &&
|
||
|
||
!row.used_at
|
||
);
|
||
|
||
}
|
||
)
|
||
.sort(
|
||
function(a,b) {
|
||
|
||
return (
|
||
new Date(
|
||
b.created_at
|
||
) -
|
||
new Date(
|
||
a.created_at
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
const record =
|
||
candidates[0];
|
||
|
||
|
||
if (
|
||
!record ||
|
||
new Date(
|
||
record.expires_at
|
||
).getTime() <
|
||
Date.now() ||
|
||
String(
|
||
record.code_hash
|
||
) !==
|
||
hashText_(
|
||
code
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Kód je neplatný nebo vypršel.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
/*
|
||
* Nový salt. Používáme stejný passwordHash_ jako dosud,
|
||
* takže se nerozbije současná autentizace.
|
||
*/
|
||
|
||
const salt =
|
||
Utilities
|
||
.getUuid()
|
||
.replace(
|
||
/-/g,
|
||
''
|
||
);
|
||
|
||
|
||
updateBy_(
|
||
CFG.SHEETS.USERS,
|
||
'user_id',
|
||
user.user_id,
|
||
{
|
||
|
||
password_salt:
|
||
salt,
|
||
|
||
password_hash:
|
||
passwordHash_(
|
||
newPassword,
|
||
salt
|
||
)
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/*
|
||
* Spotřebujeme všechny dosud platné RESET_PASSWORD kódy
|
||
* pro tento e-mail, ne pouze poslední.
|
||
*/
|
||
|
||
candidates.forEach(
|
||
function(candidate) {
|
||
|
||
updateBy_(
|
||
CFG.SHEETS.LOGIN_CODES,
|
||
'code_id',
|
||
candidate.code_id,
|
||
{
|
||
|
||
used_at:
|
||
now_()
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
/*
|
||
* Zneplatníme VŠECHNY existující sessions uživatele.
|
||
* Po resetu se musí všechna zařízení přihlásit znovu.
|
||
*/
|
||
|
||
rows_(
|
||
CFG.SHEETS.SESSIONS
|
||
)
|
||
.filter(
|
||
function(session) {
|
||
|
||
return (
|
||
String(
|
||
session.user_id
|
||
) ===
|
||
String(
|
||
user.user_id
|
||
)
|
||
);
|
||
|
||
}
|
||
)
|
||
.forEach(
|
||
function(session) {
|
||
|
||
updateBy_(
|
||
CFG.SHEETS.SESSIONS,
|
||
'session_id',
|
||
session.session_id,
|
||
{
|
||
|
||
expires_at:
|
||
new Date(
|
||
0
|
||
)
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
audit_(
|
||
user.user_id,
|
||
'PASSWORD_RESET_COMPLETED',
|
||
'USER',
|
||
user.user_id,
|
||
'',
|
||
''
|
||
);
|
||
|
||
|
||
/*
|
||
* Informační e-mail po změně hesla.
|
||
*/
|
||
|
||
try {
|
||
|
||
MailApp.sendEmail(
|
||
{
|
||
|
||
to:
|
||
email,
|
||
|
||
subject:
|
||
CFG.APP_NAME +
|
||
' – heslo bylo změněno',
|
||
|
||
body:
|
||
'Heslo k vašemu účtu ' +
|
||
CFG.APP_NAME +
|
||
' bylo právě změněno.\n\nVšechny předchozí relace byly odhlášeny.\n\nPokud jste tuto změnu neprovedli vy, kontaktujte administrátora.',
|
||
|
||
htmlBody:
|
||
'<p>Heslo k vašemu účtu <strong>' +
|
||
CFG.APP_NAME +
|
||
'</strong> bylo právě změněno.</p>' +
|
||
|
||
'<p>Všechny předchozí relace byly odhlášeny.</p>' +
|
||
|
||
'<p><strong>Pokud jste tuto změnu neprovedli vy, kontaktujte administrátora.</strong></p>',
|
||
|
||
name:
|
||
CFG.APP_NAME
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
catch(error) {
|
||
|
||
/*
|
||
* Selhání informačního e-mailu nesmí vrátit zpět
|
||
* už úspěšně změněné heslo.
|
||
*/
|
||
|
||
console.error(
|
||
'PASSWORD RESET CONFIRMATION EMAIL ERROR:',
|
||
error
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return {
|
||
ok:true
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
LOGIN
|
||
============================================================ */
|
||
|
||
function login(
|
||
email,
|
||
password
|
||
) {
|
||
|
||
email =
|
||
emailNorm_(
|
||
email
|
||
);
|
||
|
||
|
||
const user =
|
||
findOne_(
|
||
CFG.SHEETS.USERS,
|
||
function(row) {
|
||
|
||
return (
|
||
emailNorm_(
|
||
row.email
|
||
) ===
|
||
email
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
!user ||
|
||
!user.password_salt ||
|
||
!user.password_hash
|
||
) {
|
||
|
||
throw new Error(
|
||
'Neplatný e-mail nebo heslo.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const calculatedHash =
|
||
passwordHash_(
|
||
password,
|
||
user.password_salt
|
||
);
|
||
|
||
|
||
if (
|
||
calculatedHash !==
|
||
String(
|
||
user.password_hash
|
||
)
|
||
) {
|
||
|
||
throw new Error(
|
||
'Neplatný e-mail nebo heslo.'
|
||
);
|
||
|
||
}
|
||
|
||
|
||
let employee =
|
||
null;
|
||
|
||
|
||
if (
|
||
user.employee_id
|
||
) {
|
||
|
||
employee =
|
||
findOne_(
|
||
CFG.SHEETS.EMPLOYEES,
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.employee_id
|
||
) ===
|
||
String(
|
||
user.employee_id
|
||
)
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
const token =
|
||
randomToken_();
|
||
|
||
|
||
const loginTime =
|
||
now_();
|
||
|
||
|
||
append_(
|
||
CFG.SHEETS.SESSIONS,
|
||
{
|
||
|
||
session_id:
|
||
uuid_(
|
||
'SES'
|
||
),
|
||
|
||
user_id:
|
||
user.user_id,
|
||
|
||
token_hash:
|
||
hashText_(
|
||
token
|
||
),
|
||
|
||
expires_at:
|
||
new Date(
|
||
Date.now() +
|
||
CFG.SESSION_DAYS *
|
||
86400000
|
||
),
|
||
|
||
created_at:
|
||
loginTime,
|
||
|
||
last_seen_at:
|
||
loginTime
|
||
|
||
}
|
||
);
|
||
|
||
|
||
updateBy_(
|
||
CFG.SHEETS.USERS,
|
||
'user_id',
|
||
user.user_id,
|
||
{
|
||
|
||
last_login:
|
||
loginTime
|
||
|
||
}
|
||
);
|
||
|
||
|
||
return {
|
||
|
||
ok:
|
||
true,
|
||
|
||
token:
|
||
token,
|
||
|
||
role:
|
||
user.role,
|
||
|
||
user_id:
|
||
user.user_id,
|
||
|
||
user: {
|
||
|
||
user_id:
|
||
user.user_id,
|
||
|
||
email:
|
||
user.email,
|
||
|
||
role:
|
||
user.role,
|
||
|
||
employee:
|
||
employee
|
||
? {
|
||
|
||
employee_id:
|
||
employee.employee_id,
|
||
|
||
first_name:
|
||
employee.first_name,
|
||
|
||
last_name:
|
||
employee.last_name,
|
||
|
||
company_id:
|
||
employee.company_id,
|
||
|
||
location_id:
|
||
employee.location_id,
|
||
|
||
position:
|
||
employee.position
|
||
|
||
}
|
||
: null
|
||
|
||
}
|
||
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
LOGOUT
|
||
============================================================ */
|
||
|
||
function logout(token) {
|
||
|
||
const tokenHash =
|
||
hashText_(
|
||
String(
|
||
token ||
|
||
''
|
||
)
|
||
);
|
||
|
||
|
||
const session =
|
||
findOne_(
|
||
CFG.SHEETS.SESSIONS,
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.token_hash
|
||
) ===
|
||
tokenHash
|
||
);
|
||
|
||
}
|
||
);
|
||
|
||
|
||
if (
|
||
session
|
||
) {
|
||
|
||
updateBy_(
|
||
CFG.SHEETS.SESSIONS,
|
||
'session_id',
|
||
session.session_id,
|
||
{
|
||
|
||
expires_at:
|
||
new Date(
|
||
0
|
||
)
|
||
|
||
}
|
||
);
|
||
|
||
}
|
||
|
||
|
||
return {
|
||
ok:true
|
||
};
|
||
|
||
}
|
||
|
||
|
||
|
||
/* ============================================================
|
||
AKTUÁLNÍ UŽIVATEL
|
||
============================================================ */
|
||
|
||
function me(token) {
|
||
|
||
const user =
|
||
requireUser_(
|
||
token
|
||
);
|
||
|
||
|
||
const employee =
|
||
user.employee_id
|
||
|
||
? findOne_(
|
||
CFG.SHEETS.EMPLOYEES,
|
||
function(row) {
|
||
|
||
return (
|
||
String(
|
||
row.employee_id
|
||
) ===
|
||
String(
|
||
user.employee_id
|
||
)
|
||
);
|
||
|
||
}
|
||
)
|
||
|
||
: null;
|
||
|
||
|
||
return {
|
||
|
||
user_id:
|
||
user.user_id,
|
||
|
||
email:
|
||
user.email,
|
||
|
||
role:
|
||
user.role,
|
||
|
||
employee:
|
||
employee
|
||
? {
|
||
|
||
employee_id:
|
||
employee.employee_id,
|
||
|
||
first_name:
|
||
employee.first_name,
|
||
|
||
last_name:
|
||
employee.last_name,
|
||
|
||
company_id:
|
||
employee.company_id,
|
||
|
||
location_id:
|
||
employee.location_id,
|
||
|
||
position:
|
||
employee.position
|
||
|
||
}
|
||
: null
|
||
|
||
};
|
||
|
||
}
|