Add login flow and director-only create button

Login overlay on first visit. Pick employee from list; director
requires password (demo: 1234). Session persists in localStorage.

The "Create task" menu item and form are gated to the director
role. Employees are redirected away from #/create. Top bar shows
a user chip with avatar, name, role, and logout button.

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

102
app.js
View File

@ -2,6 +2,18 @@
const STORAGE_KEY = 'sultan.tasks.v6';
const USER_KEY = 'sultan.currentUser.v1';
const SESSION_KEY = 'sultan.session.v1';
const DIRECTOR_PASSWORD = '1234';
function getSession() {
try {
const raw = localStorage.getItem(SESSION_KEY);
return raw ? JSON.parse(raw) : null;
} catch { return null; }
}
function setSession(sess) { localStorage.setItem(SESSION_KEY, JSON.stringify(sess)); }
function clearSession() { localStorage.removeItem(SESSION_KEY); }
function isDirector() { return state.currentUser === 'Директор'; }
// Очистка старых версий ключей (миграция).
(function purgeOldKeys() {
@ -166,7 +178,7 @@ function fillSelect(el, items, includeAll = false, allLabel = 'Все') {
// ---------- state ----------
const state = {
currentUser: localStorage.getItem(USER_KEY) || 'Директор',
currentUser: (getSession() && getSession().user) || null,
filters: { assignee: '', status: '', direction: '', priority: '', deadline: '' },
view: 'home',
};
@ -806,6 +818,10 @@ function applyRoute() {
let view = 'home';
if (hash === '#/create') view = 'create';
else if (hash === '#/analytics') view = 'analytics';
if (view === 'create' && !isDirector()) {
location.hash = '#/home';
return;
}
state.view = view;
document.body.dataset.view = view;
$('#view-home').hidden = view !== 'home';
@ -819,6 +835,79 @@ function applyRoute() {
if (view === 'home') startHomeWidgets();
}
// ---------- auth ----------
function initAuth() {
const overlay = $('#loginOverlay');
const sel = $('#loginUser');
const pwdLabel = $('#loginPwdLabel');
const pwd = $('#loginPwd');
const err = $('#loginError');
const form = $('#loginForm');
fillSelect(sel, EMPLOYEES);
sel.value = 'Директор';
const togglePwd = () => {
pwdLabel.hidden = sel.value !== 'Директор';
if (pwdLabel.hidden) { pwd.value = ''; err.hidden = true; }
};
togglePwd();
sel.addEventListener('change', togglePwd);
form.addEventListener('submit', (e) => {
e.preventDefault();
const user = sel.value;
if (user === 'Директор' && pwd.value !== DIRECTOR_PASSWORD) {
err.hidden = false;
pwd.focus();
return;
}
err.hidden = true;
pwd.value = '';
loginAs(user);
});
$('#logoutBtn').addEventListener('click', logout);
const sess = getSession();
if (sess && sess.user && EMPLOYEES.includes(sess.user)) {
applySession(sess.user);
} else {
showLogin();
}
}
function loginAs(user) {
setSession({ user, ts: Date.now() });
applySession(user);
}
function applySession(user) {
state.currentUser = user;
localStorage.setItem(USER_KEY, user);
document.body.dataset.role = (user === 'Директор') ? 'director' : 'employee';
$('#loginOverlay').hidden = true;
$('#userChip').hidden = false;
$('#userChipName').textContent = user;
$('#userChipRole').textContent = (user === 'Директор') ? 'Директор' : 'Сотрудник';
$('#userChipAvatar').textContent = user.slice(0, 1).toUpperCase();
if (!isDirector() && location.hash === '#/create') location.hash = '#/home';
applyRoute();
}
function showLogin() {
$('#loginOverlay').hidden = false;
$('#userChip').hidden = true;
document.body.dataset.role = '';
}
function logout() {
clearSession();
state.currentUser = null;
location.hash = '#/home';
showLogin();
}
function openMenu() {
$('#menuPanel').hidden = false;
$('#menuBtn').setAttribute('aria-expanded', 'true');
@ -844,14 +933,8 @@ function init() {
fillSelect($('#fAssignee'), EMPLOYEES, true, 'Все исполнители');
fillSelect($('#fDirection'), DIRECTIONS, true, 'Все направления');
// Текущий пользователь
fillSelect($('#currentUser'), EMPLOYEES);
$('#currentUser').value = state.currentUser;
$('#currentUser').addEventListener('change', (e) => {
state.currentUser = e.target.value;
localStorage.setItem(USER_KEY, state.currentUser);
renderAll();
});
// Auth
initAuth();
// Дедлайн — по умолчанию +3 дня. Flatpickr — большой красивый календарь.
const dl = new Date();
@ -874,6 +957,7 @@ function init() {
// Форма создания
$('#taskForm').addEventListener('submit', (e) => {
e.preventDefault();
if (!isDirector()) { location.hash = '#/home'; return; }
const fd = new FormData(e.target);
const tasks = getTasks();
const now = new Date().toISOString();

View File

@ -11,7 +11,7 @@
<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 rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr@4.6.13/dist/flatpickr.min.css">
<link rel="stylesheet" href="styles.css?v=30" />
<link rel="stylesheet" href="styles.css?v=31" />
</head>
<body>
<header class="topbar">
@ -28,7 +28,7 @@
<span class="menu-sub">Время, погода, новости отрасли</span>
</span>
</a>
<a href="#/create" class="menu-item" data-view="create" role="menuitem">
<a href="#/create" class="menu-item director-only" data-view="create" role="menuitem">
<span class="menu-icon">+</span>
<span class="menu-text">
<span class="menu-title">Создать поручение</span>
@ -54,12 +54,43 @@
</a>
</div>
<div class="who">
<label for="currentUser">Я работаю как</label>
<select id="currentUser"></select>
<div id="userChip" class="user-chip" hidden>
<span class="user-chip-avatar" id="userChipAvatar">—</span>
<span class="user-chip-text">
<span class="user-chip-name" id="userChipName">—</span>
<span class="user-chip-role" id="userChipRole">—</span>
</span>
<button id="logoutBtn" class="btn btn-ghost" type="button" title="Выйти">Выйти</button>
</div>
<button id="resetDemo" class="btn btn-ghost" title="Очистить данные и пересоздать демо">↺ Сброс демо</button>
</div>
</header>
<!-- Логин -->
<div id="loginOverlay" class="login-overlay" hidden>
<div class="login-card">
<div class="login-brand">
<img src="icon.jpg" alt="" class="login-logo" />
<div>
<div class="login-title">GR Department</div>
<div class="login-sub">Помощник руководителя · ДВГО</div>
</div>
</div>
<h1 class="login-h1">Вход</h1>
<form id="loginForm" class="login-form">
<label>Сотрудник
<select id="loginUser" required></select>
</label>
<label id="loginPwdLabel" hidden>Пароль
<input id="loginPwd" type="password" autocomplete="current-password" placeholder="Введите пароль" />
</label>
<div id="loginError" class="login-error" hidden>Неверный пароль.</div>
<button type="submit" class="btn btn-primary">Войти</button>
<p class="login-hint">Демо: пароль директора — <code>1234</code>. Остальные входят без пароля.</p>
</form>
</div>
</div>
<main>
<!-- Главная -->
@ -231,6 +262,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/l10n/ru.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=26"></script>
<script src="app.js?v=27"></script>
</body>
</html>

View File

@ -211,7 +211,100 @@ body {
.brand:hover .brand-mark-secondary { opacity: 1; }
.who { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
body[data-view="home"] .who { display: none; }
body[data-role="employee"] .director-only { display: none !important; }
.user-chip {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 6px 8px 6px 6px;
background: var(--surface-2);
border-radius: var(--r-pill);
}
.user-chip-avatar {
width: 30px; height: 30px;
border-radius: 50%;
background: linear-gradient(135deg, #4FB1F7, var(--kt-blue));
color: #fff;
font-size: 13px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.user-chip-text { display: flex; flex-direction: column; line-height: 1.15; }
.user-chip-name { font-size: 13px; font-weight: 600; color: var(--ink); }
.user-chip-role { font-size: 11px; color: var(--muted); }
.login-overlay {
position: fixed;
inset: 0;
background: linear-gradient(180deg, #F5F6F8 0%, #ECEFF4 100%);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.login-card {
width: 100%;
max-width: 420px;
background: var(--surface);
border-radius: 20px;
padding: 36px 32px;
box-shadow: 0 20px 60px rgba(0,0,0,0.08), 0 4px 10px rgba(0,0,0,0.04);
}
.login-brand {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 28px;
}
.login-logo {
width: 44px; height: 44px;
border-radius: 10px;
object-fit: cover;
}
.login-title { font-size: 15px; font-weight: 600; color: var(--ink); letter-spacing: -0.01em; }
.login-sub { font-size: 12px; color: var(--muted); margin-top: 2px; }
.login-h1 {
margin: 0 0 22px 0;
font-size: 28px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--ink);
}
.login-form { display: flex; flex-direction: column; gap: 14px; }
.login-form label { display: flex; flex-direction: column; gap: 6px; font-size: 13px; color: var(--muted); font-weight: 500; }
.login-form select, .login-form input {
padding: 12px 14px;
font-size: 15px;
border: 1px solid var(--border);
border-radius: var(--r-md);
background: var(--surface);
color: var(--ink);
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.login-form select:focus, .login-form input:focus {
border-color: var(--kt-blue);
box-shadow: 0 0 0 3px rgba(0,113,227,0.15);
}
.login-form .btn-primary {
margin-top: 6px;
padding: 12px 18px;
font-size: 15px;
font-weight: 600;
}
.login-error {
font-size: 13px;
color: #FF453A;
padding: 8px 12px;
background: rgba(255, 69, 58, 0.08);
border-radius: var(--r-sm);
}
.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; }
.who label { color: var(--muted); font-size: 12px; font-weight: 400; }
.who select {
padding: 6px 28px 6px 12px;