@danieltmn/openbridge 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +221 -144
- package/docs/ARCHITECTURE.md +49 -8
- package/package.json +1 -1
- package/src/auth.js +44 -23
- package/src/bridge/bridge.js +77 -1
- package/src/cli.js +155 -9
- package/src/config.js +74 -4
- package/src/web/assets/app.js +286 -43
- package/src/web/assets/manifest.webmanifest +2 -0
- package/src/web/assets/sw.js +1 -1
- package/src/web/routes.js +49 -13
- package/src/web/server.js +7 -0
- package/src/web/templates/chat.html +54 -2
- package/src/web/templates/login.html +5 -1
package/src/web/routes.js
CHANGED
|
@@ -18,6 +18,15 @@ const jsonfile = require('../store/jsonfile');
|
|
|
18
18
|
|
|
19
19
|
const TEMPLATES = path.join(__dirname, 'templates');
|
|
20
20
|
|
|
21
|
+
// Hash dummy para igualar el costo de scrypt cuando el usuario no existe (evita
|
|
22
|
+
// filtrar si un nombre esta registrado por diferencias de tiempo).
|
|
23
|
+
const DUMMY_HASH = { algo: 'scrypt', salt: '00000000000000000000000000000000', hash: '00'.repeat(64), keylen: 64 };
|
|
24
|
+
|
|
25
|
+
// Acciones que solo puede hacer un admin.
|
|
26
|
+
const ADMIN_ONLY_ACTIONS = new Set(['session_delete']);
|
|
27
|
+
// Comandos del puente que mutan algo (procesos, tuneles, revertir).
|
|
28
|
+
const OC_MUTATING = new Set(['proc_start', 'proc_stop', 'tunnel_start', 'tunnel_stop', 'git_checkout']);
|
|
29
|
+
|
|
21
30
|
async function renderTpl(name, vars) {
|
|
22
31
|
const tpl = await fsp.readFile(path.join(TEMPLATES, name), 'utf8');
|
|
23
32
|
return tpl.replace(/\{\{([A-Z_]+)\}\}/g, (m, k) => (
|
|
@@ -59,6 +68,12 @@ async function loginCsrf(req, res) {
|
|
|
59
68
|
return csrf;
|
|
60
69
|
}
|
|
61
70
|
|
|
71
|
+
// Ultimo usuario logueado (para prellenar el campo en el login).
|
|
72
|
+
function lastUser(req) {
|
|
73
|
+
const cookies = auth.parseCookies(req.headers.cookie);
|
|
74
|
+
return String(cookies['ob_lastuser'] || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 32);
|
|
75
|
+
}
|
|
76
|
+
|
|
62
77
|
async function handleLoginPage({ app, req, res, query }) {
|
|
63
78
|
if (auth.readSession(app, req)) {
|
|
64
79
|
res.writeHead(302, { Location: 'chat.php' });
|
|
@@ -71,6 +86,7 @@ async function handleLoginPage({ app, req, res, query }) {
|
|
|
71
86
|
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
72
87
|
ERROR_BLOCK: '',
|
|
73
88
|
CSRF: csrf,
|
|
89
|
+
USER_PREFILL: lastUser(req),
|
|
74
90
|
});
|
|
75
91
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
76
92
|
res.end(html);
|
|
@@ -87,25 +103,34 @@ async function handleLogin({ app, req, res, query, form }) {
|
|
|
87
103
|
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
88
104
|
ERROR_BLOCK: '<div class="error">\u26a0 ' + msg.replace(/[<>&]/g, (c) => ({ '<': '<', '>': '>', '&': '&' }[c])) + '</div>',
|
|
89
105
|
CSRF: csrf,
|
|
106
|
+
USER_PREFILL: lastUser(req) || String(form.username || '').replace(/[^A-Za-z0-9._-]/g, '').slice(0, 32),
|
|
90
107
|
});
|
|
91
108
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
92
109
|
res.end(html);
|
|
93
110
|
};
|
|
94
111
|
if (!csrfOk) return renderError('Sesion expirada, recarga la pagina.');
|
|
95
|
-
const
|
|
112
|
+
const username = String(form.username || '').trim();
|
|
113
|
+
const locked = auth.loginLockRemaining(req, username);
|
|
96
114
|
if (locked > 0) {
|
|
97
115
|
const mins = Math.max(1, Math.ceil(locked / 60));
|
|
98
116
|
return renderError('Demasiados intentos fallidos. Proba de nuevo en ' + mins + ' min.');
|
|
99
117
|
}
|
|
100
118
|
const password = String(form.password || '');
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
119
|
+
const user = config.findUser(app, username);
|
|
120
|
+
const passOk = config.verifyUserPassword(user, password);
|
|
121
|
+
if (!user) config.verifyPassword(password, DUMMY_HASH); // mismo costo si no existe
|
|
122
|
+
if (user && !user.disabled && passOk) {
|
|
123
|
+
auth.loginClear(req, username);
|
|
124
|
+
auth.startSession(app, req, res, user);
|
|
125
|
+
const prev = res.getHeader('Set-Cookie');
|
|
126
|
+
const jar = Array.isArray(prev) ? prev.slice() : (prev ? [prev] : []);
|
|
127
|
+
jar.push(auth.serializeCookie('ob_lastuser', user.name, req, 60 * 60 * 24 * 365));
|
|
128
|
+
res.setHeader('Set-Cookie', jar);
|
|
104
129
|
res.writeHead(302, { Location: 'chat.php' });
|
|
105
130
|
return res.end();
|
|
106
131
|
}
|
|
107
|
-
auth.loginRecordFailure(req);
|
|
108
|
-
return renderError('
|
|
132
|
+
auth.loginRecordFailure(req, username);
|
|
133
|
+
return renderError('Usuario o contrasena incorrectos.');
|
|
109
134
|
}
|
|
110
135
|
|
|
111
136
|
async function handleChat({ app, req, res, query }) {
|
|
@@ -122,6 +147,8 @@ async function handleChat({ app, req, res, query }) {
|
|
|
122
147
|
const html = await renderTpl('chat.html', {
|
|
123
148
|
THEME: pickTheme(req, query, known),
|
|
124
149
|
CSRF: session.c,
|
|
150
|
+
USER_NAME: session.name,
|
|
151
|
+
USER_ROLE: session.role,
|
|
125
152
|
INITIAL_SESSION: initial > 0 ? String(initial) : 'null',
|
|
126
153
|
THEMES_JSON: JSON.stringify(store.themesIndex()),
|
|
127
154
|
SSE_DISABLED: 'false',
|
|
@@ -171,6 +198,9 @@ const OC_ALLOWED = {
|
|
|
171
198
|
opencode_version: [],
|
|
172
199
|
fs_list: ['path'],
|
|
173
200
|
fs_read: ['path'],
|
|
201
|
+
git_status: ['path'],
|
|
202
|
+
git_diff: ['path'],
|
|
203
|
+
git_checkout: ['path', 'path'],
|
|
174
204
|
tunnel_start: ['arg'],
|
|
175
205
|
tunnel_stop: ['arg'],
|
|
176
206
|
tunnel_list: [],
|
|
@@ -256,8 +286,9 @@ async function handleApi(ctx) {
|
|
|
256
286
|
return ok({ ok: true, session: await store.getSession(id) });
|
|
257
287
|
}
|
|
258
288
|
case 'session_delete': {
|
|
259
|
-
|
|
260
|
-
if (!
|
|
289
|
+
const s = auth.requireCsrf(app, req, res);
|
|
290
|
+
if (!s) return;
|
|
291
|
+
if (s.role !== 'admin') return ok({ ok: false, error: 'Permiso insuficiente' }, 403);
|
|
261
292
|
const id = parseInt(body.id, 10) || 0;
|
|
262
293
|
if (id <= 0) return ok({ ok: false, error: 'id invalido' }, 400);
|
|
263
294
|
await store.deleteSession(id);
|
|
@@ -336,8 +367,8 @@ async function handleApi(ctx) {
|
|
|
336
367
|
return ok({ ok: true, cancel: false });
|
|
337
368
|
}
|
|
338
369
|
case 'send': {
|
|
339
|
-
|
|
340
|
-
if (!
|
|
370
|
+
const s = auth.requireCsrf(app, req, res);
|
|
371
|
+
if (!s) return;
|
|
341
372
|
const sid = parseInt(body.session, 10) || 0;
|
|
342
373
|
const sess = sid > 0 ? await store.getSession(sid) : null;
|
|
343
374
|
if (!sess) return ok({ ok: false, error: 'Sesion no encontrada' }, 404);
|
|
@@ -349,7 +380,7 @@ async function handleApi(ctx) {
|
|
|
349
380
|
}
|
|
350
381
|
if (text === '' && img === '') return ok({ ok: false, error: 'Mensaje vacio' }, 400);
|
|
351
382
|
if (Array.from(text).length > 10000) return ok({ ok: false, error: 'Mensaje demasiado largo' }, 400);
|
|
352
|
-
const extra = { agent: String(sess.agent || 'build') };
|
|
383
|
+
const extra = { agent: String(sess.agent || 'build'), author: s.name };
|
|
353
384
|
if (img !== '') extra.img = img;
|
|
354
385
|
const id = await store.addMessage(sid, 'user', text, 'pending', extra);
|
|
355
386
|
if (store.sessionHasDefaultName(sess)) {
|
|
@@ -649,11 +680,13 @@ async function handleApi(ctx) {
|
|
|
649
680
|
let aid = null;
|
|
650
681
|
const found = await store.messagesUpdate(sid, (data) => {
|
|
651
682
|
let msgFound = false;
|
|
683
|
+
let author = '';
|
|
652
684
|
for (const msg of data.messages) {
|
|
653
685
|
if (parseInt(msg.id, 10) === userId) {
|
|
654
686
|
msg.status = 'done';
|
|
655
687
|
msg.answered_ts = store.nowIso();
|
|
656
688
|
delete msg.cancel_requested;
|
|
689
|
+
if (typeof msg.author === 'string') author = msg.author;
|
|
657
690
|
msgFound = true;
|
|
658
691
|
break;
|
|
659
692
|
}
|
|
@@ -674,6 +707,7 @@ async function handleApi(ctx) {
|
|
|
674
707
|
if (reasoning !== '') m.reasoning = reasoning; else delete m.reasoning;
|
|
675
708
|
if (canceled) m.canceled = true; else delete m.canceled;
|
|
676
709
|
if (!m.agent) m.agent = store.messageAgentOf(data, userId, (sess && sess.agent) || '');
|
|
710
|
+
if (author && !m.author) m.author = author;
|
|
677
711
|
delete m.draft_for;
|
|
678
712
|
} else {
|
|
679
713
|
aid = data.nextId;
|
|
@@ -681,6 +715,7 @@ async function handleApi(ctx) {
|
|
|
681
715
|
const nm = { id: aid, role: 'assistant', text, ts: store.nowIso(), status: 'done', agent: store.messageAgentOf(data, userId, (sess && sess.agent) || '') };
|
|
682
716
|
if (reasoning !== '') nm.reasoning = reasoning;
|
|
683
717
|
if (canceled) nm.canceled = true;
|
|
718
|
+
if (author) nm.author = author;
|
|
684
719
|
data.messages.push(nm);
|
|
685
720
|
}
|
|
686
721
|
});
|
|
@@ -720,13 +755,14 @@ async function handleApi(ctx) {
|
|
|
720
755
|
return ok({ ok: true, path: rel, size, mtime: Math.floor(st.mtimeMs / 1000), kind: 'text', content });
|
|
721
756
|
}
|
|
722
757
|
case 'run_oc': {
|
|
723
|
-
|
|
724
|
-
if (!
|
|
758
|
+
const s = auth.requireCsrf(app, req, res);
|
|
759
|
+
if (!s) return;
|
|
725
760
|
const bridge = bodyBridge(req, query, body) || await webBridge(req, query);
|
|
726
761
|
const file = paths.bridgeCatalogFile(bridge);
|
|
727
762
|
const cmd = String(body.cmd || '').toLowerCase().replace(/[^a-z_]/g, '');
|
|
728
763
|
const args = Array.isArray(body.args) ? body.args : [];
|
|
729
764
|
if (!Object.prototype.hasOwnProperty.call(OC_ALLOWED, cmd)) return ok({ ok: false, error: 'Comando no permitido' }, 400);
|
|
765
|
+
if (OC_MUTATING.has(cmd) && s.role !== 'admin') return ok({ ok: false, error: 'Permiso insuficiente' }, 403);
|
|
730
766
|
const spec = OC_ALLOWED[cmd];
|
|
731
767
|
const cleanArgs = [];
|
|
732
768
|
for (let i = 0; i < args.length; i++) {
|
package/src/web/server.js
CHANGED
|
@@ -70,6 +70,13 @@ function securityHeaders(res) {
|
|
|
70
70
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
71
71
|
res.setHeader('Referrer-Policy', 'no-referrer');
|
|
72
72
|
res.setHeader('X-Frame-Options', 'DENY');
|
|
73
|
+
// La app usa scripts/estilos inline (templates) y data: para imagenes
|
|
74
|
+
// adjuntas. Igual bloquea origenes externos, frames y objetos.
|
|
75
|
+
res.setHeader('Content-Security-Policy',
|
|
76
|
+
"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
|
|
77
|
+
+ "script-src 'self' 'unsafe-inline'; connect-src 'self'; worker-src 'self'; "
|
|
78
|
+
+ "manifest-src 'self'; object-src 'none'; base-uri 'none'; "
|
|
79
|
+
+ "frame-ancestors 'none'; form-action 'self'");
|
|
73
80
|
}
|
|
74
81
|
|
|
75
82
|
async function handle(app, req, res) {
|
|
@@ -280,6 +280,11 @@
|
|
|
280
280
|
text-align: center;
|
|
281
281
|
border: 1px solid var(--border);
|
|
282
282
|
}
|
|
283
|
+
#sidebar .footer .userbadge {
|
|
284
|
+
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
285
|
+
color: var(--muted); font-size: 12px;
|
|
286
|
+
}
|
|
287
|
+
#sidebar .footer .userbadge.admin { color: var(--accent); }
|
|
283
288
|
#scrim {
|
|
284
289
|
display: none;
|
|
285
290
|
position: fixed; inset: 0;
|
|
@@ -768,6 +773,17 @@
|
|
|
768
773
|
.slash-panel .item.sel, .slash-panel .item:hover { background: var(--accent-soft); }
|
|
769
774
|
.slash-panel .item b { color: var(--mine); font-weight: 600; }
|
|
770
775
|
.slash-panel .item span { display: block; color: var(--muted); font-size: 11px; margin-top: 1px; }
|
|
776
|
+
.tpl-panel .tpl-row { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 8px; cursor: pointer; font-size: 13.5px; }
|
|
777
|
+
.tpl-panel .tpl-row:hover { background: var(--accent-soft); }
|
|
778
|
+
.tpl-panel .tpl-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
779
|
+
.tpl-panel .tpl-del { background: transparent; border: 0; color: var(--muted); cursor: pointer; font: inherit; padding: 2px 6px; border-radius: 6px; flex-shrink: 0; }
|
|
780
|
+
.tpl-panel .tpl-del:hover { color: var(--danger); }
|
|
781
|
+
.tpl-panel .tpl-empty { color: var(--muted); font-size: 12px; padding: 8px 10px; line-height: 1.4; }
|
|
782
|
+
.tpl-panel .tpl-save {
|
|
783
|
+
display: block; width: 100%; margin-top: 6px; padding: 8px; border: 1px dashed var(--border);
|
|
784
|
+
border-radius: 8px; background: transparent; color: var(--muted); font: inherit; font-size: 12.5px; cursor: pointer;
|
|
785
|
+
}
|
|
786
|
+
.tpl-panel .tpl-save:hover { color: var(--accent); border-color: var(--accent); }
|
|
771
787
|
form.sendbar button {
|
|
772
788
|
padding: 9px 16px; border: 1px solid var(--mine); border-radius: 8px; background: transparent; color: var(--mine);
|
|
773
789
|
font: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer;
|
|
@@ -958,6 +974,33 @@
|
|
|
958
974
|
color: var(--muted); font-size: 13px; padding: 24px 0; text-align: center;
|
|
959
975
|
}
|
|
960
976
|
|
|
977
|
+
/* ----- Cambios (git) ----- */
|
|
978
|
+
.chg-files { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 0 0 14px; }
|
|
979
|
+
.chg-row { display: grid; grid-template-columns: 34px 1fr auto; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13px; align-items: center; }
|
|
980
|
+
.chg-row:last-child { border-bottom: 0; }
|
|
981
|
+
.chg-rev {
|
|
982
|
+
background: transparent; border: 1px solid var(--border); color: var(--muted);
|
|
983
|
+
border-radius: 6px; width: 26px; height: 24px; line-height: 1; cursor: pointer; font: inherit; font-size: 12px;
|
|
984
|
+
}
|
|
985
|
+
.chg-rev:hover { color: var(--danger); border-color: var(--danger); }
|
|
986
|
+
.chg-rev:disabled { opacity: .4; cursor: not-allowed; }
|
|
987
|
+
.chg-st { font-weight: 700; text-align: center; font-size: 11px; color: var(--muted); }
|
|
988
|
+
.chg-st.mod { color: var(--warn-text, #d29922); }
|
|
989
|
+
.chg-st.add { color: var(--accent); }
|
|
990
|
+
.chg-st.del { color: var(--danger); }
|
|
991
|
+
.chg-path { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
992
|
+
.diff {
|
|
993
|
+
background: var(--code-bg, var(--bg)); border: 1px solid var(--border);
|
|
994
|
+
border-radius: 8px; overflow: auto; padding: 10px 0; margin: 0 0 20px;
|
|
995
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
996
|
+
font-size: 12px; line-height: 1.5;
|
|
997
|
+
}
|
|
998
|
+
.diff-line { white-space: pre; padding: 0 12px; }
|
|
999
|
+
.diff-line.add { background: rgba(63, 185, 80, .12); color: #3fb950; }
|
|
1000
|
+
.diff-line.del { background: rgba(248, 81, 73, .12); color: #f85149; }
|
|
1001
|
+
.diff-line.hunk { color: var(--accent); }
|
|
1002
|
+
.diff-line.head { color: var(--muted); }
|
|
1003
|
+
|
|
961
1004
|
.view-head { margin-bottom: 12px; }
|
|
962
1005
|
.view-head h2 { margin: 0 0 4px; font-size: 16px; }
|
|
963
1006
|
.view-sub { font-size: 11.5px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
@@ -1159,13 +1202,17 @@
|
|
|
1159
1202
|
<button data-view="search" type="button" title="Buscar">buscar</button>
|
|
1160
1203
|
<button data-view="preview" type="button" title="Vista previa del proyecto (túnel)">preview</button>
|
|
1161
1204
|
<button data-view="procs" type="button" title="Procesos de la sesión">procs</button>
|
|
1205
|
+
<button data-view="changes" type="button" title="Cambios del proyecto (git)">cambios</button>
|
|
1162
1206
|
</div>
|
|
1163
1207
|
<div class="footer">
|
|
1164
1208
|
<div class="row" id="bridgeStatusRow">
|
|
1165
1209
|
<span class="dot" id="bridgeDot"></span>
|
|
1166
1210
|
<span id="bridgeStatusText">puente: desconocido</span>
|
|
1167
1211
|
</div>
|
|
1168
|
-
<
|
|
1212
|
+
<div class="row">
|
|
1213
|
+
<span class="userbadge" id="userBadge" title="sesión actual">{{USER_NAME}}</span>
|
|
1214
|
+
<a class="logout" href="logout.php">salir</a>
|
|
1215
|
+
</div>
|
|
1169
1216
|
</div>
|
|
1170
1217
|
<div class="resizer" id="sbResizer" title="Arrastrá para ajustar · doble clic para restablecer"></div>
|
|
1171
1218
|
</aside>
|
|
@@ -1221,11 +1268,13 @@
|
|
|
1221
1268
|
</div>
|
|
1222
1269
|
<span class="prompt">❯</span>
|
|
1223
1270
|
<textarea id="input" rows="1" placeholder="escribí un mensaje…" autocomplete="off"></textarea>
|
|
1271
|
+
<button type="button" class="imgbtn" id="btnTpl" title="Plantillas de prompts" aria-label="Plantillas de prompts"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M17.593 3.322c1.1.128 1.907 1.077 1.907 2.185V21L12 17.25 4.5 21V5.507c0-1.108.806-2.057 1.907-2.185a48.507 48.507 0 0 1 11.186 0Z"/></svg></button>
|
|
1224
1272
|
<button type="button" class="imgbtn" id="btnImg" title="Adjuntar imagen (modelos con visión)" aria-label="Adjuntar imagen"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z"/></svg></button>
|
|
1225
1273
|
<button type="button" class="imgbtn" id="btnMic" title="Dictar por voz" aria-label="Dictar por voz"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 1 1 6 0v8.25a3 3 0 0 1-3 3Z"/></svg></button>
|
|
1226
1274
|
<input type="file" id="imgInput" accept="image/png,image/jpeg,image/webp,image/gif" style="display:none">
|
|
1227
1275
|
<button type="submit" id="sendBtn">enviar</button>
|
|
1228
1276
|
<div id="slashPanel" class="slash-panel"></div>
|
|
1277
|
+
<div id="tplPanel" class="slash-panel tpl-panel"></div>
|
|
1229
1278
|
</form>
|
|
1230
1279
|
</div>
|
|
1231
1280
|
|
|
@@ -1235,6 +1284,7 @@
|
|
|
1235
1284
|
<div id="viewHistory" class="view" style="display:none"></div>
|
|
1236
1285
|
<div id="viewPreview" class="view" style="display:none"></div>
|
|
1237
1286
|
<div id="viewProcs" class="view" style="display:none"></div>
|
|
1287
|
+
<div id="viewChanges" class="view" style="display:none"></div>
|
|
1238
1288
|
|
|
1239
1289
|
<footer id="statusbar">
|
|
1240
1290
|
<span class="dot" id="sbDot"></span>
|
|
@@ -1302,7 +1352,9 @@
|
|
|
1302
1352
|
themes: {{THEMES_JSON}},
|
|
1303
1353
|
sseDisabled: {{SSE_DISABLED}},
|
|
1304
1354
|
pushEnabled: {{PUSH_ENABLED}},
|
|
1305
|
-
pushKey: "{{PUSH_KEY}}"
|
|
1355
|
+
pushKey: "{{PUSH_KEY}}",
|
|
1356
|
+
userName: "{{USER_NAME}}",
|
|
1357
|
+
userRole: "{{USER_ROLE}}"
|
|
1306
1358
|
};
|
|
1307
1359
|
</script>
|
|
1308
1360
|
<script src="app.js?v={{APPJS_VER}}" defer></script>
|
|
@@ -111,7 +111,11 @@
|
|
|
111
111
|
<input type="hidden" name="csrf" value="{{CSRF}}">
|
|
112
112
|
<label class="field">
|
|
113
113
|
<span class="prompt">❯</span>
|
|
114
|
-
<input type="
|
|
114
|
+
<input type="text" id="username" name="username" placeholder="usuario" required autofocus autocomplete="username" value="{{USER_PREFILL}}">
|
|
115
|
+
</label>
|
|
116
|
+
<label class="field">
|
|
117
|
+
<span class="prompt">❯</span>
|
|
118
|
+
<input type="password" id="password" name="password" placeholder="contraseña" required autocomplete="current-password">
|
|
115
119
|
<button type="button" class="peek" id="btnPeek" aria-label="Mostrar u ocultar contraseña" tabindex="-1">👁</button>
|
|
116
120
|
</label>
|
|
117
121
|
<label class="keep" for="keep">
|