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)
target: Mapped[str] = mapped_column(Text)
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="")
active: Mapped[bool] = mapped_column(Boolean, default=True)
warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
@@ -1255,6 +1257,8 @@ def ensure_schema_compatibility() -> None:
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 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(
@@ -2677,6 +2681,8 @@ def create_service(payload: dict, request: Request, _: User = Depends(require_ad
type=service_type,
target=target,
comment=payload.get("comment", ""),
svc_login=payload.get("svc_login", ""),
svc_password=payload.get("svc_password", ""),
active=payload.get("active", True),
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)
if not service:
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:
setattr(service, key, payload[key])
if "type" in payload:
+81
View File
@@ -344,6 +344,72 @@ button {
border-color: #0f5b94;
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 {
display: flex;
flex-direction: column;
@@ -355,6 +421,7 @@ button {
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06);
border: 1px solid transparent;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
flex: 1;
}
.tile:hover {
transform: translateY(-2px);
@@ -546,6 +613,11 @@ button {
border: 1px solid rgba(255, 255, 255, 0.45);
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 {
@@ -643,6 +715,11 @@ button {
background: rgba(255, 255, 255, 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 {
width: 100%;
min-width: 0;
@@ -812,3 +889,7 @@ button {
.tile-comment th { background: #eaf2fb; font-weight: 700; }
.tile-comment del { text-decoration: line-through; color: #7a9aaf; }
.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')" />
<div class="list-box" id="web_list">
{% 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" />
<div>
<div>{{s.name}}</div>
@@ -158,6 +158,16 @@
<textarea id="w_comment" placeholder="Коротко: что это за сервис"></textarea>
</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">
<span>Статус</span>
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
@@ -231,6 +241,8 @@
<label class="field-col">
<span>Описание для пользователя</span>
<textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea>
<input id="new_w_svc_login" placeholder="Логин сервиса (необязательно)" />
<input id="new_w_svc_password" placeholder="Пароль сервиса (необязательно)" />
</label>
<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')" />
<div class="list-box" id="rdp_list">
{% 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" />
<div>
<div>{{s.name}}</div>
@@ -288,6 +300,8 @@
</select>
<input id="r_target" placeholder="Собранный target (авто)" />
<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="Количество заранее прогретых слотов" />
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div>
@@ -371,6 +385,8 @@
</select>
<input id="new_r_target" placeholder="Собранный target (авто)" />
<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="Количество прогретых слотов" />
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div>
@@ -709,12 +725,14 @@
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_name').value = name;
document.getElementById('w_slug').value = slug;
document.getElementById('w_target').value = target;
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_icon_preview').src = iconPath || placeholderIcon;
setCategoryChecks('.w_cat', serviceCategoryMap[id] || []);
@@ -737,6 +755,8 @@
type: 'WEB',
target: document.getElementById('new_w_target').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'),
active: document.getElementById('new_w_active').value === 'true',
});
@@ -753,6 +773,8 @@
type: 'WEB',
target: document.getElementById('w_target').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'),
active: document.getElementById('w_active').value === 'true',
});
@@ -760,7 +782,7 @@
}
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';
setCategoryChecks('.w_cat', []);
document.getElementById('w_icon_preview').src = placeholderIcon;
@@ -843,7 +865,7 @@
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);
document.getElementById('r_id').value = id;
document.getElementById('r_name').value = name;
@@ -854,6 +876,8 @@
document.getElementById('r_domain').value = cfg.domain;
document.getElementById('r_sec').value = cfg.sec;
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_pool').value = pool;
setCategoryChecks('.r_cat', serviceCategoryMap[id] || []);
@@ -873,6 +897,8 @@
type: 'RDP',
target,
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'),
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
active: document.getElementById('new_r_active').value === 'true',
@@ -890,6 +916,8 @@
type: 'RDP',
target,
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'),
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true',
@@ -898,7 +926,7 @@
}
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('r_sec').value = '';
document.getElementById('r_active').value = 'true';
+36
View File
@@ -77,6 +77,7 @@
<section class="grid service-grid">
{% for service in services %}
{% set svc_cats = service_categories.get(service.id, []) %}
<div class="tile-wrap">
<a class="tile" href="/go/{{ service.slug }}">
<div class="tile-icon-box">
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
@@ -94,6 +95,25 @@
</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 %}
</div>
{% else %}
<div class="tile">
{% if selected_category_slug %}
@@ -194,5 +214,21 @@
});
})();
</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>
</html>