feat: service credentials (login/password) on dashboard cards with copy button

This commit is contained in:
2026-04-28 12:10:40 +00:00
parent fa88f7f4e4
commit b9d13733c9
4 changed files with 173 additions and 22 deletions
+7 -1
View File
@@ -197,6 +197,8 @@ class Service(Base):
type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True) type: Mapped[ServiceType] = mapped_column(Enum(ServiceType), index=True)
target: Mapped[str] = mapped_column(Text) target: Mapped[str] = mapped_column(Text)
comment: Mapped[str] = mapped_column(Text, default="") comment: Mapped[str] = mapped_column(Text, default="")
svc_login: Mapped[str] = mapped_column(String(256), default="")
svc_password: Mapped[str] = mapped_column(String(256), default="")
icon_path: Mapped[str] = mapped_column(Text, default="") icon_path: Mapped[str] = mapped_column(Text, default="")
active: Mapped[bool] = mapped_column(Boolean, default=True) active: Mapped[bool] = mapped_column(Boolean, default=True)
warm_pool_size: Mapped[int] = mapped_column(Integer, default=0) warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
@@ -1255,6 +1257,8 @@ def ensure_schema_compatibility() -> None:
with engine.begin() as conn: with engine.begin() as conn:
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS warm_pool_size INTEGER NOT NULL DEFAULT 0")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS warm_pool_size INTEGER NOT NULL DEFAULT 0"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS comment TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS comment TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_login VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_password VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''"))
conn.execute( conn.execute(
text( text(
@@ -2677,6 +2681,8 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad
type=service_type, type=service_type,
target=target, target=target,
comment=payload.get("comment", ""), comment=payload.get("comment", ""),
svc_login=payload.get("svc_login", ""),
svc_password=payload.get("svc_password", ""),
active=payload.get("active", True), active=payload.get("active", True),
warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))), warm_pool_size=max(0, int(payload.get("warm_pool_size", 0))),
) )
@@ -2741,7 +2747,7 @@ def edit_service(service_id: int, payload: dict, request: Request, _: User = Dep
service = db.get(Service, service_id) service = db.get(Service, service_id)
if not service: if not service:
raise HTTPException(status_code=404, detail="Service not found") raise HTTPException(status_code=404, detail="Service not found")
for key in ["name", "slug", "target", "active", "comment"]: for key in ["name", "slug", "target", "active", "comment", "svc_login", "svc_password"]:
if key in payload: if key in payload:
setattr(service, key, payload[key]) setattr(service, key, payload[key])
if "type" in payload: if "type" in payload:
+81
View File
@@ -344,6 +344,72 @@ button {
border-color: #0f5b94; border-color: #0f5b94;
color: #fff; color: #fff;
} }
.tile-wrap {
display: flex;
flex-direction: column;
gap: 0;
}
.svc-credentials {
background: linear-gradient(135deg, #f0f6fc 0%, #e8f2f9 100%);
border: 1px solid #c7d9ea;
border-top: none;
border-radius: 0 0 12px 12px;
padding: 0.55rem 0.85rem 0.65rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.svc-cred-row {
display: flex;
align-items: center;
gap: 0.4rem;
min-width: 0;
}
.svc-cred-label {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: .04em;
color: #5a7d9a;
min-width: 3.8rem;
flex-shrink: 0;
}
.svc-cred-value {
font-size: 0.84rem;
font-family: monospace;
color: #1a3a52;
font-weight: 600;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.svc-cred-masked {
letter-spacing: .12em;
font-size: 0.9rem;
}
.svc-cred-copy {
flex-shrink: 0;
width: 26px;
height: 26px;
border: 1px solid #b0c8de;
border-radius: 6px;
background: #fff;
color: #3a6d96;
cursor: pointer;
display: grid;
place-items: center;
padding: 0;
font-size: 0.78rem;
transition: background .15s, color .15s, border-color .15s;
font-family: sans-serif;
}
.svc-cred-copy::before { content: "\2398"; font-size: 0.9rem; }
.svc-cred-copy.copied { background: #1a8a4a; border-color: #1a8a4a; color: #fff; }
.svc-cred-copy.copied::before { content: "\2713"; }
.svc-cred-copy:hover:not(.copied) { background: #e6f0f9; border-color: #7aabcf; }
.tile { .tile {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -355,6 +421,7 @@ button {
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06); box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06);
border: 1px solid transparent; border: 1px solid transparent;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease; transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
flex: 1;
} }
.tile:hover { .tile:hover {
transform: translateY(-2px); transform: translateY(-2px);
@@ -546,6 +613,11 @@ button {
border: 1px solid rgba(255, 255, 255, 0.45); border: 1px solid rgba(255, 255, 255, 0.45);
backdrop-filter: blur(3px); backdrop-filter: blur(3px);
} }
.dashboard-page .svc-credentials {
background: rgba(224, 240, 252, 0.7);
border-color: rgba(180, 210, 235, 0.6);
backdrop-filter: blur(3px);
}
.made-by-wrap { .made-by-wrap {
@@ -643,6 +715,11 @@ button {
background: rgba(255, 255, 255, 0.9) !important; background: rgba(255, 255, 255, 0.9) !important;
border: 1px solid rgba(198, 218, 235, 0.9) !important; border: 1px solid rgba(198, 218, 235, 0.9) !important;
} }
.dashboard-page .svc-credentials {
backdrop-filter: none !important;
background: rgba(232, 244, 253, 0.95) !important;
border-color: rgba(180, 210, 235, 0.9) !important;
}
.dashboard-page .panel { .dashboard-page .panel {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
@@ -812,3 +889,7 @@ button {
.tile-comment th { background: #eaf2fb; font-weight: 700; } .tile-comment th { background: #eaf2fb; font-weight: 700; }
.tile-comment del { text-decoration: line-through; color: #7a9aaf; } .tile-comment del { text-decoration: line-through; color: #7a9aaf; }
.tile-comment input[type=checkbox] { margin-right: 0.3em; } .tile-comment input[type=checkbox] { margin-right: 0.3em; }
.tile-wrap:has(.svc-credentials) .tile {
border-radius: 12px 12px 0 0;
}
+34 -6
View File
@@ -123,7 +123,7 @@
<input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" /> <input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" />
<div class="list-box" id="web_list"> <div class="list-box" id="web_list">
{% for s in web_services %} {% for s in web_services %}
<button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}})'> <button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div> <div>
<div>{{s.name}}</div> <div>{{s.name}}</div>
@@ -158,6 +158,16 @@
<textarea id="w_comment" placeholder="Коротко: что это за сервис"></textarea> <textarea id="w_comment" placeholder="Коротко: что это за сервис"></textarea>
</label> </label>
<label class="field-col">
<span>Логин сервиса (показывается на карточке)</span>
<input id="w_svc_login" placeholder="Например: admin" />
</label>
<label class="field-col">
<span>Пароль сервиса (показывается на карточке)</span>
<input id="w_svc_password" placeholder="Пароль для входа в сервис" />
</label>
<label class="field-col"> <label class="field-col">
<span>Статус</span> <span>Статус</span>
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
@@ -231,6 +241,8 @@
<label class="field-col"> <label class="field-col">
<span>Описание для пользователя</span> <span>Описание для пользователя</span>
<textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea> <textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea>
<input id="new_w_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_w_svc_password" placeholder="Пароль сервиса (необязательно)" />
</label> </label>
<label class="field-col"> <label class="field-col">
@@ -257,7 +269,7 @@
<input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" /> <input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" />
<div class="list-box" id="rdp_list"> <div class="list-box" id="rdp_list">
{% for s in rdp_services %} {% for s in rdp_services %}
<button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}})'> <button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}}, {{s.svc_login|tojson}}, {{s.svc_password|tojson}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div> <div>
<div>{{s.name}}</div> <div>{{s.name}}</div>
@@ -288,6 +300,8 @@
</select> </select>
<input id="r_target" placeholder="Собранный target (авто)" /> <input id="r_target" placeholder="Собранный target (авто)" />
<textarea id="r_comment" placeholder="Описание для пользователя"></textarea> <textarea id="r_comment" placeholder="Описание для пользователя"></textarea>
<input id="r_svc_login" placeholder="Логин сервиса (показывается на карточке)" />
<input id="r_svc_password" placeholder="Пароль сервиса (показывается на карточке)" />
<input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" /> <input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" />
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div> </div>
@@ -371,6 +385,8 @@
</select> </select>
<input id="new_r_target" placeholder="Собранный target (авто)" /> <input id="new_r_target" placeholder="Собранный target (авто)" />
<textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea> <textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea>
<input id="new_r_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_r_svc_password" placeholder="Пароль сервиса (необязательно)" />
<input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" /> <input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" />
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select> <select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div> </div>
@@ -709,12 +725,14 @@
location.reload(); location.reload();
} }
function selectWebService(id, name, slug, target, comment, iconPath, active) { function selectWebService(id, name, slug, target, comment, iconPath, active, svcLogin, svcPassword) {
document.getElementById('w_id').value = id; document.getElementById('w_id').value = id;
document.getElementById('w_name').value = name; document.getElementById('w_name').value = name;
document.getElementById('w_slug').value = slug; document.getElementById('w_slug').value = slug;
document.getElementById('w_target').value = target; document.getElementById('w_target').value = target;
document.getElementById('w_comment').value = comment || ''; document.getElementById('w_comment').value = comment || '';
document.getElementById('w_svc_login').value = svcLogin || '';
document.getElementById('w_svc_password').value = svcPassword || '';
document.getElementById('w_active').value = String(active); document.getElementById('w_active').value = String(active);
document.getElementById('w_icon_preview').src = iconPath || placeholderIcon; document.getElementById('w_icon_preview').src = iconPath || placeholderIcon;
setCategoryChecks('.w_cat', serviceCategoryMap[id] || []); setCategoryChecks('.w_cat', serviceCategoryMap[id] || []);
@@ -737,6 +755,8 @@
type: 'WEB', type: 'WEB',
target: document.getElementById('new_w_target').value, target: document.getElementById('new_w_target').value,
comment: document.getElementById('new_w_comment').value, comment: document.getElementById('new_w_comment').value,
svc_login: document.getElementById('new_w_svc_login').value,
svc_password: document.getElementById('new_w_svc_password').value,
category_ids: checkedCategoryIds('.new_w_cat'), category_ids: checkedCategoryIds('.new_w_cat'),
active: document.getElementById('new_w_active').value === 'true', active: document.getElementById('new_w_active').value === 'true',
}); });
@@ -753,6 +773,8 @@
type: 'WEB', type: 'WEB',
target: document.getElementById('w_target').value, target: document.getElementById('w_target').value,
comment: document.getElementById('w_comment').value, comment: document.getElementById('w_comment').value,
svc_login: document.getElementById('w_svc_login').value,
svc_password: document.getElementById('w_svc_password').value,
category_ids: checkedCategoryIds('.w_cat'), category_ids: checkedCategoryIds('.w_cat'),
active: document.getElementById('w_active').value === 'true', active: document.getElementById('w_active').value === 'true',
}); });
@@ -760,7 +782,7 @@
} }
function clearWebForm() { function clearWebForm() {
['w_id','w_name','w_slug','w_target','w_comment'].forEach(id => document.getElementById(id).value = ''); ['w_id','w_name','w_slug','w_target','w_comment','w_svc_login','w_svc_password'].forEach(id => document.getElementById(id).value = '');
document.getElementById('w_active').value = 'true'; document.getElementById('w_active').value = 'true';
setCategoryChecks('.w_cat', []); setCategoryChecks('.w_cat', []);
document.getElementById('w_icon_preview').src = placeholderIcon; document.getElementById('w_icon_preview').src = placeholderIcon;
@@ -843,7 +865,7 @@
location.reload(); location.reload();
} }
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool) { function selectRdpService(id, name, slug, target, comment, iconPath, active, pool, svcLogin, svcPassword) {
const cfg = parseRdpTarget(target); const cfg = parseRdpTarget(target);
document.getElementById('r_id').value = id; document.getElementById('r_id').value = id;
document.getElementById('r_name').value = name; document.getElementById('r_name').value = name;
@@ -854,6 +876,8 @@
document.getElementById('r_domain').value = cfg.domain; document.getElementById('r_domain').value = cfg.domain;
document.getElementById('r_sec').value = cfg.sec; document.getElementById('r_sec').value = cfg.sec;
document.getElementById('r_comment').value = comment || ''; document.getElementById('r_comment').value = comment || '';
document.getElementById('r_svc_login').value = svcLogin || '';
document.getElementById('r_svc_password').value = svcPassword || '';
document.getElementById('r_active').value = String(active); document.getElementById('r_active').value = String(active);
document.getElementById('r_pool').value = pool; document.getElementById('r_pool').value = pool;
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []); setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
@@ -873,6 +897,8 @@
type: 'RDP', type: 'RDP',
target, target,
comment: document.getElementById('new_r_comment').value, comment: document.getElementById('new_r_comment').value,
svc_login: document.getElementById('new_r_svc_login').value,
svc_password: document.getElementById('new_r_svc_password').value,
category_ids: checkedCategoryIds('.new_r_cat'), category_ids: checkedCategoryIds('.new_r_cat'),
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10), warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
active: document.getElementById('new_r_active').value === 'true', active: document.getElementById('new_r_active').value === 'true',
@@ -890,6 +916,8 @@
type: 'RDP', type: 'RDP',
target, target,
comment: document.getElementById('r_comment').value, comment: document.getElementById('r_comment').value,
svc_login: document.getElementById('r_svc_login').value,
svc_password: document.getElementById('r_svc_password').value,
category_ids: checkedCategoryIds('.r_cat'), category_ids: checkedCategoryIds('.r_cat'),
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10), warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true', active: document.getElementById('r_active').value === 'true',
@@ -898,7 +926,7 @@
} }
function clearRdpForm() { function clearRdpForm() {
['r_id','r_name','r_slug','r_target','r_host','r_port','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = ''); ['r_id','r_name','r_slug','r_target','r_host','r_port','r_domain','r_comment','r_pool','r_svc_login','r_svc_password'].forEach(id => document.getElementById(id).value = '');
document.getElementById('rdp_slots_box').style.display = 'none'; document.getElementById('rdp_slots_box').style.display = 'none';
document.getElementById('r_sec').value = ''; document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true'; document.getElementById('r_active').value = 'true';
+51 -15
View File
@@ -77,23 +77,43 @@
<section class="grid service-grid"> <section class="grid service-grid">
{% for service in services %} {% for service in services %}
{% set svc_cats = service_categories.get(service.id, []) %} {% set svc_cats = service_categories.get(service.id, []) %}
<a class="tile" href="/go/{{ service.slug }}"> <div class="tile-wrap">
<div class="tile-icon-box"> <a class="tile" href="/go/{{ service.slug }}">
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" /> <div class="tile-icon-box">
</div> <img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<h3>{{ service.name }}</h3>
<p>Открыть сервис</p>
{% if service.comment %}
<small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small>
{% endif %}
{% if svc_cats %}
<div class="service-categories">
{% for category in svc_cats %}
<span class="service-cat-badge">{{ category.name }}</span>
{% endfor %}
</div> </div>
<h3>{{ service.name }}</h3>
<p>Открыть сервис</p>
{% if service.comment %}
<small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small>
{% endif %}
{% if svc_cats %}
<div class="service-categories">
{% for category in svc_cats %}
<span class="service-cat-badge">{{ category.name }}</span>
{% endfor %}
</div>
{% endif %}
</a>
{% if service.svc_login or service.svc_password %}
<div class="svc-credentials">
{% if service.svc_login %}
<div class="svc-cred-row">
<span class="svc-cred-label">Логин</span>
<span class="svc-cred-value">{{ service.svc_login }}</span>
<button class="svc-cred-copy" type="button" data-copy="{{ service.svc_login }}" title="Копировать логин">&#xe92c;</button>
</div>
{% endif %}
{% if service.svc_password %}
<div class="svc-cred-row">
<span class="svc-cred-label">Пароль</span>
<span class="svc-cred-value svc-cred-masked">{{ service.svc_password }}</span>
<button class="svc-cred-copy" type="button" data-copy="{{ service.svc_password }}" title="Копировать пароль">&#xe92c;</button>
</div>
{% endif %}
</div>
{% endif %} {% endif %}
</a> </div>
{% else %} {% else %}
<div class="tile"> <div class="tile">
{% if selected_category_slug %} {% if selected_category_slug %}
@@ -194,5 +214,21 @@
}); });
})(); })();
</script> </script>
<script>
document.querySelectorAll('.svc-cred-copy').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault(); e.stopPropagation();
const text = btn.dataset.copy;
try { await navigator.clipboard.writeText(text); } catch(_) {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position='fixed'; ta.style.opacity='0';
document.body.appendChild(ta); ta.select();
document.execCommand('copy'); document.body.removeChild(ta);
}
btn.classList.add('copied');
setTimeout(() => btn.classList.remove('copied'), 1500);
});
});
</script>
</body> </body>
</html> </html>