Rename Director to Абделазиз, per-user passwords, profile modal

- Replace 'Директор' employee with 'Абделазиз' (director role).
- Seed unique simple temporary passwords for every employee in
  localStorage on first run. Existing sessions/usernames migrated.
- Login now requires a password for every employee.
- Add personal cabinet (modal) reachable via the user chip with a
  change-password form (current + new + confirm, min 4 chars).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
sultan 2026-05-25 09:07:27 +05:00
parent 46f21c2ee2
commit f51d1f12c1
3 changed files with 211 additions and 25 deletions

111
app.js
View File

@ -3,7 +3,7 @@
const STORAGE_KEY = 'sultan.tasks.v6'; const STORAGE_KEY = 'sultan.tasks.v6';
const USER_KEY = 'sultan.currentUser.v1'; const USER_KEY = 'sultan.currentUser.v1';
const SESSION_KEY = 'sultan.session.v1'; const SESSION_KEY = 'sultan.session.v1';
const DIRECTOR_PASSWORD = '1234'; const PASSWORDS_KEY = 'sultan.passwords.v1';
function getSession() { function getSession() {
try { try {
@ -13,7 +13,29 @@ function getSession() {
} }
function setSession(sess) { localStorage.setItem(SESSION_KEY, JSON.stringify(sess)); } function setSession(sess) { localStorage.setItem(SESSION_KEY, JSON.stringify(sess)); }
function clearSession() { localStorage.removeItem(SESSION_KEY); } function clearSession() { localStorage.removeItem(SESSION_KEY); }
function isDirector() { return state.currentUser === 'Директор'; } function isDirector() { return state.currentUser === 'Абделазиз'; }
function loadPasswords() {
try {
const raw = localStorage.getItem(PASSWORDS_KEY);
const stored = raw ? JSON.parse(raw) : {};
let mutated = false;
EMPLOYEES.forEach(u => {
if (!stored[u]) { stored[u] = DEFAULT_PASSWORDS[u] || 'temp-1111'; mutated = true; }
});
if (mutated) localStorage.setItem(PASSWORDS_KEY, JSON.stringify(stored));
return stored;
} catch {
localStorage.setItem(PASSWORDS_KEY, JSON.stringify(DEFAULT_PASSWORDS));
return { ...DEFAULT_PASSWORDS };
}
}
function getPassword(user) { return loadPasswords()[user]; }
function setPassword(user, pwd) {
const all = loadPasswords();
all[user] = pwd;
localStorage.setItem(PASSWORDS_KEY, JSON.stringify(all));
}
// Очистка старых версий ключей (миграция). // Очистка старых версий ключей (миграция).
(function purgeOldKeys() { (function purgeOldKeys() {
@ -28,7 +50,7 @@ function isDirector() { return state.currentUser === 'Директор'; }
})(); })();
const EMPLOYEES = [ const EMPLOYEES = [
'Директор', 'Абделазиз',
'Азат', 'Азат',
'Айгерим', 'Айгерим',
'Анар', 'Анар',
@ -39,6 +61,18 @@ const EMPLOYEES = [
'Ислам', 'Ислам',
]; ];
const DEFAULT_PASSWORDS = {
'Абделазиз': 'aZ-9012',
'Азат': 'az-1122',
'Айгерим': 'ay-3344',
'Анар': 'an-5566',
'Арайлым': 'ar-7788',
'Гайнель': 'ga-2233',
'Гаухар': 'gh-4455',
'Елена': 'el-6677',
'Ислам': 'is-8899',
};
const DIRECTIONS = [ const DIRECTIONS = [
'ДВГО', 'ДВГО',
'НПА', 'НПА',
@ -135,7 +169,7 @@ function seedTasks() {
title, title,
description: '', description: '',
assignee, assignee,
created_by: 'Директор', created_by: 'Абделазиз',
direction, direction,
priority: computePriority(deadline), priority: computePriority(deadline),
status: 'new', status: 'new',
@ -218,7 +252,7 @@ function renderTable(tasks) {
let visible = tasks.slice(); let visible = tasks.slice();
// Сотрудник видит только свои по умолчанию (если фильтр не задан явно). // Сотрудник видит только свои по умолчанию (если фильтр не задан явно).
if (state.currentUser !== 'Директор' && !f.assignee) { if (!isDirector() && !f.assignee) {
visible = visible.filter((t) => t.assignee === state.currentUser); visible = visible.filter((t) => t.assignee === state.currentUser);
} }
if (f.assignee) visible = visible.filter((t) => t.assignee === f.assignee); if (f.assignee) visible = visible.filter((t) => t.assignee === f.assignee);
@ -838,26 +872,23 @@ function applyRoute() {
// ---------- auth ---------- // ---------- auth ----------
function initAuth() { function initAuth() {
const overlay = $('#loginOverlay');
const sel = $('#loginUser'); const sel = $('#loginUser');
const pwdLabel = $('#loginPwdLabel'); const pwdLabel = $('#loginPwdLabel');
const pwd = $('#loginPwd'); const pwd = $('#loginPwd');
const err = $('#loginError'); const err = $('#loginError');
const form = $('#loginForm'); const form = $('#loginForm');
loadPasswords();
fillSelect(sel, EMPLOYEES); fillSelect(sel, EMPLOYEES);
sel.value = 'Директор'; sel.value = 'Абделазиз';
const togglePwd = () => { pwdLabel.hidden = false;
pwdLabel.hidden = sel.value !== 'Директор'; sel.addEventListener('change', () => { pwd.value = ''; err.hidden = true; pwd.focus(); });
if (pwdLabel.hidden) { pwd.value = ''; err.hidden = true; }
};
togglePwd();
sel.addEventListener('change', togglePwd);
form.addEventListener('submit', (e) => { form.addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
const user = sel.value; const user = sel.value;
if (user === 'Директор' && pwd.value !== DIRECTOR_PASSWORD) { if (pwd.value !== getPassword(user)) {
err.hidden = false; err.hidden = false;
pwd.focus(); pwd.focus();
return; return;
@ -868,8 +899,19 @@ function initAuth() {
}); });
$('#logoutBtn').addEventListener('click', logout); $('#logoutBtn').addEventListener('click', logout);
$('#profileBtn').addEventListener('click', openProfile);
$('#profileClose').addEventListener('click', closeProfile);
$('#profileBackdrop').addEventListener('click', closeProfile);
$('#profileForm').addEventListener('submit', submitChangePassword);
// Миграция старой сессии "Директор" → "Абделазиз"
let sess = getSession();
if (sess && sess.user === 'Директор') {
sess = { ...sess, user: 'Абделазиз' };
setSession(sess);
}
if (localStorage.getItem(USER_KEY) === 'Директор') localStorage.setItem(USER_KEY, 'Абделазиз');
const sess = getSession();
if (sess && sess.user && EMPLOYEES.includes(sess.user)) { if (sess && sess.user && EMPLOYEES.includes(sess.user)) {
applySession(sess.user); applySession(sess.user);
} else { } else {
@ -877,6 +919,45 @@ function initAuth() {
} }
} }
function openProfile() {
$('#profileName').textContent = state.currentUser || '—';
$('#profileRole').textContent = isDirector() ? 'Директор' : 'Сотрудник';
$('#profileForm').reset();
$('#profileError').hidden = true;
$('#profileSuccess').hidden = true;
$('#profileModal').hidden = false;
}
function closeProfile() { $('#profileModal').hidden = true; }
function submitChangePassword(e) {
e.preventDefault();
const cur = $('#pwdCurrent').value;
const next = $('#pwdNew').value;
const conf = $('#pwdConfirm').value;
const err = $('#profileError');
const ok = $('#profileSuccess');
ok.hidden = true;
err.hidden = true;
if (cur !== getPassword(state.currentUser)) {
err.textContent = 'Текущий пароль неверен.';
err.hidden = false;
return;
}
if (!next || next.length < 4) {
err.textContent = 'Новый пароль слишком короткий (минимум 4 символа).';
err.hidden = false;
return;
}
if (next !== conf) {
err.textContent = 'Подтверждение не совпадает.';
err.hidden = false;
return;
}
setPassword(state.currentUser, next);
$('#profileForm').reset();
ok.hidden = false;
}
function loginAs(user) { function loginAs(user) {
setSession({ user, ts: Date.now() }); setSession({ user, ts: Date.now() });
applySession(user); applySession(user);

View File

@ -11,7 +11,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
<link rel="stylesheet" href="styles.css?v=31" /> <link rel="stylesheet" href="styles.css?v=32" />
</head> </head>
<body> <body>
<header class="topbar"> <header class="topbar">
@ -55,17 +55,47 @@
</div> </div>
<div class="who"> <div class="who">
<div id="userChip" class="user-chip" hidden> <div id="userChip" class="user-chip" hidden>
<button id="profileBtn" class="user-chip-main" type="button" title="Профиль">
<span class="user-chip-avatar" id="userChipAvatar">—</span> <span class="user-chip-avatar" id="userChipAvatar">—</span>
<span class="user-chip-text"> <span class="user-chip-text">
<span class="user-chip-name" id="userChipName">—</span> <span class="user-chip-name" id="userChipName">—</span>
<span class="user-chip-role" id="userChipRole">—</span> <span class="user-chip-role" id="userChipRole">—</span>
</span> </span>
</button>
<button id="logoutBtn" class="btn btn-ghost" type="button" title="Выйти">Выйти</button> <button id="logoutBtn" class="btn btn-ghost" type="button" title="Выйти">Выйти</button>
</div> </div>
<button id="resetDemo" class="btn btn-ghost" title="Очистить данные и пересоздать демо">↺ Сброс демо</button> <button id="resetDemo" class="btn btn-ghost" title="Очистить данные и пересоздать демо">↺ Сброс демо</button>
</div> </div>
</header> </header>
<!-- Профиль / смена пароля -->
<div id="profileModal" class="modal" hidden>
<div class="modal-backdrop" id="profileBackdrop"></div>
<div class="modal-card" role="dialog" aria-modal="true" aria-labelledby="profileTitle">
<button id="profileClose" class="modal-close" type="button" aria-label="Закрыть">×</button>
<h2 id="profileTitle" class="modal-title">Личный кабинет</h2>
<div class="profile-head">
<div class="profile-name" id="profileName">—</div>
<div class="profile-role" id="profileRole">—</div>
</div>
<h3 class="modal-sub">Смена пароля</h3>
<form id="profileForm" class="login-form">
<label>Текущий пароль
<input id="pwdCurrent" type="password" autocomplete="current-password" required />
</label>
<label>Новый пароль
<input id="pwdNew" type="password" autocomplete="new-password" minlength="4" required />
</label>
<label>Подтвердите новый
<input id="pwdConfirm" type="password" autocomplete="new-password" minlength="4" required />
</label>
<div id="profileError" class="login-error" hidden></div>
<div id="profileSuccess" class="login-success" hidden>Пароль обновлён.</div>
<button type="submit" class="btn btn-primary">Сохранить</button>
</form>
</div>
</div>
<!-- Логин --> <!-- Логин -->
<div id="loginOverlay" class="login-overlay" hidden> <div id="loginOverlay" class="login-overlay" hidden>
<div class="login-card"> <div class="login-card">
@ -86,7 +116,7 @@
</label> </label>
<div id="loginError" class="login-error" hidden>Неверный пароль.</div> <div id="loginError" class="login-error" hidden>Неверный пароль.</div>
<button type="submit" class="btn btn-primary">Войти</button> <button type="submit" class="btn btn-primary">Войти</button>
<p class="login-hint">Демо: пароль директора — <code>1234</code>. Остальные входят без пароля.</p> <p class="login-hint">Если забыли пароль — попросите Абделазиза сбросить через личный кабинет.</p>
</form> </form>
</div> </div>
</div> </div>
@ -262,6 +292,6 @@
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/ru.js"></script> <script src="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/l10n/ru.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script src="app.js?v=27"></script> <script src="app.js?v=28"></script>
</body> </body>
</html> </html>

View File

@ -215,11 +215,26 @@ body[data-role="employee"] .director-only { display: none !important; }
.user-chip { .user-chip {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
padding: 6px 8px 6px 6px; padding: 4px 6px 4px 4px;
background: var(--surface-2); background: var(--surface-2);
border-radius: var(--r-pill); border-radius: var(--r-pill);
} }
.user-chip-main {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 2px 8px 2px 2px;
background: transparent;
border: none;
border-radius: var(--r-pill);
cursor: pointer;
color: inherit;
font: inherit;
transition: background 0.15s ease;
}
.user-chip-main:hover { background: rgba(0,113,227,0.08); }
.user-chip-main:focus-visible { outline: 2px solid var(--kt-blue); outline-offset: 2px; }
.user-chip-avatar { .user-chip-avatar {
width: 30px; height: 30px; width: 30px; height: 30px;
border-radius: 50%; border-radius: 50%;
@ -305,6 +320,66 @@ body[data-role="employee"] .director-only { display: none !important; }
} }
.login-hint { font-size: 12px; color: var(--muted); margin: 4px 0 0 0; } .login-hint { font-size: 12px; color: var(--muted); margin: 4px 0 0 0; }
.login-hint code { background: var(--surface-2); padding: 1px 6px; border-radius: 4px; font-size: 12px; } .login-hint code { background: var(--surface-2); padding: 1px 6px; border-radius: 4px; font-size: 12px; }
.login-success {
font-size: 13px;
color: #1B7F3A;
padding: 8px 12px;
background: rgba(48, 209, 88, 0.12);
border-radius: var(--r-sm);
}
/* ---------- Modal ---------- */
.modal {
position: fixed;
inset: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.modal-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
}
.modal-card {
position: relative;
width: 100%;
max-width: 440px;
background: var(--surface);
border-radius: 18px;
padding: 30px 28px 28px;
box-shadow: 0 20px 60px rgba(0,0,0,0.18);
}
.modal-close {
position: absolute;
top: 12px;
right: 12px;
width: 32px; height: 32px;
border: none;
background: transparent;
font-size: 22px;
line-height: 1;
color: var(--muted);
cursor: pointer;
border-radius: 50%;
}
.modal-close:hover { background: var(--surface-2); color: var(--ink); }
.modal-title { margin: 0 0 18px 0; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
.modal-sub { margin: 18px 0 12px 0; font-size: 14px; font-weight: 600; color: var(--ink-2); text-transform: uppercase; letter-spacing: 0.04em; }
.profile-head {
padding: 14px 16px;
background: var(--surface-2);
border-radius: var(--r-md);
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.profile-name { font-size: 16px; font-weight: 600; color: var(--ink); }
.profile-role { font-size: 12px; color: var(--muted); padding: 4px 10px; background: var(--surface); border-radius: var(--r-pill); }
.who label { color: var(--muted); font-size: 12px; font-weight: 400; } .who label { color: var(--muted); font-size: 12px; font-weight: 400; }
.who select { .who select {
padding: 6px 28px 6px 12px; padding: 6px 28px 6px 12px;