console: namespace = sub (убрать sless-SHA256) и удалить несуществующий /ensure

This commit is contained in:
Repinoid
2026-10-11 08:06:20 +03:00
parent fc26ca1954
commit 994fc054b1
+515 -158
View File
@@ -6,6 +6,7 @@
Функционал: вход, список устройств, credentials, эмулятор MQTT, заглушка телеметрии. Функционал: вход, список устройств, credentials, эмулятор MQTT, заглушка телеметрии.
Встраивается в iot-service через go:embed, раздаётся по GET /console. --> Встраивается в iot-service через go:embed, раздаётся по GET /console. -->
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -23,219 +24,598 @@
#6b8eaa — text secondary #6b8eaa — text secondary
#2d5070 — text muted / disabled */ #2d5070 — text muted / disabled */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } *,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body { body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: #001120; color: #e2ecf6; min-height: 100vh; background: #001120;
color: #e2ecf6;
min-height: 100vh;
} }
/* ——— Navbar ——— */ /* ——— Navbar ——— */
.navbar { .navbar {
background: #001C34; background: #001C34;
border-bottom: 1px solid #0b2d50; border-bottom: 1px solid #0b2d50;
padding: 0 28px; height: 58px; padding: 0 28px;
display: flex; align-items: center; gap: 14px; height: 58px;
display: flex;
align-items: center;
gap: 14px;
} }
/* Логотип Nubes — SVG с инвертированием для белого цвета на тёмном фоне */ /* Логотип Nubes — SVG с инвертированием для белого цвета на тёмном фоне */
.navbar-logo { .navbar-logo {
display: flex; align-items: center; gap: 10px; display: flex;
cursor: pointer; user-select: none; text-decoration: none; align-items: center;
gap: 10px;
cursor: pointer;
user-select: none;
text-decoration: none;
} }
.navbar-logo img { .navbar-logo img {
height: 18px; height: 18px;
/* SVG залит #001C34, инвертируем до белого */ /* SVG залит #001C34, инвертируем до белого */
filter: brightness(0) invert(1); filter: brightness(0) invert(1);
} }
.navbar-logo-sep { .navbar-logo-sep {
width: 1px; height: 18px; background: #1a4a73; margin: 0 4px; width: 1px;
height: 18px;
background: #1a4a73;
margin: 0 4px;
} }
.navbar-logo-product { .navbar-logo-product {
font-size: 13px; font-weight: 600; color: #7aafd4; font-size: 13px;
letter-spacing: 0.04em; text-transform: uppercase; font-weight: 600;
color: #7aafd4;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.navbar-spacer {
flex: 1;
} }
.navbar-spacer { flex: 1; }
/* ——— Layout ——— */ /* ——— Layout ——— */
.container { max-width: 940px; margin: 0 auto; padding: 32px 24px; } .container {
max-width: 940px;
margin: 0 auto;
padding: 32px 24px;
}
/* ——— Card ——— */ /* ——— Card ——— */
.card { .card {
background: #001929; background: #001929;
border-radius: 8px; border-radius: 8px;
border: 1px solid #0b2d50; border: 1px solid #0b2d50;
padding: 24px; margin-bottom: 16px; padding: 24px;
margin-bottom: 16px;
} }
.card-title { .card-title {
font-size: 15px; font-weight: 600; font-size: 15px;
font-weight: 600;
color: #b8d4ec; color: #b8d4ec;
margin-bottom: 16px; margin-bottom: 16px;
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
/* ——— Form ——— */ /* ——— Form ——— */
.form-group { margin-bottom: 16px; } .form-group {
label { display: block; font-size: 12px; font-weight: 500; color: #6b8eaa; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; } margin-bottom: 16px;
input, textarea, select { }
label {
display: block;
font-size: 12px;
font-weight: 500;
color: #6b8eaa;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.05em;
}
input,
textarea,
select {
width: 100%; width: 100%;
background: #001120; background: #001120;
border: 1px solid #0b2d50; border: 1px solid #0b2d50;
border-radius: 5px; padding: 10px 12px; border-radius: 5px;
padding: 10px 12px;
color: #e2ecf6; color: #e2ecf6;
font-size: 14px; font-family: inherit; font-size: 14px;
font-family: inherit;
transition: border-color 0.15s; transition: border-color 0.15s;
} }
input:focus, textarea:focus { outline: none; border-color: #1a7fd4; box-shadow: 0 0 0 2px rgba(26,127,212,0.15); }
textarea { resize: vertical; font-family: 'Courier New', monospace; line-height: 1.5; } input:focus,
input[type="number"] { width: auto; } textarea:focus {
outline: none;
border-color: #1a7fd4;
box-shadow: 0 0 0 2px rgba(26, 127, 212, 0.15);
}
textarea {
resize: vertical;
font-family: 'Courier New', monospace;
line-height: 1.5;
}
input[type="number"] {
width: auto;
}
/* ——— Buttons ——— */ /* ——— Buttons ——— */
.btn { .btn {
border: none; border-radius: 5px; padding: 10px 18px; font-size: 13px; border: none;
font-weight: 600; cursor: pointer; transition: opacity 0.15s, background 0.15s; border-radius: 5px;
display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; padding: 10px 18px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s, background 0.15s;
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.btn:hover:not(:disabled) { opacity: 0.85; }
.btn:disabled { opacity: 0.35; cursor: not-allowed; } .btn:hover:not(:disabled) {
.btn-primary { background: #1a7fd4; color: #fff; } opacity: 0.85;
.btn-danger { background: #c0392b; color: #fff; } }
.btn-success { background: #1a9e5c; color: #fff; }
.btn-warning { background: #d48f0a; color: #fff; } .btn:disabled {
.btn-ghost { background: transparent; color: #6b8eaa; border: 1px solid #0b2d50; } opacity: 0.35;
.btn-ghost:hover:not(:disabled) { color: #b8d4ec; border-color: #1a4a73; opacity: 1; } cursor: not-allowed;
.btn-sm { padding: 5px 11px; font-size: 12px; } }
.btn-primary {
background: #1a7fd4;
color: #fff;
}
.btn-danger {
background: #c0392b;
color: #fff;
}
.btn-success {
background: #1a9e5c;
color: #fff;
}
.btn-warning {
background: #d48f0a;
color: #fff;
}
.btn-ghost {
background: transparent;
color: #6b8eaa;
border: 1px solid #0b2d50;
}
.btn-ghost:hover:not(:disabled) {
color: #b8d4ec;
border-color: #1a4a73;
opacity: 1;
}
.btn-sm {
padding: 5px 11px;
font-size: 12px;
}
/* ——— Table ——— */ /* ——— Table ——— */
table { width: 100%; border-collapse: collapse; } table {
th { width: 100%;
text-align: left; font-size: 11px; font-weight: 600; color: #2d5070; border-collapse: collapse;
text-transform: uppercase; letter-spacing: 0.07em; }
padding: 0 14px 10px; border-bottom: 1px solid #0b2d50;
th {
text-align: left;
font-size: 11px;
font-weight: 600;
color: #2d5070;
text-transform: uppercase;
letter-spacing: 0.07em;
padding: 0 14px 10px;
border-bottom: 1px solid #0b2d50;
}
td {
padding: 13px 14px;
border-bottom: 1px solid rgba(11, 45, 80, 0.6);
vertical-align: middle;
}
tbody tr:last-child td {
border-bottom: none;
}
tbody tr:hover td {
background: rgba(26, 127, 212, 0.04);
} }
td { padding: 13px 14px; border-bottom: 1px solid rgba(11,45,80,0.6); vertical-align: middle; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover td { background: rgba(26,127,212,0.04); }
/* ——— Badges ——— */ /* ——— Badges ——— */
.badge { .badge {
display: inline-flex; align-items: center; gap: 5px; display: inline-flex;
font-size: 11px; font-weight: 600; border-radius: 4px; align-items: center;
padding: 3px 9px; letter-spacing: 0.04em; text-transform: uppercase; gap: 5px;
font-size: 11px;
font-weight: 600;
border-radius: 4px;
padding: 3px 9px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.badge::before {
content: '●';
font-size: 6px;
}
.badge-active {
background: rgba(26, 158, 92, 0.15);
color: #2ecc8a;
}
.badge-active::before {
color: #2ecc8a;
}
.badge-disabled {
background: rgba(45, 80, 112, 0.3);
color: #2d5070;
}
.badge-disabled::before {
color: #2d5070;
}
.badge-error {
background: rgba(192, 57, 43, 0.15);
color: #e55042;
}
.badge-error::before {
color: #e55042;
}
.badge-pending {
background: rgba(212, 143, 10, 0.15);
color: #d4a017;
}
.badge-pending::before {
color: #d4a017;
} }
.badge::before { content: '●'; font-size: 6px; }
.badge-active { background: rgba(26,158,92,0.15); color: #2ecc8a; }
.badge-active::before { color: #2ecc8a; }
.badge-disabled { background: rgba(45,80,112,0.3); color: #2d5070; }
.badge-disabled::before { color: #2d5070; }
.badge-error { background: rgba(192,57,43,0.15); color: #e55042; }
.badge-error::before { color: #e55042; }
.badge-pending { background: rgba(212,143,10,0.15); color: #d4a017; }
.badge-pending::before { color: #d4a017; }
/* ——— Tabs ——— */ /* ——— Tabs ——— */
.tabs { .tabs {
display: flex; border-bottom: 1px solid #0b2d50; margin-bottom: 24px; display: flex;
border-bottom: 1px solid #0b2d50;
margin-bottom: 24px;
} }
.tab { .tab {
padding: 11px 20px; font-size: 13px; font-weight: 600; padding: 11px 20px;
cursor: pointer; color: #2d5070; font-size: 13px;
font-weight: 600;
cursor: pointer;
color: #2d5070;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s; transition: color 0.15s, border-color 0.15s;
user-select: none; letter-spacing: 0.03em; user-select: none;
letter-spacing: 0.03em;
}
.tab:hover {
color: #7aafd4;
}
.tab.active {
color: #1a7fd4;
border-bottom-color: #1a7fd4;
} }
.tab:hover { color: #7aafd4; }
.tab.active { color: #1a7fd4; border-bottom-color: #1a7fd4; }
/* ——— Credentials ——— */ /* ——— Credentials ——— */
.cred-row { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; } .cred-row {
.cred-label { font-size: 13px; font-weight: 600; color: #7eb8e0; min-width: 110px; } display: flex;
align-items: center;
gap: 10px;
margin-bottom: 14px;
}
.cred-label {
font-size: 13px;
font-weight: 600;
color: #7eb8e0;
min-width: 110px;
}
.cred-value { .cred-value {
font-family: 'Courier New', monospace; font-size: 14px; font-family: 'Courier New', monospace;
background: #0a1e30; padding: 9px 14px; border-radius: 6px; font-size: 14px;
border: 1px solid #1a4a6e; flex: 1; color: #e2f0ff; background: #0a1e30;
overflow-x: auto; word-break: break-all; padding: 9px 14px;
border-radius: 6px;
border: 1px solid #1a4a6e;
flex: 1;
color: #e2f0ff;
overflow-x: auto;
word-break: break-all;
} }
/* ——— Connection pill ——— */ /* ——— Connection pill ——— */
.conn-pill { .conn-pill {
display: inline-flex; align-items: center; gap: 7px; display: inline-flex;
font-size: 12px; font-weight: 600; padding: 4px 13px; align-items: center;
border-radius: 4px; letter-spacing: 0.04em; text-transform: uppercase; gap: 7px;
font-size: 12px;
font-weight: 600;
padding: 4px 13px;
border-radius: 4px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.conn-pill::before {
content: '●';
font-size: 6px;
}
.conn-pill.off {
background: rgba(45, 80, 112, 0.25);
color: #2d5070;
}
.conn-pill.off::before {
color: #2d5070;
}
.conn-pill.ing {
background: rgba(212, 143, 10, 0.15);
color: #d4a017;
}
.conn-pill.ing::before {
color: #d4a017;
animation: blink 1s step-start infinite;
}
.conn-pill.on {
background: rgba(26, 158, 92, 0.15);
color: #2ecc8a;
}
.conn-pill.on::before {
color: #2ecc8a;
}
@keyframes blink {
50% {
opacity: 0;
}
} }
.conn-pill::before { content: '●'; font-size: 6px; }
.conn-pill.off { background: rgba(45,80,112,0.25); color: #2d5070; }
.conn-pill.off::before { color: #2d5070; }
.conn-pill.ing { background: rgba(212,143,10,0.15); color: #d4a017; }
.conn-pill.ing::before { color: #d4a017; animation: blink 1s step-start infinite; }
.conn-pill.on { background: rgba(26,158,92,0.15); color: #2ecc8a; }
.conn-pill.on::before { color: #2ecc8a; }
@keyframes blink { 50% { opacity: 0; } }
/* ——— MQTT log ——— */ /* ——— MQTT log ——— */
.mqtt-log { .mqtt-log {
background: #000d18; border: 1px solid #0b2d50; border-radius: 5px; background: #000d18;
padding: 10px 12px; max-height: 180px; overflow-y: auto; border: 1px solid #0b2d50;
font-family: 'Courier New', monospace; font-size: 12px; border-radius: 5px;
padding: 10px 12px;
max-height: 180px;
overflow-y: auto;
font-family: 'Courier New', monospace;
font-size: 12px;
line-height: 1.6; line-height: 1.6;
} }
.mqtt-log .ok { color: #2ecc8a; }
.mqtt-log .err { color: #e55042; } .mqtt-log .ok {
.mqtt-log .info { color: #2d5070; } color: #2ecc8a;
}
.mqtt-log .err {
color: #e55042;
}
.mqtt-log .info {
color: #2d5070;
}
/* ——— Modal ——— */ /* ——— Modal ——— */
.modal-overlay { .modal-overlay {
position: fixed; inset: 0; background: rgba(0,8,16,0.82); position: fixed;
display: flex; align-items: center; justify-content: center; z-index: 200; inset: 0;
background: rgba(0, 8, 16, 0.82);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
} }
.modal { .modal {
background: #001929; border: 1px solid #0b2d50; border-radius: 8px; background: #001929;
padding: 28px; width: 100%; max-width: 460px; border: 1px solid #0b2d50;
border-radius: 8px;
padding: 28px;
width: 100%;
max-width: 460px;
}
.modal-title {
font-size: 15px;
font-weight: 700;
margin-bottom: 20px;
color: #b8d4ec;
} }
.modal-title { font-size: 15px; font-weight: 700; margin-bottom: 20px; color: #b8d4ec; }
/* ——— Alerts ——— */ /* ——— Alerts ——— */
.alert { padding: 11px 15px; border-radius: 5px; font-size: 13px; margin-bottom: 14px; } .alert {
.alert-error { background: rgba(192,57,43,0.1); color: #e55042; border: 1px solid rgba(192,57,43,0.25); } padding: 11px 15px;
.alert-success { background: rgba(26,158,92,0.1); color: #2ecc8a; border: 1px solid rgba(26,158,92,0.25); } border-radius: 5px;
.alert-info { background: rgba(26,127,212,0.1); color: #5aaae8; border: 1px solid rgba(26,127,212,0.25); } font-size: 13px;
margin-bottom: 14px;
}
.alert-error {
background: rgba(192, 57, 43, 0.1);
color: #e55042;
border: 1px solid rgba(192, 57, 43, 0.25);
}
.alert-success {
background: rgba(26, 158, 92, 0.1);
color: #2ecc8a;
border: 1px solid rgba(26, 158, 92, 0.25);
}
.alert-info {
background: rgba(26, 127, 212, 0.1);
color: #5aaae8;
border: 1px solid rgba(26, 127, 212, 0.25);
}
/* ——— Misc ——— */ /* ——— Misc ——— */
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .row {
.flex1 { flex: 1; } display: flex;
.mt8 { margin-top: 8px; } gap: 10px;
.mt16 { margin-top: 16px; } align-items: center;
.mt24 { margin-top: 24px; } flex-wrap: wrap;
.separator { border: none; border-top: 1px solid #0b2d50; margin: 20px 0; } }
.hint { font-size: 14px; color: #a0bcd8; line-height: 1.7; }
.mono { font-family: 'Courier New', monospace; } .flex1 {
flex: 1;
}
.mt8 {
margin-top: 8px;
}
.mt16 {
margin-top: 16px;
}
.mt24 {
margin-top: 24px;
}
.separator {
border: none;
border-top: 1px solid #0b2d50;
margin: 20px 0;
}
.hint {
font-size: 14px;
color: #a0bcd8;
line-height: 1.7;
}
.mono {
font-family: 'Courier New', monospace;
}
/* ——— Device list empty state ——— */ /* ——— Device list empty state ——— */
.coming-soon { text-align: center; padding: 56px 24px; color: #2d5070; } .coming-soon {
.coming-soon .icon { font-size: 36px; margin-bottom: 12px; opacity: 0.5; } text-align: center;
.coming-soon h3 { font-size: 17px; font-weight: 600; margin-bottom: 8px; color: #1a4a73; } padding: 56px 24px;
color: #2d5070;
}
a { color: #1a7fd4; text-decoration: none; } .coming-soon .icon {
a:hover { text-decoration: underline; } font-size: 36px;
margin-bottom: 12px;
opacity: 0.5;
}
.coming-soon h3 {
font-size: 17px;
font-weight: 600;
margin-bottom: 8px;
color: #1a4a73;
}
a {
color: #1a7fd4;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
/* ——— Help block ——— */ /* ——— Help block ——— */
.help-block { .help-block {
border: 1px solid #0b2d50; border-radius: 8px; border: 1px solid #0b2d50;
padding: 16px 20px; background: #001929; border-radius: 8px;
padding: 16px 20px;
background: #001929;
} }
.help-block summary::-webkit-details-marker { display: none; }
.help-block[open] summary .help-arrow { transform: rotate(90deg); } .help-block summary::-webkit-details-marker {
.help-arrow { display: inline-block; transition: transform 0.15s; margin-right: 6px; font-size: 10px; } display: none;
.help-step { display: flex; gap: 14px; align-items: flex-start; font-size: 13px; color: #6b8eaa; line-height: 1.6; } }
.help-block[open] summary .help-arrow {
transform: rotate(90deg);
}
.help-arrow {
display: inline-block;
transition: transform 0.15s;
margin-right: 6px;
font-size: 10px;
}
.help-step {
display: flex;
gap: 14px;
align-items: flex-start;
font-size: 13px;
color: #6b8eaa;
line-height: 1.6;
}
.help-num { .help-num {
flex-shrink: 0; width: 22px; height: 22px; border-radius: 4px; flex-shrink: 0;
background: #001C34; border: 1px solid #1a4a73; width: 22px;
color: #1a7fd4; font-weight: 700; font-size: 11px; height: 22px;
display: flex; align-items: center; justify-content: center; margin-top: 2px; border-radius: 4px;
background: #001C34;
border: 1px solid #1a4a73;
color: #1a7fd4;
font-weight: 700;
font-size: 11px;
display: flex;
align-items: center;
justify-content: center;
margin-top: 2px;
}
.help-step strong {
color: #b8d4ec;
}
.help-step em {
color: #1a7fd4;
font-style: normal;
} }
.help-step strong { color: #b8d4ec; }
.help-step em { color: #1a7fd4; font-style: normal; }
</style> </style>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
@@ -249,7 +629,7 @@
const S = { const S = {
// Сохраняемые настройки // Сохраняемые настройки
token: localStorage.getItem('iot_token') || '', token: localStorage.getItem('iot_token') || '',
// ns вычисляется из токена автоматически (SHA256(sub) → "sless-{hex}"), не вводится вручную // ns = sub из токена (middleware сверяет namespace == sub), не вводится вручную
ns: localStorage.getItem('iot_ns') || '', ns: localStorage.getItem('iot_ns') || '',
// apiBase — same-origin с API (консоль и /v1 раздаёт один iot-service); // apiBase — same-origin с API (консоль и /v1 раздаёт один iot-service);
// mqttBase — отдельный хост EMQX (как в landing.html). // mqttBase — отдельный хост EMQX (как в landing.html).
@@ -309,48 +689,29 @@ function displayNameFromToken(token) {
return token; return token;
} }
// namespaceFromToken — вычисляет namespace из токена. // namespaceFromToken — возвращает namespace (= sub из токена).
// Алгоритм ИДЕНТИЧЕН Terraform провайдеру (client.NamespaceFromSub): // Middleware сверяет namespace == sub (tenant-граница), поэтому namespace
// SHA256(sub) → первые 8 байт → "sless-{16 hex символов}" // должен быть РОВНО sub, а не производной строкой: прежний «sless-{SHA256(sub)}»
// расходился с middleware и давал бы 403 (находка 2026-10-11).
// //
// Поддерживает два режима (соответствует authTestMode на сервере): // Два режима (соответствует authTestMode на сервере):
// - Режим JWT (прод): токен вида xxx.yyy.zzz → извлекаем sub из payload → SHA256(sub) // - JWT (прод): sub из payload.
// - Тестовый режим: любая строка без пробелов → используем её саму как sub → SHA256(token) // - Тестовый: строка без пробелов используется как sub.
// function namespaceFromToken(token) {
// Чтобы вернуться только к JWT: убрать ветку isPlainToken ниже.
async function namespaceFromToken(token) {
let sub;
const parts = token.split('.'); const parts = token.split('.');
if (parts.length === 3) { if (parts.length === 3) {
// ── JWT путь (боевой режим) ────────────────────────────────────────────── // JWT: base64url → base64
// base64url → base64 (заменяем символы, добавляем padding)
let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); let payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
while (payload.length % 4) payload += '='; while (payload.length % 4) payload += '=';
let claims; let claims;
try { claims = JSON.parse(atob(payload)); } try { claims = JSON.parse(atob(payload)); }
catch (e) { throw new Error('Не удалось декодировать токен: ' + e.message); } catch (e) { throw new Error('Не удалось декодировать токен: ' + e.message); }
if (!claims.sub) throw new Error('В токене нет поля sub — токен не поддерживается'); if (!claims.sub) throw new Error('В токене нет поля sub — токен не поддерживается');
sub = claims.sub; return claims.sub;
} else {
// ── Тестовый режим: plain token ─────────────────────────────────────────
// Строка без пробелов используется напрямую как идентификатор (sub).
// Сервер (authTestMode=true) тоже принимает такой токен без JWT-проверки.
// ВАЖНО: каждая уникальная строка → отдельный изолированный namespace.
if (/\s/.test(token)) throw new Error('Токен не должен содержать пробелы');
sub = token;
} }
// Тестовый режим: plain token = sub.
// SHA256(sub) через Web Crypto API (встроен во все современные браузеры) if (/\s/.test(token)) throw new Error('Токен не должен содержать пробелы');
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(sub)); return token;
// Берём первые 8 байт → 16 hex символов — идентично Go: hash[:8]
const hex = Array.from(new Uint8Array(buf).slice(0, 8))
.map(b => b.toString(16).padStart(2, '0')).join('');
return 'sless-' + hex;
} }
// ============================================================= // =============================================================
@@ -363,7 +724,7 @@ function sanitizeApiError(msg) {
if (!msg) return 'Внутренняя ошибка. Попробуйте ещё раз.'; if (!msg) return 'Внутренняя ошибка. Попробуйте ещё раз.';
// Скрываем k8s-сообщения с namespace/not found // Скрываем k8s-сообщения с namespace/not found
if (/namespaces?.*not found/i.test(msg)) return 'Ошибка инициализации. Выйдите и войдите снова.'; if (/namespaces?.*not found/i.test(msg)) return 'Ошибка инициализации. Выйдите и войдите снова.';
if (/not found/i.test(msg) && /sless-/i.test(msg)) return 'Ресурс не найден.'; if (/not found/i.test(msg)) return 'Ресурс не найден.';
if (/already exists/i.test(msg)) return 'Устройство с таким именем уже существует.'; if (/already exists/i.test(msg)) return 'Устройство с таким именем уже существует.';
if (/device.*id.*already/i.test(msg) || /device_id.*exists/i.test(msg)) return 'Device ID уже занят.'; if (/device.*id.*already/i.test(msg) || /device_id.*exists/i.test(msg)) return 'Device ID уже занят.';
if (/cannot decode jwt/i.test(msg) || /not a jwt/i.test(msg)) return 'Неверный токен.'; if (/cannot decode jwt/i.test(msg) || /not a jwt/i.test(msg)) return 'Неверный токен.';
@@ -626,7 +987,7 @@ async function doLogin() {
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Проверка...'; btn.textContent = 'Проверка...';
// Вычисляем namespace из токена — SHA256(sub) → "sless-{hex}" // ns = sub из токена
let ns; let ns;
try { try {
ns = await namespaceFromToken(tok); ns = await namespaceFromToken(tok);
@@ -644,10 +1005,8 @@ async function doLogin() {
S.displayName = displayNameFromToken(tok); S.displayName = displayNameFromToken(tok);
try { try {
// Создаём namespace если не существует — идемпотентный вызов. // Отдельного «ensure» в новом API нет (tenant-БД создаётся лениво в
// Terraform-провайдер делает это в Configure(), консоль — при первом логине. // consumer). Проверяем токен списком устройств.
// Без этого шага создание устройства упадёт с "namespace not found".
await apiCall('POST', `/v1/namespaces/${S.ns}/ensure`, {});
await apiListDevices(); // проверяем что токен валиден и API доступен await apiListDevices(); // проверяем что токен валиден и API доступен
saveSettings(); saveSettings();
nav('devices'); nav('devices');
@@ -778,9 +1137,6 @@ async function doCreate() {
btn.disabled = true; btn.textContent = 'Создание...'; btn.disabled = true; btn.textContent = 'Создание...';
try { try {
// EnsureNamespace — идемпотентный вызов.
// Страховка на случай если сессия была сохранена до автосоздания namespace при логине.
await apiCall('POST', `/v1/namespaces/${S.ns}/ensure`, {});
await apiCreateDevice(name, did); await apiCreateDevice(name, did);
closeModal(); closeModal();
await renderDevices(); await renderDevices();
@@ -1329,4 +1685,5 @@ function copyVal(text, btn) {
render(); render();
</script> </script>
</body> </body>
</html> </html>