"""HTML-интерфейс сервиса: вход, регистрация, кабинет, админка.""" from __future__ import annotations import html import json STYLE = """ :root{--bg:#0f1116;--card:#171a22;--line:#262b36;--fg:#e8ebf2;--mut:#9aa3b2; --acc:#c69e2d;--acc2:#e0bd57;--ok:#3fb950;--err:#f85149} *{box-sizing:border-box} body{margin:0;font:15px/1.5 -apple-system,Segoe UI,Roboto,Arial,sans-serif; background:var(--bg);color:var(--fg)} header{padding:18px 28px;border-bottom:1px solid var(--line); display:flex;align-items:center;gap:14px;background:linear-gradient(180deg,#141822,#0f1116)} header h1{font-size:19px;margin:0;font-weight:700} header .dot{width:10px;height:10px;border-radius:50%;background:var(--acc)} header .spacer{margin-left:auto} header .who{color:var(--mut);font-size:13px} header a{color:var(--acc2);text-decoration:none;font-size:13px} main{max-width:1180px;margin:0 auto;padding:26px;display:grid; grid-template-columns:minmax(340px,440px) 1fr;gap:24px;align-items:start} main.single{grid-template-columns:1fr;max-width:920px} @media(max-width:920px){main{grid-template-columns:1fr}} .card{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:20px} .card h2{font-size:14px;text-transform:uppercase;letter-spacing:.08em; color:var(--mut);margin:0 0 16px;font-weight:600} label{display:block;font-size:13px;color:var(--mut);margin:12px 0 5px} input,select,textarea{width:100%;background:#0d1017;border:1px solid var(--line); color:var(--fg);border-radius:9px;padding:10px 12px;font:inherit;outline:none} input:focus,select:focus,textarea:focus{border-color:var(--acc)} textarea{resize:vertical;min-height:70px} .row{display:grid;grid-template-columns:1fr 1fr;gap:12px} button{cursor:pointer;border:none;border-radius:10px;padding:11px 16px;font:inherit; font-weight:600;transition:.15s} .btn-primary{background:var(--acc);color:#1a1500;width:100%;margin-top:16px} .btn-primary:hover{background:var(--acc2)} .btn-ghost{background:#20252f;color:var(--fg);border:1px solid var(--line); text-decoration:none;display:inline-block} .btn-ghost:hover{background:#262c38} .btn-sm{padding:6px 10px;font-size:13px;border-radius:8px} .btn-danger{background:#3a1d20;color:#ffb4b4;border:1px solid #5a2a2e} .actions{display:flex;gap:10px;margin-top:10px;flex-wrap:wrap} .linkbox{display:flex;gap:8px;margin:10px 0} .linkbox input{font-family:ui-monospace,Menlo,Consolas,monospace;font-size:13px} .preview{border-radius:12px;overflow:hidden;border:1px solid var(--line);background:#fff;margin-top:14px} .preview img{width:100%;display:block} table{width:100%;border-collapse:collapse;font-size:14px} th,td{text-align:left;padding:9px 8px;border-bottom:1px solid var(--line);white-space:nowrap} th{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.06em} td a{color:var(--acc2);text-decoration:none} .mono{font-family:ui-monospace,Menlo,Consolas,monospace} .muted{color:var(--mut)} .err{color:var(--err);font-size:13px;margin-top:10px} .notice{color:var(--ok);font-size:13px;margin-top:10px} .pill{display:inline-block;background:#20252f;border:1px solid var(--line); border-radius:20px;padding:2px 10px;font-size:12px;color:var(--mut)} .pill.admin{color:#ffd76a;border-color:#5a4a1e;background:#2a2412} .cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px} .doc{border:1px solid var(--line);border-radius:12px;overflow:hidden;background:#0d1017} .doc img{width:100%;display:block;background:#fff} .doc .meta{padding:10px 12px} .doc .num{font-family:ui-monospace,Menlo,Consolas,monospace;font-weight:700} .doc .fio{font-size:14px;margin:2px 0} .doc .links{display:flex;gap:10px;margin-top:8px;font-size:13px} .authwrap{max-width:420px;margin:8vh auto;padding:0 20px} .authwrap h1{font-size:22px;margin:0 0 6px} .authwrap .sub{color:var(--mut);margin:0 0 18px;font-size:14px} .switch{color:var(--mut);font-size:14px;margin-top:14px} .switch a{color:var(--acc2);text-decoration:none} """ AUTH_JS = r""" const $ = id => document.getElementById(id); const mode = window.AUTH_MODE; function setMsg(id, text){ const el = $(id); if (el) el.textContent = text; } async function submit(e){ e.preventDefault(); setMsg('err',''); setMsg('notice',''); const body = {email:$('email').value.trim(), password:$('password').value}; if(mode==='register'){ body.name = ($('name')?$('name').value.trim():''); } let r; try { r = await fetch('/api/auth/'+mode,{method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); } catch(err){ setMsg('err','Сервер недоступен. Попробуйте позже.'); return; } if(r.ok){ location.href='/'; return; } let msg = 'Ошибка'; try { msg = (await r.json()).error || msg; } catch(e){} setMsg('err', msg); } window.addEventListener('DOMContentLoaded',()=>{ $('form').addEventListener('submit',submit); }); """ DASH_JS = r""" const $ = id => document.getElementById(id); const U = window.CURRENT_USER, ADMIN = window.IS_ADMIN; async function api(path, opts){ const r = await fetch(path, opts); if(!r.ok){ let m='Ошибка'; try{ m=(await r.json()).error||m; }catch(e){} throw new Error(m); } const ct = r.headers.get('content-type')||''; return ct.includes('json') ? r.json() : r.blob(); } function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g, c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } async function loadTemplates(){ const sel=$('template'); if(!sel) return; const t = await api('/api/templates'); sel.innerHTML = t.map(x=>``).join(''); } function payload(){ const p={template:$('template').value, fio:$('fio').value.trim(), title:$('title').value.trim(), body:$('body').value.trim(), date:$('date').value.trim(), prefix:$('prefix').value.trim(), email:$('email').value.trim()}; const n=$('number').value.trim(); if(n) p.number=n; return p; } async function preview(){ $('err').textContent=''; try{ const blob = await api('/api/preview',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload())}); $('img').src = URL.createObjectURL(blob); $('result').classList.add('show'); }catch(e){ $('err').textContent=e.message; } } async function create(){ $('err').textContent=''; $('notice').textContent=''; try{ const c = await api('/api/certificates',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload())}); $('link').value=c.url; $('img').src=c.png+'?t='+Date.now(); $('dlpng').href=c.png; $('dlpdf').href=c.pdf; $('numbadge').textContent='№ '+c.number; $('result').classList.add('show'); loadDocs(); }catch(e){ $('err').textContent=e.message; } } function copyLink(){ const el=$('link'); el.select(); navigator.clipboard.writeText(el.value); $('copy').textContent='Скопировано'; setTimeout(()=>$('copy').textContent='Копировать',1200); } async function loadDocs(){ let items; try{ items = await api('/api/certificates?limit=200'); }catch(e){ return; } const box=$('docs'); if(!items.length){ box.innerHTML='

'+(ADMIN?'Документов пока нет.':'У вас пока нет сертификатов. Если ожидаете документ — проверьте, что администратор указал этот email как получателя.')+'

'; return; } box.innerHTML = items.map(c=>`
№ ${esc(c.number)}
${esc(c.fio||'')}
${esc(c.title||'')}${c.recipient_email?(' · '+esc(c.recipient_email)):''}
${c.public_url?``:''}
`).join(''); box.querySelectorAll('[data-del]').forEach(a=>a.addEventListener('click',async ev=>{ ev.preventDefault(); if(!confirm('Удалить документ №'+a.dataset.del+'?')) return; try{ await api('/api/certificates/'+a.dataset.del,{method:'DELETE'}); loadDocs(); }catch(e){ alert(e.message); } })); box.querySelectorAll('[data-share]').forEach(a=>a.addEventListener('click',async ev=>{ ev.preventDefault(); const seq=a.dataset.share, isOn=a.dataset.on==='1'; try{ if(isOn){ await api('/api/certificates/'+seq+'/share',{method:'DELETE'}); } else{ const r = await api('/api/certificates/'+seq+'/share',{method:'POST'}); try{ await navigator.clipboard.writeText(r.public_url); }catch(e){} alert('Публичная ссылка создана и скопирована:\n'+r.public_url); } loadDocs(); }catch(e){ alert(e.message); } })); box.querySelectorAll('[data-copy]').forEach(el=>el.addEventListener('click',ev=>{ ev.preventDefault(); navigator.clipboard.writeText(el.dataset.copy).catch(()=>{}); el.textContent='скопировано'; setTimeout(()=>el.textContent='копировать',1200); })); } async function changePassword(){ $('pwmsg').textContent=''; const cur=$('pw_cur').value, nw=$('pw_new').value; try{ await api('/api/auth/password',{method:'POST',headers:{'Content-Type':'application/json'}, body:JSON.stringify({current:cur, password:nw})}); $('pw_cur').value=''; $('pw_new').value=''; $('pwmsg').textContent='Пароль изменён'; }catch(e){ $('pwmsg').textContent=e.message; } } async function logout(){ try{ await fetch('/api/auth/logout',{method:'POST'}); }catch(e){} location.href='/login'; } window.addEventListener('DOMContentLoaded',()=>{ loadDocs(); if(ADMIN && $('template')) loadTemplates(); }); """ USERS_JS = r""" const $ = id => document.getElementById(id); const ME = window.CURRENT_USER; async function api(path, opts){ const r = await fetch(path, opts); if(!r.ok){ let m='Ошибка'; try{ m=(await r.json()).error||m; }catch(e){} throw new Error(m); } return r.headers.get('content-type','').includes('json') ? r.json() : r.blob(); } function esc(s){ return (s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c])); } async function load(){ const users = await api('/api/users'); $('tbody').innerHTML = users.map(u=>` ${esc(u.email)} ${esc(u.name||'')} ${u.role} ${u.is_active?'да':'нет'} ${esc((u.created_at||'').slice(0,10))} ${u.id===ME.id?'':``} `).join(''); bind(); } function bind(){ document.querySelectorAll('[data-del]').forEach(b=>b.onclick=async()=>{ if(!confirm('Удалить пользователя?')) return; try{ await api('/api/users/'+b.dataset.del,{method:'DELETE'}); load(); }catch(e){ alert(e.message); } }); document.querySelectorAll('[data-role]').forEach(b=>b.onclick=async()=>{ const role = b.dataset.cur==='admin'?'user':'admin'; try{ await api('/api/users/'+b.dataset.role,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({role})}); load(); }catch(e){ alert(e.message); } }); document.querySelectorAll('[data-active]').forEach(b=>b.onclick=async()=>{ const is_active = b.dataset.cur!=='1'; try{ await api('/api/users/'+b.dataset.active,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({is_active})}); load(); }catch(e){ alert(e.message); } }); document.querySelectorAll('[data-pw]').forEach(b=>b.onclick=async()=>{ const pw = prompt('Новый пароль (мин. 8 символов):'); if(!pw) return; try{ await api('/api/users/'+b.dataset.pw,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})}); alert('Пароль обновлён'); }catch(e){ alert(e.message); } }); } async function addUser(){ $('msg').textContent=''; const body={email:$('u_email').value.trim(), name:$('u_name').value.trim(), password:$('u_pw').value, role:$('u_role').value}; try{ await api('/api/users',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)}); $('u_email').value=''; $('u_name').value=''; $('u_pw').value=''; load(); }catch(e){ $('msg').textContent=e.message; } } async function logout(){ try{ await fetch('/api/auth/logout',{method:'POST'}); }catch(e){} location.href='/login'; } window.addEventListener('DOMContentLoaded',load); """ def _head(title: str, extra_css: str = "") -> str: return (f'' f'' f'{html.escape(title)}') def auth_page_html(mode: str, notice: str = "") -> str: is_reg = mode == "register" title = "Регистрация" if is_reg else "Вход" extra = ('' if is_reg else "") link = ('
Уже есть аккаунт? Войти
' if is_reg else '
Нет аккаунта? Зарегистрироваться
') notice_html = f'
{html.escape(notice)}
' if notice else "" return ( _head(f"certgen — {title}") + f'
' f'

{title}

Сертификаты и благодарственные письма

' f'{notice_html}' f'
' f'' f'{extra}' f'' f'' f'
{link}' f'
' f'' ) def dashboard_html(user: dict, base: str) -> str: is_admin = user["role"] == "admin" user_js = json.dumps({"id": user["id"], "email": user["email"], "name": user.get("name"), "role": user["role"]}, ensure_ascii=False) role_pill = ('администратор' if is_admin else 'пользователь') users_link = 'Пользователи' if is_admin else "" header = ( '

certgen

' f'{html.escape(user["email"])} {role_pill}' f'{users_link}' 'Выйти
' ) if is_admin: create_card = f"""

Новый документ

Пользователь увидит документ после входа под этим email.

""" docs_title = "Все документы" else: create_card = "" docs_title = "Мои документы" docs_card = f"""

{docs_title}

""" account_card = """

Аккаунт

""" main_cls = "" if is_admin else ' class="single"' return ( _head("certgen — кабинет") + '' + header + f'{create_card}{docs_card}' + (account_card if is_admin else "") + '' f'' '' ) def users_page_html(user: dict, base: str) -> str: user_js = json.dumps({"id": user["id"], "email": user["email"], "role": user["role"]}, ensure_ascii=False) header = ( '

certgen

' 'Пользователи' '← Кабинет ' 'Выйти
' ) return ( _head("certgen — пользователи") + '' + header + '
' '

Новый пользователь

' '
' '
' '
' '
' '
' '
' '' '
' '

Список

' '' '
EmailИмяРольАктивенСоздан
' f'' ) def cert_page_html(cert: dict, base: str) -> str: safe = html.escape return ( _head(f"Документ №{cert['number']}", ".wrap{max-width:1000px;margin:0 auto;padding:28px}" ".head{display:flex;align-items:center;gap:12px;margin-bottom:18px;flex-wrap:wrap}" ".head h1{font-size:18px;margin:0}" ".big{background:#fff;border-radius:14px;overflow:hidden;border:1px solid var(--line)}" ".big img{width:100%;display:block}") + '
' '
' f'

Документ №{safe(cert["number"])}

' f'{safe(cert.get("fio") or "")} · {safe(cert.get("date") or "")}' '' f'Скачать PNG ' f'Скачать PDF' '
' f'
document
' '
' ) def public_cert_page_html(cert: dict, base: str, image_size: tuple[int, int] | None = None) -> str: safe = html.escape esc = lambda s: html.escape(str(s or ""), quote=True) # noqa: E731 token = cert.get("public_token") or "" url = f"{base}/s/{token}" img = f"{base}/s/{token}.png" pdf = f"{base}/s/{token}.pdf" number = cert.get("number") or "" doc_title = cert.get("title") or "Документ" fio = cert.get("fio") or "" date = cert.get("date") or "" page_title = f"{doc_title} № {number}" + (f" — {fio}" if fio else "") desc_parts = [f"{doc_title} № {number}"] if fio: desc_parts.append(f"получатель: {fio}") if date: desc_parts.append(f"дата: {date}") description = ". ".join(desc_parts) + ". Электронный документ certgen." image_alt = f"{doc_title} № {number}" + (f", {fio}" if fio else "") dims = "" if image_size: dims = (f'\n' f'\n') secure = ('\n' % esc(img)) if url.startswith("https://") else "" favicon = ("data:image/svg+xml," "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E" "%3Crect width='32' height='32' rx='7' fill='%230f1116'/%3E" "%3Ccircle cx='16' cy='16' r='7' fill='%23c69e2d'/%3E%3C/svg%3E") jsonld = { "@context": "https://schema.org", "@type": "EducationalOccupationalCredential", "name": page_title, "description": description, "image": img, "url": url, "dateCreated": cert.get("date"), "credentialCategory": "certificate", "creator": {"@type": "Organization", "name": "certgen"}, } head = f""" {safe(page_title)} {dims}{secure} """ return ( head + '
' '
' f'

Документ №{safe(number)}

' f'{safe(fio)} · {safe(date)}' '' f'Скачать PDF ' f'Скачать PNG' '
' f'
{esc(image_alt)}
' '
' )