Files
eatme/gscript/AuthService.js
Michal Pemcak f917ed06a8 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.
2026-08-16 18:41:11 +02:00

1105 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ============================================================
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
};
}