diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 74b7179..77ac6bd 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -21,8 +21,8 @@ android { applicationId = "ru.checklists.app" minSdk = 26 targetSdk = 34 - versionCode = 16 - versionName = "1.10.0" + versionCode = 17 + versionName = "1.11.0" } signingConfigs { diff --git a/android/app/src/main/java/ru/checklists/app/data/Repository.kt b/android/app/src/main/java/ru/checklists/app/data/Repository.kt index 39861ff..c0da59f 100644 --- a/android/app/src/main/java/ru/checklists/app/data/Repository.kt +++ b/android/app/src/main/java/ru/checklists/app/data/Repository.kt @@ -103,6 +103,12 @@ class Repository( /** Сообщение о смене режима (показывается один раз). */ val modeChanged = MutableStateFlow(null) + /** Сменить свой пароль: другие сессии завершаются, это устройство получает новые токены. */ + suspend fun changePassword(old: String, new: String) { + val t = client.api().changePassword(ChangePasswordReq(old, new)) + prefs.access = t.accessToken; prefs.refresh = t.refreshToken + } + /** Выйти из демо (если включил сам или включил администратор) и вернуться к рабочим данным. */ suspend fun leaveDemo() { val t = client.api().demoLeave() diff --git a/android/app/src/main/java/ru/checklists/app/data/api/ApiClient.kt b/android/app/src/main/java/ru/checklists/app/data/api/ApiClient.kt index 166f0c5..3ff6072 100644 --- a/android/app/src/main/java/ru/checklists/app/data/api/ApiClient.kt +++ b/android/app/src/main/java/ru/checklists/app/data/api/ApiClient.kt @@ -28,6 +28,9 @@ interface Api { @POST("auth/login") suspend fun login(@Body b: LoginReq): TokenRes + @POST("auth/change-password") + suspend fun changePassword(@Body b: ChangePasswordReq): TokenRes + @POST("auth/logout") suspend fun logout(@Body b: RefreshReq) diff --git a/android/app/src/main/java/ru/checklists/app/data/api/Models.kt b/android/app/src/main/java/ru/checklists/app/data/api/Models.kt index 4959239..9e56002 100644 --- a/android/app/src/main/java/ru/checklists/app/data/api/Models.kt +++ b/android/app/src/main/java/ru/checklists/app/data/api/Models.kt @@ -6,6 +6,9 @@ import kotlinx.serialization.Serializable @Serializable data class LoginReq(val username: String, val password: String, val device: String = "android") +@Serializable +data class ChangePasswordReq(@SerialName("old_password") val oldPassword: String, @SerialName("new_password") val newPassword: String) + @Serializable data class RefreshReq(@SerialName("refresh_token") val refreshToken: String, val device: String = "android") diff --git a/android/app/src/main/java/ru/checklists/app/ui/LoginScreen.kt b/android/app/src/main/java/ru/checklists/app/ui/LoginScreen.kt index 22c1268..4a9a2e2 100644 --- a/android/app/src/main/java/ru/checklists/app/ui/LoginScreen.kt +++ b/android/app/src/main/java/ru/checklists/app/ui/LoginScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch import ru.checklists.app.data.api.humanMessage @@ -79,8 +78,7 @@ fun LoginScreen(onDone: () -> Unit) { OutlinedTextField(url, { url = it }, label = { Text("Адрес сервера") }, placeholder = { Text("https://checklists.example.ru") }, singleLine = true, modifier = Modifier.fillMaxWidth(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri)) OutlinedTextField(user, { user = it }, label = { Text("Логин") }, singleLine = true, modifier = Modifier.fillMaxWidth()) - OutlinedTextField(pass, { pass = it }, label = { Text("Пароль") }, singleLine = true, modifier = Modifier.fillMaxWidth(), - visualTransformation = PasswordVisualTransformation(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password)) + PasswordField(pass, { pass = it }, "Пароль") error?.let { Text(it, color = MaterialTheme.colorScheme.error) } Button(onClick = ::go, enabled = !busy && url.isNotBlank() && user.isNotBlank() && pass.isNotEmpty(), modifier = Modifier.fillMaxWidth()) { if (busy) CircularProgressIndicator(Modifier.size(20.dp), strokeWidth = 2.dp) else Text("Войти") diff --git a/android/app/src/main/java/ru/checklists/app/ui/PasswordField.kt b/android/app/src/main/java/ru/checklists/app/ui/PasswordField.kt new file mode 100644 index 0000000..272eaa1 --- /dev/null +++ b/android/app/src/main/java/ru/checklists/app/ui/PasswordField.kt @@ -0,0 +1,88 @@ +package ru.checklists.app.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import ru.checklists.app.data.api.humanMessage +import ru.checklists.app.repo + +private const val PASSWORD_MIN = 10 // как на сервере (app/schemas.py) + +/** Поле пароля с «глазиком»: показать или скрыть введённое. */ +@Composable +fun PasswordField(value: String, onChange: (String) -> Unit, label: String, modifier: Modifier = Modifier.fillMaxWidth(), supporting: String? = null) { + var show by remember { mutableStateOf(false) } + OutlinedTextField(value, onChange, label = { Text(label) }, singleLine = true, modifier = modifier, + visualTransformation = if (show) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + supportingText = supporting?.let { { Text(it) } }, + trailingIcon = { + val hint = if (show) "Скрыть пароль" else "Показать пароль" + IconButton({ show = !show }, Modifier.semantics { contentDescription = hint }) { Text(if (show) "🙈" else "👁") } + }) +} + +/** Смена своего пароля: текущий, новый и повтор. Другие сессии завершаются, это устройство остаётся в системе. */ +@Composable +fun ChangePasswordDialog(onDismiss: () -> Unit, onDone: () -> Unit) { + val repo = LocalContext.current.repo + val scope = rememberCoroutineScope() + var old by remember { mutableStateOf("") } + var new by remember { mutableStateOf("") } + var repeat by remember { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + var busy by remember { mutableStateOf(false) } + + fun validate(): String? = when { + old.isEmpty() -> "Введите текущий пароль" + new.length < PASSWORD_MIN -> "Новый пароль — не короче $PASSWORD_MIN символов" + new != repeat -> "Новые пароли не совпадают" + new == old -> "Новый пароль совпадает с текущим" + else -> null + } + + AlertDialog(onDismissRequest = { if (!busy) onDismiss() }, title = { Text("Смена пароля") }, text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + PasswordField(old, { old = it }, "Текущий пароль") + PasswordField(new, { new = it }, "Новый пароль", supporting = "Не короче $PASSWORD_MIN символов") + PasswordField(repeat, { repeat = it }, "Новый пароль ещё раз") + Text("После смены на других устройствах и на сайте нужно будет войти заново.", + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.secondary) + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, confirmButton = { + TextButton({ + error = validate() + if (error == null) { + busy = true + scope.launch { + try { repo.changePassword(old, new); onDone() } + catch (e: Throwable) { error = e.humanMessage() } + finally { busy = false } + } + } + }, enabled = !busy) { Text("Сменить") } + }, dismissButton = { TextButton(onDismiss, enabled = !busy) { Text("Отмена") } }) +} diff --git a/android/app/src/main/java/ru/checklists/app/ui/SettingsScreen.kt b/android/app/src/main/java/ru/checklists/app/ui/SettingsScreen.kt index 4c8e3f1..a2284bb 100644 --- a/android/app/src/main/java/ru/checklists/app/ui/SettingsScreen.kt +++ b/android/app/src/main/java/ru/checklists/app/ui/SettingsScreen.kt @@ -45,6 +45,8 @@ fun SettingsScreen(onLoggedOut: () -> Unit) { val error by repo.lastError.collectAsState() var confirmLogout by remember { mutableStateOf(false) } var unsynced by remember { mutableStateOf(0) } + var changePass by remember { mutableStateOf(false) } + val ctx = LocalContext.current Scaffold(topBar = { ScreenBar(title = { Text("Настройки") }) @@ -55,6 +57,7 @@ fun SettingsScreen(onLoggedOut: () -> Unit) { me?.position?.let { Text("${it.name} · " + if (me?.onShift == true) "на смене" else if (me?.onShift == false) "не на смене" else "") } if (me?.isAdmin == true) Text("Администратор") else if (me?.isModerator == true) Text("Модератор") if (me?.isStorekeeper == true) Text("Администратор склада") + if (me?.demoMode != true) OutlinedButton({ changePass = true }, Modifier.fillMaxWidth()) { Text("🔑 Сменить пароль") } Text("Сервер", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.secondary, modifier = Modifier.padding(top = 8.dp)) Text(repo.prefs.baseUrl) @@ -77,6 +80,11 @@ fun SettingsScreen(onLoggedOut: () -> Unit) { } } + if (changePass) ChangePasswordDialog(onDismiss = { changePass = false }) { + changePass = false + android.widget.Toast.makeText(ctx, "Пароль изменён", android.widget.Toast.LENGTH_LONG).show() + } + if (confirmLogout) AlertDialog( onDismissRequest = { confirmLogout = false }, title = { Text("Выйти из аккаунта?") }, diff --git a/app/assistant_kb.txt b/app/assistant_kb.txt index 641059b..93f30b7 100644 --- a/app/assistant_kb.txt +++ b/app/assistant_kb.txt @@ -45,6 +45,7 @@ - Не вижу наряд: наряд на должность виден только сотрудникам этой должности, которые сейчас на смене по своему графику; проверьте график в своей карточке. - График работы задаётся сотруднику (#/users → «Изменить» → «График работы»), не должности. Одну должность могут занимать сменщики: наряды и уведомления должности получает только тот, кто на смене. Без графика — всегда на смене. - Фото к пункту чек-листа или к заданию можно отметить «хранить более года» (в конструкторе чек-листа рядом с режимом фото, в форме задания) — такие фото не удаляются автоочисткой. Автоочистку (удалять фото старше N дней) включает администратор портала в #/server. +- Смена своего пароля: своя карточка (имя вверху меню, #/user/<свой id>) → блок «🔑 Пароль» → «Сменить пароль»; в приложении Android — «Настройки» → «Сменить пароль». Нужен текущий пароль; другие устройства выйдут из системы. Забытый пароль сбрасывает администратор или модератор в #/users. При входе значок 👁 показывает введённый пароль. - Мониторинг сервера — #/monitor (администратор портала): загрузка процессора, память, место на дисках. - Фильтр «Место» над списком оборудования #/equipment и кранов: показывает машины места и вложенных мест. - Мастер первичной настройки: «Завершить и проверить» открывает проверку готовности #/setup/done — что мешает работе и где поправить. diff --git a/app/static/app.js b/app/static/app.js index b49e85c..68f3e56 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -180,6 +180,19 @@ async function route() { } window.addEventListener("hashchange", route); +// поле пароля с «глазиком»: показать или скрыть введённое +function pwEye(input) { + const btn = h("button", { type: "button", class: "pweye", title: "Показать пароль", "aria-label": "Показать пароль" }, "👁"); + btn.onclick = () => { + const show = input.type === "password"; + input.type = show ? "text" : "password"; + btn.textContent = show ? "🙈" : "👁"; + btn.title = show ? "Скрыть пароль" : "Показать пароль"; + btn.setAttribute("aria-label", btn.title); + }; + return h("div", { class: "pwwrap" }, input, btn); +} + function loginPage() { $top.replaceChildren(); $banner.replaceChildren(); document.getElementById("appfoot")?.replaceChildren(); const u = h("input", { autofocus: true, autocomplete: "username" }), p = h("input", { type: "password", autocomplete: "current-password" }); @@ -205,7 +218,7 @@ function loginPage() { }).catch(() => {}); fetch(API + "/demo/status").then(r => r.json()).then(d => { if (d.active) note.style.display = ""; }).catch(() => {}); $app.replaceChildren(h("div", { class: "card login" }, h("h1", {}, "Вход"), note, - h("label", {}, "Логин"), u, h("div", { style: "height:10px" }), h("label", {}, "Пароль"), p, + h("label", {}, "Логин"), u, h("div", { style: "height:10px" }), h("label", {}, "Пароль"), pwEye(p), h("div", { style: "height:14px" }), h("button", { onclick: go }, "Войти"), h("div", { style: "margin-top:14px" }, apk), docs)); } diff --git a/app/static/m/m.css b/app/static/m/m.css index b0ee7e8..b9de532 100644 --- a/app/static/m/m.css +++ b/app/static/m/m.css @@ -81,3 +81,7 @@ body.menu-open #shade{display:block;position:fixed;inset:0;background:rgba(0,0,0 .fbm{opacity:.7;font-size:13px} .fbmodal{position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;z-index:200;padding:14px} .fbmodal .card{width:100%;max-width:520px}.fbmodal textarea{width:100%;margin-top:8px} + +/* поле пароля с «глазиком» */ +.pwwrap{position:relative}.pwwrap input{width:100%;padding-right:44px} +.pweye{position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:0;padding:4px 8px;font-size:18px;cursor:pointer;color:inherit;width:auto;margin:0} diff --git a/app/static/m/m.js b/app/static/m/m.js index cd84f06..75222e5 100644 --- a/app/static/m/m.js +++ b/app/static/m/m.js @@ -399,7 +399,7 @@ function renderDrawer() { h("a", { href: "#", class: route.page === "list" ? "on" : "", onclick: e => { e.preventDefault(); go("")(); } }, h("i", {}, "📋"), "Наряды"), h("a", { href: "#/s", class: route.page === "settings" ? "on" : "", onclick: e => { e.preventDefault(); go("#/s")(); } }, h("i", {}, "⚙"), "Настройки и синхронизация"), h("div", { class: "navh" }, "Портал (нужна связь)"), - web("#/work", "🎯", "Задачи: всё в одном месте"), web("#/tasks", "🗓", "Задачи и поручения"), web("#/equipment", "🔧", "Оборудование и инциденты"), web("#/faults", "⚠", "Неисправности"), web("#/map", "🗺", "Карта завода"), me.crane_map ? web("#/cranes", "🏗", "Краны") : null, me.crane_map ? web("#/map/cranes", "🗺", "Карта кранов") : null, + web("#/work", "🎯", "Задачи: всё в одном месте"), web("#/tasks", "🗓", "Задачи и поручения"), web("#/equipment", "🔧", "Оборудование и отказы"), web("#/faults", "⚠", "Неисправности"), web("#/map", "🗺", "Карта завода"), me.crane_map ? web("#/cranes", "🏗", "Краны") : null, me.crane_map ? web("#/map/cranes", "🗺", "Карта кранов") : null, web("#/my-tools", "🧰", "Мой инструмент"), web("#/requests", "🛒", "Заявки на инструмент"), store || eng ? web("#/inventory", "🏬", "Склады и учёт") : null, web("#/people", "👥", "Сотрудники"), web("#/user/" + me.id, "👤", "Моя карточка"), @@ -448,6 +448,19 @@ const badges = o => h("span", { class: "row", style: "gap:6px" }, h("span", { cl const progress = o => { const t = (o.items || []).length; return t ? [(o.items || []).filter(itemDone).length, t] : null; }; // ----- вход ----- +// поле пароля с «глазиком»: показать или скрыть введённое +function pwEye(input) { + const btn = h("button", { type: "button", class: "pweye", title: "Показать пароль", "aria-label": "Показать пароль" }, "👁"); + btn.onclick = () => { + const show = input.type === "password"; + input.type = show ? "text" : "password"; + btn.textContent = show ? "🙈" : "👁"; + btn.title = show ? "Скрыть пароль" : "Показать пароль"; + btn.setAttribute("aria-label", btn.title); + }; + return h("div", { class: "pwwrap" }, input, btn); +} + function loginView(my) { setBar(h("h1", {}, "Чек-листы")); const u = h("input", { autocomplete: "username", autocapitalize: "none", autocorrect: "off", placeholder: "Логин" }), p = h("input", { type: "password", autocomplete: "current-password", placeholder: "Пароль" }); @@ -472,7 +485,7 @@ function loginView(my) { const ios = /iPhone|iPad|iPod/i.test(navigator.userAgent) && !navigator.standalone; commit(my, h("div", { class: "login stack" }, h("h1", {}, "🔧 Чек-листы"), h("p", { class: "muted" }, "Обходы и обслуживание оборудования"), sync.error && hasSession() === false && sync.error.startsWith("Сессия") ? h("p", { class: "warn" }, sync.error) : null, - u, p, err, btn, apk, + u, pwEye(p), err, btn, apk, ios ? h("p", { class: "small muted" }, "Чтобы установить: нажмите «Поделиться» → «На экран Домой».") : null, h("p", { class: "small" }, h("a", { href: "/", onclick: () => localStorage.setItem("stay_desktop", "1") }, "Полная версия сайта")))); } diff --git a/app/static/portal.js b/app/static/portal.js index a7b7ab3..5f919c9 100644 --- a/app/static/portal.js +++ b/app/static/portal.js @@ -808,6 +808,29 @@ async function peoplePage() { draw(); } +// Смена своего пароля: старый, новый и повтор. Остальные сессии завершаются, эта получает новые токены. +function passwordCard() { + const inp = ac => h("input", { type: "password", autocomplete: ac }); + const old = inp("current-password"), nw = inp("new-password"), rep = inp("new-password"); + const body = h("div", { style: "display:none;margin-top:8px" }, + h("div", { class: "grid" }, field("Текущий пароль", pwEye(old)), field("Новый пароль", pwEye(nw), "Не короче 10 символов"), field("Новый пароль ещё раз", pwEye(rep))), + h("div", { class: "row", style: "margin-top:10px" }, + h("button", { onclick: guard(async () => { + if (!old.value) throw new Error("Введите текущий пароль"); + if (nw.value.length < 10) throw new Error("Новый пароль — не короче 10 символов"); + if (nw.value !== rep.value) throw new Error("Новые пароли не совпадают"); + if (nw.value === old.value) throw new Error("Новый пароль совпадает с текущим"); + const t = await api("POST", "/auth/change-password", { old_password: old.value, new_password: nw.value }); + token = t.access_token; localStorage.setItem("token", token); localStorage.setItem("refresh", t.refresh_token); + old.value = nw.value = rep.value = ""; body.style.display = "none"; + toast("Пароль изменён. На других устройствах войдите заново"); + }) }, "Сменить пароль"), + h("button", { class: "sec", onclick: () => { body.style.display = "none"; } }, "Отмена"))); + return h("div", { class: "card" }, h("div", { class: "row" }, h("h2", { style: "margin:0;flex:1" }, "🔑 Пароль"), + h("button", { class: "sec sm", onclick: () => { body.style.display = body.style.display === "none" ? "" : "none"; if (body.style.display === "") old.focus(); } }, "Сменить пароль")), + h("div", { class: "small muted", style: "margin-top:4px" }, "После смены пароля вход на других устройствах и в приложении нужно будет выполнить заново."), body); +} + async function userCardPage(id) { const c = await api("GET", `/users/${id}/card`); const u = c.user, reload = () => userCardPage(id); @@ -824,6 +847,7 @@ async function userCardPage(id) { u.department ? h("div", { style: "margin-top:2px" }, "🏢 ", h("a", { href: "#/people?dep=" + u.department.id }, u.department.name)) : null, u.position ? h("div", { class: "small", style: "margin-top:4px" }, u.on_shift ? "🟢 Сейчас на смене" : "⚪ Сейчас не на смене") : null, u.phone && !c.can_edit ? h("div", { style: "margin-top:6px" }, "📞 ", h("a", { href: "tel:" + u.phone.replace(/[^+\d]/g, "") }, u.phone)) : null)), + +id === me.id ? passwordCard() : null, h("div", { class: "cols" }, h("div", { class: "card" }, h("h2", { style: "margin-top:0" }, "График"), c.schedule.name ? h("div", { class: "small muted", style: "margin-bottom:6px" }, c.schedule.name) : null, diff --git a/app/static/style.css b/app/static/style.css index d537439..da60b88 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -306,3 +306,7 @@ button.fault{background:var(--fault);border-color:var(--fault);color:#fff} /* кнопка закрытия отказа и неисправности: крупная и заметная */ button.closebtn{background:#16a34a;border:1px solid #16a34a;color:#fff;font-size:16px;font-weight:700;padding:10px 20px;border-radius:10px} button.closebtn:hover{filter:brightness(1.08)} + +/* поле пароля с «глазиком» */ +.pwwrap{position:relative}.pwwrap input{width:100%;padding-right:44px} +.pweye{position:absolute;right:4px;top:50%;transform:translateY(-50%);background:transparent;border:0;padding:4px 8px;font-size:18px;cursor:pointer;color:inherit;width:auto;margin:0} diff --git a/docs/user-guide.pdf b/docs/user-guide.pdf index 95f3731..0d96d8f 100644 Binary files a/docs/user-guide.pdf and b/docs/user-guide.pdf differ diff --git a/release/README.md b/release/README.md index 91a8228..28dc60a 100644 --- a/release/README.md +++ b/release/README.md @@ -1,9 +1,9 @@ # Готовый APK -`checklists-1.10.0.apk` — подписанное release-приложение для Android (версия 1.10.0, versionCode 16; подписи v2+v3, только HTTPS). Сервер раздаёт его по `/app.apk` +`checklists-1.11.0.apk` — подписанное release-приложение для Android (версия 1.11.0, versionCode 17; подписи v2+v3, только HTTPS). Сервер раздаёт его по `/app.apk` и показывает ссылку на экране входа, если ничего не загружено в `DATA_DIR/apk/` (загруженный вручную файл важнее). -SHA-256: `b2c8d8152fa5c9b19eab34c9341ed574fb24d2c0e64294e8c4cc56644fbada14` +SHA-256: `49ed6928967ab665c7171ce64ed1491ab3d1245d03928c74fe2437630ab0db8e` Обновление: соберите новый APK (см. android/README.md), замените файл здесь, закоммитьте и передеплойте — либо просто положите новый файл в том `appdata` по пути `apk/` (без передеплоя). diff --git a/release/checklists-1.10.0.apk b/release/checklists-1.11.0.apk similarity index 72% rename from release/checklists-1.10.0.apk rename to release/checklists-1.11.0.apk index 30fefc7..64f52b3 100644 Binary files a/release/checklists-1.10.0.apk and b/release/checklists-1.11.0.apk differ diff --git a/tools/guide/build_pdf.py b/tools/guide/build_pdf.py index c477759..7fbc121 100644 --- a/tools/guide/build_pdf.py +++ b/tools/guide/build_pdf.py @@ -386,7 +386,8 @@ def build(): "он зададит новый.") pdf.h2("Вход на сайте") pdf.wide("d_login", "Страница входа", width=120) - pdf.p("Введите логин и пароль и нажмите «Войти». Если администратор загрузил приложение для Android, под кнопкой будет " + pdf.p("Введите логин и пароль и нажмите «Войти». Значок-глаз в поле пароля показывает введённое (ещё раз — скрывает): удобно " + "проверить пароль перед входом. Если администратор загрузил приложение для Android, под кнопкой будет " "ссылка для скачивания, а ниже — ссылки на **инструкцию** и **презентацию** системы. После нескольких неверных попыток " "подряд вход на 5 минут блокируется — подождите и повторите.") @@ -843,6 +844,11 @@ def build(): pdf.note("Фото, телефон и «что умею» заполняет сам сотрудник в своей карточке (нажмите своё имя вверху бокового меню); " "модератор и администратор тоже могут их изменить. Выданный инструмент коллеге не показывается — только самому " "сотруднику, складу и инженерам.", "info") + pdf.h2("Смена своего пароля") + pdf.p("Сменить свой пароль можно самому, без администратора. На сайте: нажмите своё имя вверху бокового меню → блок **«Пароль»** → " + "«Сменить пароль»: текущий пароль, новый (не короче 10 символов) и ещё раз новый. В приложении для Android: «Настройки» → " + "**«Сменить пароль»**. На этом устройстве вы останетесь в системе, а на остальных (и в приложении на телефоне) нужно будет " + "войти заново с новым паролем. Забытый пароль сбрасывает администратор или модератор."), # ================= 6 ================= pdf.h1("13. Администратор и модератор") @@ -1031,7 +1037,8 @@ def build(): "У одного из обслуживаемых узлов машины подходит или уже наступил срок обслуживания. Откройте карточку машины: узлы " "со статусом «Скоро» и «Требуется» показаны вверху списка."), ("Я забыл пароль.", - "Обратитесь к администратору или модератору: в разделе «Пользователи» он задаст новый пароль."), + "Обратитесь к администратору или модератору: в разделе «Пользователи» он задаст новый пароль. Затем смените его на свой: " + "своя карточка → «Пароль» (в приложении — «Настройки» → «Сменить пароль»)."), ("Время в нарядах отличается от времени на телефоне.", "Сервер использует часовой пояс организации. Сроки и отчёты показываются в нём; время на телефоне может отличаться, если у вас " "другой часовой пояс."),