mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-22 12:45:25 -04:00
Merge origin/dev into main
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 174 B |
+2
-2
@@ -1918,7 +1918,7 @@
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>Change Password</h2>
|
||||
<div class="settings-col">
|
||||
<input id="settings-pw-current" type="password" placeholder="Current password" autocomplete="current-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<input id="settings-pw-new" type="password" placeholder="New password (min 8)" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<input id="settings-pw-new" type="password" placeholder="New password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<input id="settings-pw-confirm" type="password" placeholder="Confirm new password" autocomplete="new-password" style="padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--fg);font-family:inherit;font-size:12px;">
|
||||
<div class="settings-row" style="margin-top:2px;justify-content:flex-end;">
|
||||
<span id="settings-pw-msg" style="font-size:11px;margin-right:auto;"></span>
|
||||
@@ -2054,7 +2054,7 @@
|
||||
<h2><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:5px;opacity:0.6"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>Add User</h2>
|
||||
<div class="admin-add-form">
|
||||
<input id="adm-newUsername" type="text" placeholder="Username">
|
||||
<input id="adm-newPassword" type="password" placeholder="Password (min 8)">
|
||||
<input id="adm-newPassword" type="password" placeholder="Password">
|
||||
<div class="admin-switch-inline" title="Grant full admin access"><label class="admin-switch"><input type="checkbox" id="adm-newIsAdmin"><span class="admin-slider"></span></label> Admin</div>
|
||||
</div>
|
||||
<div class="settings-row" style="margin-top:6px;">
|
||||
|
||||
+12
-2
@@ -13,6 +13,7 @@ let modalEl = null;
|
||||
// the endpoints list can flash a glow on that row. Cleared once the
|
||||
// animation fires.
|
||||
let _recentlyAddedEpId = null;
|
||||
let _authPolicy = { password_min_length: 8, reserved_usernames: [] };
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { return uiModule.esc(s); }
|
||||
@@ -343,6 +344,15 @@ function initSignupToggle() {
|
||||
}
|
||||
|
||||
function initAddUser() {
|
||||
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(policy => {
|
||||
if (!policy) return;
|
||||
_authPolicy = policy;
|
||||
const admPw = el('adm-newPassword');
|
||||
if (admPw) admPw.placeholder = `Password (min ${policy.password_min_length})`;
|
||||
})
|
||||
.catch(() => {});
|
||||
el('adm-addBtn').addEventListener('click', async () => {
|
||||
const msg = el('adm-addMsg');
|
||||
msg.textContent = ''; msg.className = '';
|
||||
@@ -350,7 +360,8 @@ function initAddUser() {
|
||||
const password = el('adm-newPassword').value;
|
||||
const is_admin = el('adm-newIsAdmin').checked;
|
||||
if (!username) { msg.textContent = 'Username required'; msg.className = 'admin-error'; return; }
|
||||
if (password.length < 8) { msg.textContent = 'Password must be at least 8 characters'; msg.className = 'admin-error'; return; }
|
||||
if (password.length < _authPolicy.password_min_length) { msg.textContent = `Password must be at least ${_authPolicy.password_min_length} characters`; msg.className = 'admin-error'; return; }
|
||||
if (_authPolicy.reserved_usernames.includes(username.toLowerCase())) { msg.textContent = 'This username is reserved'; msg.className = 'admin-error'; return; }
|
||||
el('adm-addBtn').disabled = true;
|
||||
try {
|
||||
const res = await fetch('/api/auth/users', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password, is_admin }) });
|
||||
@@ -1745,7 +1756,6 @@ const TOOL_META = {
|
||||
manage_skills: { name: 'Skills', desc: 'Learn and use procedures', cat: 'Knowledge', ctx: '~200' },
|
||||
manage_rag: { name: 'RAG / Docs', desc: 'Query indexed documents', cat: 'Knowledge', ctx: '~150' },
|
||||
chat_with_model: { name: 'Chat with Model', desc: 'Talk to another AI model', cat: 'Multi-Agent', ctx: '~200' },
|
||||
second_opinion: { name: 'Second Opinion', desc: 'Get another model\'s take', cat: 'Multi-Agent', ctx: '~150' },
|
||||
pipeline: { name: 'Pipeline', desc: 'Multi-step AI workflows', cat: 'Multi-Agent', ctx: '~200' },
|
||||
ask_teacher: { name: 'Ask Teacher', desc: 'Query a more capable model', cat: 'Multi-Agent', ctx: '~150' },
|
||||
send_to_session: { name: 'Send to Session', desc: 'Send message to another chat', cat: 'Sessions', ctx: '~100' },
|
||||
|
||||
@@ -125,7 +125,7 @@ const TOOL_GROUPS = {
|
||||
'Knowledge': ['web_search', 'read_file', 'manage_memory', 'manage_rag', 'search_chats'],
|
||||
'Code': ['bash', 'python', 'write_file'],
|
||||
'Documents': ['create_document', 'edit_document', 'update_document', 'suggest_document'],
|
||||
'AI & Models': ['chat_with_model', 'second_opinion', 'ask_teacher', 'pipeline', 'list_models', 'generate_image'],
|
||||
'AI & Models': ['chat_with_model', 'ask_teacher', 'pipeline', 'list_models', 'generate_image'],
|
||||
'System': ['manage_session', 'manage_endpoints', 'manage_mcp', 'manage_settings', 'manage_skills', 'manage_webhooks', 'manage_tokens', 'manage_documents', 'create_session', 'list_sessions', 'send_to_session', 'ui_control'],
|
||||
};
|
||||
|
||||
|
||||
@@ -413,7 +413,7 @@ function _calEventFg(ev) {
|
||||
// Returns '' for normal solid-color events.
|
||||
function _calItemBgStyle(ev) {
|
||||
if (!_isCalBgImage(ev.color)) return '';
|
||||
const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'");
|
||||
const url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
||||
return `background-image: linear-gradient(color-mix(in srgb, var(--bg) 70%, transparent), color-mix(in srgb, var(--bg) 70%, transparent)), url('${url}'); background-size: cover; background-position: center;`;
|
||||
}
|
||||
|
||||
@@ -1260,7 +1260,7 @@ async function _renderWeek() {
|
||||
// events keep the original tinted treatment.
|
||||
let bgDecl;
|
||||
if (_isCalBgImage(ev.color)) {
|
||||
const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'");
|
||||
const _url = _calBgImageUrl(ev.color).replace(/'/g, "\\'").replace(/"/g, "%22");
|
||||
bgDecl = `background-image: linear-gradient(color-mix(in srgb, var(--bg) 55%, transparent), color-mix(in srgb, var(--bg) 55%, transparent)), url('${_url}'); background-size: cover; background-position: center;`;
|
||||
} else {
|
||||
bgDecl = `background:color-mix(in srgb, ${_calColor(ev)} 18%, var(--bg));`;
|
||||
|
||||
@@ -635,8 +635,8 @@ export function applyModelColor(roleEl, modelName) {
|
||||
popup.className = 'ctx-popup';
|
||||
let html = '<div style="font-weight:600;margin-bottom:6px;color:var(--fg);display:flex;align-items:center;gap:6px;">';
|
||||
if (logoHtml) html += '<span class="role-provider-logo" style="opacity:0.7">' + logoHtml + '</span>';
|
||||
html += short + '</div>';
|
||||
html += '<div><span class="ctx-label">Model</span> ' + modelName.split('/').pop() + '</div>';
|
||||
html += uiModule.esc(short) + '</div>';
|
||||
html += '<div><span class="ctx-label">Model</span> ' + uiModule.esc(modelName.split('/').pop()) + '</div>';
|
||||
// Provider = the serving endpoint, distinct from the model vendor/logo
|
||||
// (e.g. the same model via OpenRouter vs Copilot vs Anthropic direct).
|
||||
const _epUrl = (window.sessionModule && window.sessionModule.getCurrentEndpointUrl)
|
||||
|
||||
@@ -56,7 +56,7 @@ const _RECIPES = [
|
||||
match: () => true,
|
||||
variants: {
|
||||
pip: { commands: ['CMAKE_ARGS="-DGGML_CUDA=on" uv pip install -U "llama-cpp-python[server]"'] },
|
||||
docker: { commands: ['docker pull ghcr.io/ggerganov/llama.cpp:server-cuda'] },
|
||||
docker: { commands: ['docker pull ghcr.io/ggml-org/llama.cpp:server-cuda'] },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -612,24 +612,50 @@ export const ERROR_PATTERNS = [
|
||||
],
|
||||
},
|
||||
{
|
||||
// Tail-only + healthy-server suppression. tmux capture-pane returns the
|
||||
// entire scrollback every poll, so a one-shot startup traceback would
|
||||
// otherwise stick on the panel forever even while the server happily
|
||||
// serves /v1/models. Only fire if the traceback is in recent output AND
|
||||
// the server isn't currently logging healthy traffic.
|
||||
// Dependency-install (pip) build failure — a required package failed to
|
||||
// build its wheel (common when an old sdist's setup.py breaks on a newer
|
||||
// Python, e.g. basicsr on 3.13). This is an install problem, NOT a serve
|
||||
// problem, so it must never suggest killing vLLM.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-6000);
|
||||
// A serve script can run a fallback build and then start serving fine —
|
||||
// don't flag a stale build error once the server is up.
|
||||
if (/Application startup complete|"(?:GET|POST)\s+\/v1\/[^"]+ HTTP\/[\d.]+"\s*2\d\d|Uvicorn running on|server is listening on https?:\/\//i.test(TAIL)) return false;
|
||||
return /Failed to build\b|subprocess-exited-with-error|Could not build wheels|metadata-generation-failed/i.test(TAIL);
|
||||
},
|
||||
message: 'A dependency failed to build during install — usually an older package whose build breaks on this Python version, not a server problem. The install did not finish.',
|
||||
suggestion: 'Suggested action: check the captured output for the package that failed to build; it may need a newer release or a patch to install on this Python version.',
|
||||
fixes: [],
|
||||
},
|
||||
{
|
||||
// vLLM-specific traceback: only offer the kill-processes recovery when the
|
||||
// output is actually about vLLM. Tail-only + healthy-server suppression so
|
||||
// a one-shot startup traceback doesn't stick on the panel forever while
|
||||
// the server happily serves /v1/models.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-4096);
|
||||
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
|
||||
// Healthy markers in the tail mean whatever blew up has been recovered
|
||||
// from — the server is up and answering requests.
|
||||
if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false;
|
||||
return true;
|
||||
return /vllm/i.test(TAIL);
|
||||
},
|
||||
message: 'Python traceback detected — may be a handled error, check logs.',
|
||||
message: 'A vLLM process hit a Python traceback and may be wedged.',
|
||||
fixes: [
|
||||
{ label: 'Kill vLLM processes', action: (panel) => _runQuickCmd(panel, 'pkill -f vllm') },
|
||||
],
|
||||
},
|
||||
{
|
||||
// Generic traceback (not vLLM, not a pip build): surface it without
|
||||
// suggesting an unrelated vLLM kill. Same tail-only + healthy suppression.
|
||||
match: (text) => {
|
||||
const TAIL = text.slice(-4096);
|
||||
if (!/Traceback \(most recent call last\)/i.test(TAIL)) return false;
|
||||
if (/Application startup complete|"GET \/v1\/[^"]+ HTTP\/[\d.]+" 2\d\d|Uvicorn running on/i.test(TAIL)) return false;
|
||||
return true;
|
||||
},
|
||||
message: 'Python traceback detected — check the captured output below for the underlying error.',
|
||||
suggestion: 'Suggested action: read the captured output for the failing step; copy the troubleshooting bundle if you need help.',
|
||||
fixes: [],
|
||||
},
|
||||
];
|
||||
|
||||
export function _diagnose(text) {
|
||||
|
||||
@@ -2958,10 +2958,13 @@ export async function open(opts) {
|
||||
// returned before hydration — and since close/reopen doesn't reset the page,
|
||||
// only a full reload recovered it. Re-rendering is cheap and the in-progress
|
||||
// Running tab is rendered separately just below.
|
||||
_renderRecipes();
|
||||
// Guard the render passes: a single broken task card must not throw out of
|
||||
// open() and leave the modal stuck hidden (it has no catch, so the panel
|
||||
// would silently never appear). Show the window regardless; log and move on.
|
||||
try { _renderRecipes(); } catch (e) { console.error('[cookbook] renderRecipes failed', e); }
|
||||
_rendered = true;
|
||||
_clearCookbookNotif();
|
||||
_renderRunningTab();
|
||||
try { _renderRunningTab(); } catch (e) { console.error('[cookbook] renderRunningTab failed', e); }
|
||||
// Self-heal: revive any download tasks whose tmux session is still alive
|
||||
// but were persisted as done/error (covers the "restarted server while a
|
||||
// big multi-shard download was in flight" case — the task survived in
|
||||
|
||||
@@ -804,40 +804,47 @@ function _winSessionCmd(task, tmuxArgs) {
|
||||
const ps = host
|
||||
? `Get-Content '${sd}\\${sid}.log' -Tail ${lines} -ErrorAction SilentlyContinue`
|
||||
: `Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.log') -Tail ${lines} -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (tmuxArgs.includes('has-session')) {
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Get-Process -Id $p -ErrorAction SilentlyContinue | Out-Null; if ($?) { exit 0 } else { exit 1 } } else { exit 1 }`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Get-Process -Id $p -ErrorAction SilentlyContinue | Out-Null; if ($?) { exit 0 } else { exit 1 } } else { exit 1 }`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (tmuxArgs.includes('kill-session')) {
|
||||
const stopTree = `function Stop-Tree([int]$Id) { Get-CimInstance Win32_Process -Filter "ParentProcessId = $Id" -ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }`;
|
||||
const ps = host
|
||||
? `${stopTree}; $p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `${stopTree}; $p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
const ps = _winSessionStopTreePs(task);
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (tmuxArgs.includes('send-keys') && tmuxArgs.includes('C-c')) {
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -ErrorAction SilentlyContinue }`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
return host ? `ssh ${pf}${host} 'tmux ${tmuxArgs}' 2>/dev/null` : `tmux ${tmuxArgs} 2>/dev/null`;
|
||||
}
|
||||
|
||||
function _winPowerShellCmd(task, ps) {
|
||||
const command = `powershell -Command "${ps}"`;
|
||||
if (!task.remoteHost) return command;
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} ${_shQuote(command)}`;
|
||||
}
|
||||
|
||||
function _winSessionStopTreePs(task) {
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
const sid = task.sessionId;
|
||||
const stopTree = `function Stop-Tree([int]$Id) { Get-CimInstance Win32_Process -Filter ('ParentProcessId = ' + $Id) -ErrorAction SilentlyContinue | ForEach-Object { Stop-Tree ([int]$_.ProcessId) }; Stop-Process -Id $Id -Force -ErrorAction SilentlyContinue }`;
|
||||
return host
|
||||
? `${stopTree}; $p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `${stopTree}; $p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p -match '^\\d+$') { Stop-Tree ([int]$p) }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
}
|
||||
|
||||
export function _tmuxGracefulKill(task) {
|
||||
if (_isWindows(task)) {
|
||||
const host = task.remoteHost;
|
||||
const sd = host ? '$env:TEMP\\odysseus-sessions' : '$env:TEMP\\odysseus-tmux';
|
||||
const sid = task.sessionId;
|
||||
const pf = _sshPrefix(_getPort(task));
|
||||
const ps = host
|
||||
? `$p = Get-Content '${sd}\\${sid}.pid' -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item '${sd}\\${sid}.*' -Force -ErrorAction SilentlyContinue`
|
||||
: `$p = Get-Content (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.pid') -ErrorAction SilentlyContinue; if ($p) { Stop-Process -Id $p -Force -ErrorAction SilentlyContinue }; Remove-Item (Join-Path $env:TEMP 'odysseus-tmux\\${sid}.*') -Force -ErrorAction SilentlyContinue`;
|
||||
return host ? `ssh ${pf}${host} "powershell -Command \\"${ps}\\""` : `powershell -Command "${ps}"`;
|
||||
const ps = _winSessionStopTreePs(task);
|
||||
return _winPowerShellCmd(task, ps);
|
||||
}
|
||||
if (task.remoteHost) {
|
||||
return `ssh ${_sshPrefix(_getPort(task))}${task.remoteHost} 'tmux send-keys -t ${task.sessionId} C-c 2>/dev/null; sleep 2; tmux kill-session -t ${task.sessionId} 2>/dev/null'`;
|
||||
|
||||
+41
-47
@@ -499,13 +499,28 @@ function _selectedServeTarget(panel) {
|
||||
host,
|
||||
serverKey: server ? (_serverKey?.(server) || '') : (select?.value || ''),
|
||||
serverName: server?.name || '',
|
||||
port: host ? (_getPort(host) || server?.port || '') : '',
|
||||
env: server?.env || '',
|
||||
port: host ? (server?.port || _getPort(host) || '') : '',
|
||||
venv,
|
||||
platform: server?.platform || _envState.platform || '',
|
||||
label,
|
||||
};
|
||||
}
|
||||
|
||||
function _remoteWindowsDiffusersUnsupported(target) {
|
||||
return !!(target?.host && target?.platform === 'windows');
|
||||
}
|
||||
|
||||
function _backendChoicesForTarget(target) {
|
||||
if (target?.platform === 'windows') {
|
||||
if (_remoteWindowsDiffusersUnsupported(target)) return [['llamacpp','llama.cpp']];
|
||||
return [['llamacpp','llama.cpp'],['diffusers','Diffusers']];
|
||||
}
|
||||
return _isMetal()
|
||||
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['diffusers','Diffusers']];
|
||||
}
|
||||
|
||||
async function _fetchServeRuntimePackage(panel, backend) {
|
||||
const packageByBackend = {
|
||||
vllm: 'vllm',
|
||||
@@ -948,13 +963,14 @@ function _rerenderCachedModels() {
|
||||
const _isMiniMaxM2 = _isMiniMaxM2Model({ ...m, repo_id: repo });
|
||||
const _isMiniMaxMSeries = _isMiniMaxM3 || _isMiniMaxM2;
|
||||
const svm = (k, def) => (_modelSs && _hasOwn(_modelSs, k)) ? _modelSs[k] : def;
|
||||
const _serveTarget = _selectedServeTarget();
|
||||
const _backendChoices = _backendChoicesForTarget(_serveTarget);
|
||||
const _allowedBackends = new Set(_backendChoices.map(([v]) => v));
|
||||
const detectedBackend = _detectBackend(m).backend;
|
||||
const _allowedBackends = new Set(_isWindows()
|
||||
? ['llamacpp', 'diffusers']
|
||||
: (_isMetal() ? ['llamacpp', 'ollama'] : ['vllm', 'sglang', 'llamacpp', 'ollama', 'diffusers']));
|
||||
const defaultBackend = (_repoForcedBackend && ss.backend && _allowedBackends.has(ss.backend))
|
||||
let defaultBackend = (_repoForcedBackend && ss.backend && _allowedBackends.has(ss.backend))
|
||||
? ss.backend
|
||||
: detectedBackend;
|
||||
if (!_allowedBackends.has(defaultBackend)) defaultBackend = _backendChoices[0]?.[0] || detectedBackend;
|
||||
const savedMatchesBackend = _repoForcedBackend || (ss.backend || 'vllm') === detectedBackend;
|
||||
const sv = (k, def) => (ss[k] !== undefined && savedMatchesBackend) ? ss[k] : def;
|
||||
const defaultTp = defaultBackend === 'llamacpp' ? '1' : sv('tp', _isMiniMaxMSeries ? '8' : '1');
|
||||
@@ -1027,12 +1043,6 @@ function _rerenderCachedModels() {
|
||||
panelHtml += `<div class="hwfit-serve-preset-row">${_slotsHtml}</div>`;
|
||||
// Row 1: Engine + Server + Env
|
||||
panelHtml += `<div class="hwfit-serve-row">`;
|
||||
const _backendChoices = _isWindows()
|
||||
? [['llamacpp','llama.cpp'],['diffusers','Diffusers']]
|
||||
: _isMetal()
|
||||
// Diffusers (diffusion_server.py) is CUDA-only — omit it on Metal.
|
||||
? [['llamacpp','llama.cpp'],['ollama','Ollama']]
|
||||
: [['vllm','vLLM'],['sglang','SGLang'],['llamacpp','llama.cpp'],['ollama','Ollama'],['diffusers','Diffusers']];
|
||||
const backendOpts = _backendChoices.map(([v,l]) => `<option value="${v}"${defaultBackend===v?' selected':''}>${l}</option>`).join('');
|
||||
// Custom Backend picker — native <select> can't host SVG inside
|
||||
// options, so we render a button + menu that show the backend logo
|
||||
@@ -2770,30 +2780,12 @@ function _rerenderCachedModels() {
|
||||
else serveState[el.dataset.field] = el.value;
|
||||
});
|
||||
serveState.backend = serveState.backend || (_detectBackend(m).backend) || 'vllm';
|
||||
// Resolve the target host from the visible Server dropdown before any
|
||||
// preflight checks. _envState.remoteHost can lag behind the panel's
|
||||
// selected server, which made cross-server serves look like port
|
||||
// collisions.
|
||||
const _selectedServeTarget = (() => {
|
||||
const target = { host: _envState.remoteHost || '', serverKey: '', serverName: '', env: '', envPath: '' };
|
||||
const ssEl = document.getElementById('hwfit-server-select') || document.getElementById('hwfit-dl-server');
|
||||
if (!ssEl || ssEl.value == null) return target;
|
||||
if (ssEl.value === 'local') {
|
||||
target.host = '';
|
||||
target.serverKey = 'local';
|
||||
target.serverName = 'Local';
|
||||
return target;
|
||||
}
|
||||
const srv = _serverByVal?.(ssEl.value) || _envState.servers[parseInt(ssEl.value)];
|
||||
if (srv) {
|
||||
target.host = srv.host || '';
|
||||
target.serverKey = _serverKey?.(srv) || ssEl.value || '';
|
||||
target.serverName = srv.name || srv.host || '';
|
||||
target.env = srv.env || '';
|
||||
target.envPath = srv.envPath || '';
|
||||
}
|
||||
return target;
|
||||
})();
|
||||
const launchTarget = _selectedServeTarget(panel);
|
||||
if (serveState.backend === 'diffusers' && _remoteWindowsDiffusersUnsupported(launchTarget)) {
|
||||
_restoreLaunchBtn();
|
||||
uiModule.showToast('Diffusers serving is not supported on remote Windows servers yet. Use local Windows or a Linux server.', 9000);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pre-launch: check our own task list for a serve already running
|
||||
// on this host. Offer to stop+launch as the default action — the
|
||||
@@ -2802,8 +2794,8 @@ function _rerenderCachedModels() {
|
||||
// common case instantly without waiting for a network round-trip.
|
||||
try {
|
||||
const _runningMod = await import('./cookbookRunning.js');
|
||||
const _hostStr = _selectedServeTarget.host || '';
|
||||
const _serverKeyStr = _selectedServeTarget.serverKey || (_hostStr || 'local');
|
||||
const _hostStr = launchTarget.host || '';
|
||||
const _serverKeyStr = launchTarget.serverKey || (_hostStr || 'local');
|
||||
const _active = (_runningMod._loadTasks ? _runningMod._loadTasks() : []).filter(t =>
|
||||
t && t.type === 'serve'
|
||||
&& ((t.remoteHost || '') === _hostStr || (t.remoteServerKey || '') === _serverKeyStr)
|
||||
@@ -2991,12 +2983,11 @@ function _rerenderCachedModels() {
|
||||
|| (serveState.backend === 'diffusers');
|
||||
if (_needsGpu) {
|
||||
try {
|
||||
const _probeHost = (_selectedServeTarget.host || '').trim();
|
||||
const _probeHost = (launchTarget.host || '').trim();
|
||||
const _probeParams = new URLSearchParams();
|
||||
if (_probeHost) {
|
||||
_probeParams.set('host', _probeHost);
|
||||
const _sp = (_serverByVal?.(_selectedServeTarget.serverKey || _probeHost) || {}).port;
|
||||
if (_sp) _probeParams.set('ssh_port', _sp);
|
||||
if (launchTarget.port) _probeParams.set('ssh_port', launchTarget.port);
|
||||
}
|
||||
const _probeRes = await fetch('/api/cookbook/gpus' + (_probeParams.toString() ? '?' + _probeParams : ''), { credentials: 'same-origin' });
|
||||
const _probeData = await _probeRes.json();
|
||||
@@ -3029,10 +3020,10 @@ function _rerenderCachedModels() {
|
||||
|| launchCmd.match(/OLLAMA_HOST=[^:\s]+:(\d{2,5})\b/);
|
||||
const _port = _portMatch ? _portMatch[1] : '';
|
||||
if (_port) {
|
||||
const _portHost = (_selectedServeTarget.host || '').trim();
|
||||
const _portHost = (launchTarget.host || '').trim();
|
||||
const _checkInner = `ss -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}' || netstat -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}'`;
|
||||
const _cmd = _portHost
|
||||
? `ss h ${_portHost} <<<"" 2>/dev/null; ssh -o ConnectTimeout=4 -o StrictHostKeyChecking=no ${_portHost} ${JSON.stringify(_checkInner)}`
|
||||
? `ssh -o ConnectTimeout=4 -o StrictHostKeyChecking=no ${_sshPrefix(launchTarget.port)}${_portHost} ${JSON.stringify(_checkInner)}`
|
||||
: _checkInner;
|
||||
const _res = await fetch('/api/shell/exec', {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
@@ -3086,11 +3077,14 @@ function _rerenderCachedModels() {
|
||||
const venvVal = panel.querySelector('[data-field="venv"]')?.value?.trim();
|
||||
const gpusVal = panel.querySelector('[data-field="gpus"]')?.value?.trim();
|
||||
const origGpus = _envState.gpus;
|
||||
const serveHost = _selectedServeTarget.host || '';
|
||||
const serveServerKey = _selectedServeTarget.serverKey || '';
|
||||
const serveServerName = _selectedServeTarget.serverName || '';
|
||||
const _srvEnv = _selectedServeTarget.env || '';
|
||||
const _srvEnvPath = _selectedServeTarget.envPath || '';
|
||||
// Resolve the target host from the visible Server dropdown — the reliable
|
||||
// source. Relying on _envState.remoteHost silently sent serves to Local
|
||||
// when that value was stale/empty. Pass it explicitly to the launcher.
|
||||
const serveHost = launchTarget.host || '';
|
||||
const serveServerKey = launchTarget.serverKey || '';
|
||||
const serveServerName = launchTarget.serverName || '';
|
||||
const _srvEnv = launchTarget.env || '';
|
||||
const _srvEnvPath = launchTarget.venv || '';
|
||||
// The venv field wins; otherwise fall back to the env configured for the
|
||||
// selected server in Settings, so the activation isn't silently dropped
|
||||
// when the field is left blank (the per-server venv wasn't being applied).
|
||||
|
||||
@@ -88,7 +88,8 @@ import * as Modals from './modalManager.js';
|
||||
}
|
||||
|
||||
function _accountCanSend(account) {
|
||||
return !!(account && account.smtp_host && account.smtp_user && account.has_smtp_password);
|
||||
if (!account || !account.smtp_host || !account.smtp_user) return false;
|
||||
return !!(account.has_smtp_password || account.oauth_provider);
|
||||
}
|
||||
|
||||
async function _resolveComposeSendAccountId() {
|
||||
@@ -298,8 +299,8 @@ import * as Modals from './modalManager.js';
|
||||
? langIcon(doc.language, 12, { style: 'opacity:0.65;flex-shrink:0;color:currentColor;margin-right:4px;' })
|
||||
: '';
|
||||
const langChip = `<span class="doc-tab-lang">${lic}</span>`;
|
||||
html += `<div class="doc-tab${isActive ? ' active' : ''}" draggable="true" data-doc-id="${id}" title="${title}">
|
||||
${verChip}${langChip}<span class="doc-tab-title">${shortTitle}</span>
|
||||
html += `<div class="doc-tab${isActive ? ' active' : ''}" draggable="true" data-doc-id="${id}" title="${_esc(title)}">
|
||||
${verChip}${langChip}<span class="doc-tab-title">${_esc(shortTitle)}</span>
|
||||
<button class="doc-tab-close" data-doc-id="${id}" title="Unlink from chat (kept in the Library)">×</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ export function canvasCoords(e, canvas) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const scaleX = canvas.width / rect.width;
|
||||
const scaleY = canvas.height / rect.height;
|
||||
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||
const clientX = e.touches && e.touches.length ? e.touches[0].clientX : e.clientX;
|
||||
const clientY = e.touches && e.touches.length ? e.touches[0].clientY : e.clientY;
|
||||
return {
|
||||
x: (clientX - rect.left) * scaleX,
|
||||
y: (clientY - rect.top) * scaleY,
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import { previewZoneAt, clearPreview, snapModalToZone } from './tileManager.js';
|
||||
import { suspendDock, resumeDock, clearRightDock, applyEdgeDock } from './modalSnap.js';
|
||||
import { dismissOrRemove } from './escMenuStack.js';
|
||||
import { nextToolWindowZ } from './toolWindowZOrder.js';
|
||||
|
||||
const _state = new Map(); // id -> { restoreFn, closeFn, railBtnId, isMinimized, restoreMinHeight }
|
||||
|
||||
@@ -63,7 +64,14 @@ function _applyRememberedDock(id) {
|
||||
// those statics and bump on every bring-to-front.
|
||||
let _modalTopZ = 300;
|
||||
function _bringToFront(modal) {
|
||||
if (modal) modal.style.setProperty('z-index', String(++_modalTopZ), 'important');
|
||||
if (!modal) return;
|
||||
const z = nextToolWindowZ({
|
||||
exclude: modal,
|
||||
current: getComputedStyle(modal).zIndex,
|
||||
floor: _modalTopZ,
|
||||
});
|
||||
_modalTopZ = Math.max(_modalTopZ, z);
|
||||
modal.style.setProperty('z-index', String(z), 'important');
|
||||
}
|
||||
|
||||
function _emitModalOpened(id, modal) {
|
||||
|
||||
+26
-1
@@ -10,6 +10,7 @@ import { attachColorPicker } from './colorPicker.js';
|
||||
import { makeWindowDraggable } from './windowDrag.js';
|
||||
import { snapModalToZone } from './tileManager.js';
|
||||
import { applyEdgeDock, clearDockSide } from './modalSnap.js';
|
||||
import { topToolWindowZ } from './toolWindowZOrder.js';
|
||||
|
||||
const API_BASE = window.location.origin;
|
||||
let _open = false;
|
||||
@@ -200,6 +201,23 @@ function _restoreNotesSidebarDock(pane) {
|
||||
applyEdgeDock(pane, 'right');
|
||||
}
|
||||
|
||||
// Notes is not a `.modal`; its backdrop is the top-level stacking surface.
|
||||
function _topToolWindowZ(exclude = null) {
|
||||
return topToolWindowZ({ exclude });
|
||||
}
|
||||
|
||||
function _bringNotesToFront(pane = document.getElementById('notes-pane')) {
|
||||
if (!pane) return;
|
||||
const backdrop = document.getElementById('notes-pane-backdrop') || pane.parentElement;
|
||||
const z = _topToolWindowZ(backdrop) + 1;
|
||||
if (backdrop) backdrop.style.setProperty('z-index', String(z), 'important');
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('odysseus:modal-opened', {
|
||||
detail: { id: 'notes-panel', modal: pane },
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function _loadPendingHighlights() {
|
||||
try { return new Set(JSON.parse(localStorage.getItem(REMINDER_PENDING_HIGHLIGHT_KEY) || '[]')); }
|
||||
catch { return new Set(); }
|
||||
@@ -1096,7 +1114,10 @@ export async function refreshDueBadge(opts = {}) {
|
||||
// ---- Panel ----
|
||||
|
||||
export function openPanel() {
|
||||
if (_open) return;
|
||||
if (_open) {
|
||||
_bringNotesToFront();
|
||||
return;
|
||||
}
|
||||
_open = true;
|
||||
_editingId = null;
|
||||
_clearViewedReminderGlows();
|
||||
@@ -1189,6 +1210,7 @@ export function openPanel() {
|
||||
document.body.appendChild(backdrop);
|
||||
_wireNotesWindow(pane);
|
||||
_restoreNotesSidebarDock(pane);
|
||||
_bringNotesToFront(pane);
|
||||
|
||||
// Events
|
||||
// (Close chevron removed — swipe down on mobile, tool-rail toggle on desktop.)
|
||||
@@ -1199,6 +1221,9 @@ export function openPanel() {
|
||||
_wireNotesSwipeDismiss(pane.querySelector('.notes-mobile-grabber'), pane);
|
||||
_wireNotesSwipeDismiss(pane.querySelector('.notes-pane-header'), pane);
|
||||
|
||||
pane.addEventListener('pointerdown', () => _bringNotesToFront(pane), true);
|
||||
pane.addEventListener('focusin', () => _bringNotesToFront(pane), true);
|
||||
|
||||
const minBtn = document.getElementById('notes-minimize-btn');
|
||||
if (minBtn) minBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
+140
-11
@@ -11,6 +11,7 @@ import { isAltGrEvent } from './platform.js';
|
||||
|
||||
let initialized = false;
|
||||
let modalEl = null;
|
||||
let _authPolicy = { password_min_length: 8 };
|
||||
|
||||
function el(id) { return document.getElementById(id); }
|
||||
function esc(s) { return uiModule.esc(s); }
|
||||
@@ -2160,6 +2161,16 @@ function initAccount() {
|
||||
}
|
||||
}).catch(() => {});
|
||||
|
||||
// Update password placeholder and policy from server
|
||||
fetch('/api/auth/policy', { credentials: 'same-origin' })
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(policy => {
|
||||
if (!policy) return;
|
||||
_authPolicy = policy;
|
||||
const pwNew = el('settings-pw-new');
|
||||
if (pwNew) pwNew.placeholder = `New password (min ${policy.password_min_length})`;
|
||||
}).catch(() => {});
|
||||
|
||||
// Change password
|
||||
const saveBtn = el('settings-pw-save');
|
||||
const msgEl = el('settings-pw-msg');
|
||||
@@ -2170,7 +2181,7 @@ function initAccount() {
|
||||
const conf = el('settings-pw-confirm').value;
|
||||
msgEl.style.color = '';
|
||||
if (!cur || !nw) { msgEl.textContent = 'Fill in all fields'; msgEl.style.color = 'var(--red)'; return; }
|
||||
if (nw.length < 8) { msgEl.textContent = 'Min 8 characters'; msgEl.style.color = 'var(--red)'; return; }
|
||||
if (nw.length < _authPolicy.password_min_length) { msgEl.textContent = `Min ${_authPolicy.password_min_length} characters`; msgEl.style.color = 'var(--red)'; return; }
|
||||
if (nw !== conf) { msgEl.textContent = 'Passwords don\'t match'; msgEl.style.color = 'var(--red)'; return; }
|
||||
saveBtn.disabled = true;
|
||||
try {
|
||||
@@ -2913,13 +2924,14 @@ async function initEmailAccountsSettings() {
|
||||
// IMAP and SMTP. Dovecot is IMAP-only here; the host is intentionally
|
||||
// blank because it may live on another machine (DNS, LAN, Tailscale).
|
||||
const PROVIDERS = {
|
||||
gmail: { label: 'Gmail', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } },
|
||||
migadu: { label: 'Migadu', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } },
|
||||
icloud: { label: 'iCloud', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } },
|
||||
outlook: { label: 'Outlook / Office 365', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } },
|
||||
fastmail: { label: 'Fastmail', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } },
|
||||
yahoo: { label: 'Yahoo', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } },
|
||||
dovecot: { label: 'Dovecot IMAP (no SMTP)', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } },
|
||||
gmail: { label: 'Gmail', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } },
|
||||
google_workspace: { label: 'Google Workspace / .edu', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 587 }, oauth: 'google' },
|
||||
migadu: { label: 'Migadu', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } },
|
||||
icloud: { label: 'iCloud', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } },
|
||||
outlook: { label: 'Outlook / Office 365', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } },
|
||||
fastmail: { label: 'Fastmail', imap: { host: 'imap.fastmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.fastmail.com', port: 465 } },
|
||||
yahoo: { label: 'Yahoo', imap: { host: 'imap.mail.yahoo.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.yahoo.com', port: 465 } },
|
||||
dovecot: { label: 'Dovecot IMAP (no SMTP)', imap: { host: '', port: 31143, starttls: false }, smtp: { host: '', port: 465 } },
|
||||
};
|
||||
const _providerOptions = Object.entries(PROVIDERS)
|
||||
.map(([k, v]) => `<option value="${k}">${esc(v.label)}</option>`)
|
||||
@@ -2932,11 +2944,17 @@ async function initEmailAccountsSettings() {
|
||||
<div id="eaf-provider-note" style="display:none;font-size:11px;line-height:1.5;padding:8px 10px;margin:2px 0 4px;border:1px solid color-mix(in srgb, var(--fg) 15%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:4px;background:color-mix(in srgb, var(--fg) 4%, transparent);"></div>
|
||||
<div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. “Work” or “Personal”). Leave blank to use the email address.')}</label><input id="eaf-name" class="settings-input" placeholder="(optional — leave blank to use email)" value="${esc(a.name || '')}"></div>
|
||||
<div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="eaf-from" class="settings-input" placeholder="you@example.com" value="${esc(a.from_address || '')}"></div>
|
||||
<div class="settings-row"><label class="settings-label">Display Name${_hint('Your name as it appears in the From: field of emails you send, e.g. Jane Smith. Auto-filled from Google during OAuth.')}</label><input id="eaf-display-name" class="settings-input" placeholder="Your Name" value="${esc(a.display_name || '')}"></div>
|
||||
<div id="eaf-oauth-section" style="display:none;margin:8px 0;padding:10px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb,var(--accent,#50fa7b) 6%,transparent)">
|
||||
<div style="font-size:11px;font-weight:600;margin-bottom:6px">Google OAuth2 — required for Workspace / .edu accounts</div>
|
||||
<div id="eaf-oauth-status" style="font-size:11px;opacity:0.7;margin-bottom:6px">${a.oauth_provider === 'google' ? '✓ Connected via Google OAuth' : 'Not connected — click below to authorize'}</div>
|
||||
<button type="button" id="eaf-oauth-btn" class="admin-btn-add" style="font-size:11px">${a.oauth_provider === 'google' ? 'Reconnect with Google' : 'Connect with Google'}</button>
|
||||
</div>
|
||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:6px 0 2px">IMAP (Receiving)</div>
|
||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="eaf-imap-host" class="settings-input" value="${esc(a.imap_host || '')}"></div>
|
||||
<div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="eaf-imap-port" class="settings-input" type="number" value="${esc(a.imap_port || 993)}" style="max-width:100px"></div>
|
||||
<div class="settings-row"><label class="settings-label">Username${_hint('Usually your full email address.')}</label><input id="eaf-imap-user" class="settings-input" value="${esc(a.imap_user || '')}"></div>
|
||||
<div class="settings-row"><label class="settings-label">Password${_hint('Your IMAP login password. Use an app-specific password if your provider requires 2FA. Outlook / Office 365 generally requires OAuth and will not work with a normal password here.')}</label><input id="eaf-imap-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_imap_password ? '(unchanged)' : ''}"></div>
|
||||
<div class="eaf-password-section"><div class="settings-row"><label class="settings-label">Password${_hint('Your IMAP login password. Use an app-specific password if your provider requires 2FA. Outlook / Office 365 generally requires OAuth and will not work with a normal password here.')}</label><input id="eaf-imap-pass" class="settings-input" type="password" placeholder="${isEdit && a.has_imap_password ? '(unchanged)' : ''}"></div></div>
|
||||
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch"><input type="checkbox" id="eaf-imap-starttls" ${a.imap_starttls !== false ? 'checked' : ''}><span class="admin-slider"></span></label></div>
|
||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px">SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com, smtp.migadu.com. Leave blank to make this account read-only.')}</label><input id="eaf-smtp-host" class="settings-input" value="${esc(a.smtp_host || '')}"></div>
|
||||
@@ -2959,6 +2977,16 @@ async function initEmailAccountsSettings() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show/hide OAuth section and password fields based on provider selection.
|
||||
function _syncOauthUI(providerKey) {
|
||||
const p = PROVIDERS[providerKey];
|
||||
const isOauth = !!(p && p.oauth);
|
||||
el('eaf-oauth-section').style.display = isOauth ? '' : 'none';
|
||||
formEl.querySelectorAll('.eaf-password-section').forEach(r => {
|
||||
r.style.display = isOauth ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
const eafProviderNotes = {
|
||||
outlook: {
|
||||
title: 'Outlook / Office 365 needs OAuth',
|
||||
@@ -2983,13 +3011,41 @@ async function initEmailAccountsSettings() {
|
||||
el('eaf-provider').addEventListener('change', (e) => {
|
||||
_renderEafProviderNote(e.target.value);
|
||||
const p = PROVIDERS[e.target.value];
|
||||
if (!p) return;
|
||||
if (!p) { _syncOauthUI(''); return; }
|
||||
el('eaf-imap-host').value = p.imap.host;
|
||||
el('eaf-imap-port').value = p.imap.port;
|
||||
el('eaf-imap-starttls').checked = !!p.imap.starttls;
|
||||
el('eaf-smtp-host').value = p.smtp.host;
|
||||
el('eaf-smtp-port').value = p.smtp.port;
|
||||
el('eaf-smtp-security').value = p.smtp.security || ((parseInt(p.smtp.port || 465) === 587) ? 'starttls' : 'ssl');
|
||||
_syncOauthUI(e.target.value);
|
||||
});
|
||||
|
||||
// Init OAuth UI for accounts already connected via OAuth.
|
||||
if (a.oauth_provider === 'google') _syncOauthUI('google_workspace');
|
||||
|
||||
// "Connect with Google" button — save the account first, then redirect to OAuth.
|
||||
el('eaf-oauth-btn').addEventListener('click', async () => {
|
||||
// Must save the account first to get an account_id to pass to the OAuth flow.
|
||||
const body = {
|
||||
name: el('eaf-name').value.trim() || el('eaf-from').value.trim(),
|
||||
from_address: el('eaf-from').value.trim(),
|
||||
imap_host: el('eaf-imap-host').value.trim(),
|
||||
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
||||
imap_user: el('eaf-imap-user').value.trim(),
|
||||
imap_starttls: el('eaf-imap-starttls').checked,
|
||||
smtp_host: el('eaf-smtp-host').value.trim(),
|
||||
smtp_port: parseInt(el('eaf-smtp-port').value) || 587,
|
||||
smtp_user: el('eaf-imap-user').value.trim(),
|
||||
};
|
||||
if (!body.name) { el('eaf-msg').textContent = 'Enter a Name or Email first'; el('eaf-msg').style.color = 'var(--red)'; return; }
|
||||
const url = isEdit ? `/api/email/accounts/${a.id}` : '/api/email/accounts';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
const r = await fetch(url, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const d = await r.json();
|
||||
if (!d.ok) { el('eaf-msg').textContent = d.error || 'Save failed'; el('eaf-msg').style.color = 'var(--red)'; return; }
|
||||
const accId = isEdit ? a.id : d.id;
|
||||
window.location.href = `/api/email/oauth/google/authorize?account_id=${encodeURIComponent(accId)}`;
|
||||
});
|
||||
el('eaf-smtp-security').value = _smtpSecurity(a);
|
||||
|
||||
@@ -3009,6 +3065,7 @@ async function initEmailAccountsSettings() {
|
||||
const body = {
|
||||
name: el('eaf-name').value.trim(),
|
||||
from_address: el('eaf-from').value.trim(),
|
||||
display_name: el('eaf-display-name').value.trim(),
|
||||
imap_host: el('eaf-imap-host').value.trim(),
|
||||
imap_port: parseInt(el('eaf-imap-port').value) || 993,
|
||||
imap_user: el('eaf-imap-user').value.trim(),
|
||||
@@ -4317,6 +4374,7 @@ async function initUnifiedIntegrations() {
|
||||
// it may be remote (DNS, LAN, Tailscale), not localhost.
|
||||
const PROVIDERS = {
|
||||
gmail: { label: 'Gmail', emailEx: 'you@gmail.com', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 465 } },
|
||||
google_workspace: { label: 'Google Workspace / .edu', emailEx: 'you@yourschool.edu', imap: { host: 'imap.gmail.com', port: 993, starttls: false }, smtp: { host: 'smtp.gmail.com', port: 587 }, oauth: 'google' },
|
||||
migadu: { label: 'Migadu', emailEx: 'you@yourdomain.com', imap: { host: 'imap.migadu.com', port: 993, starttls: false }, smtp: { host: 'smtp.migadu.com', port: 465 } },
|
||||
icloud: { label: 'iCloud', emailEx: 'you@icloud.com', imap: { host: 'imap.mail.me.com', port: 993, starttls: false }, smtp: { host: 'smtp.mail.me.com', port: 587 } },
|
||||
outlook: { label: 'Outlook / Office 365', emailEx: 'you@outlook.com', imap: { host: 'outlook.office365.com', port: 993, starttls: false }, smtp: { host: 'smtp.office365.com', port: 587 } },
|
||||
@@ -4334,6 +4392,7 @@ async function initUnifiedIntegrations() {
|
||||
const PROV_LOGO = {
|
||||
'': _customLogo,
|
||||
gmail: _letterLogo('G', '#ea4335'),
|
||||
google_workspace: _letterLogo('G', '#ea4335'),
|
||||
migadu: _letterLogo('M', '#3aa39d'),
|
||||
icloud: _letterLogo('i', '#3693f3'),
|
||||
outlook: _letterLogo('O', '#0078d4'),
|
||||
@@ -4362,11 +4421,17 @@ async function initUnifiedIntegrations() {
|
||||
<div id="uf-email-provider-note" style="display:none;font-size:11px;line-height:1.5;padding:8px 10px;margin:2px 0 4px;border:1px solid color-mix(in srgb, var(--fg) 15%, transparent);border-left:3px solid var(--accent, var(--red));border-radius:4px;background:color-mix(in srgb, var(--fg) 4%, transparent);"></div>
|
||||
<div class="settings-row"><label class="settings-label">Name${_hint('Optional label for this account (e.g. “Work” or “Personal”). Leave blank to use the email address.')}</label><input id="uf-email-name" class="settings-input" placeholder="(optional — leave blank to use email)"></div>
|
||||
<div class="settings-row"><label class="settings-label">Email${_hint('Your email address. Used as the From: header on outgoing mail and as the display label when Name is blank.')}</label><input id="uf-email-from" class="settings-input" placeholder="you@example.com"></div>
|
||||
<div class="settings-row"><label class="settings-label">Display Name${_hint('Your name as it appears in the From: field of emails you send, e.g. Jane Smith. Auto-filled from Google during OAuth.')}</label><input id="uf-display-name" class="settings-input" placeholder="Your Name"></div>
|
||||
<div id="uf-oauth-section" style="display:none;margin:8px 0;padding:10px;border:1px solid var(--border);border-radius:6px;background:color-mix(in srgb,var(--accent,#50fa7b) 6%,transparent)">
|
||||
<div style="font-size:11px;font-weight:600;margin-bottom:6px">Google OAuth2 — required for Workspace / .edu accounts</div>
|
||||
<div id="uf-oauth-status" style="font-size:11px;opacity:0.7;margin-bottom:6px">${existing && existing.oauth_provider === 'google' ? '✓ Connected via Google OAuth' : 'Not connected — click below to authorize'}</div>
|
||||
<button type="button" id="uf-oauth-btn" class="admin-btn-add" style="font-size:11px">${existing && existing.oauth_provider === 'google' ? 'Reconnect with Google' : 'Connect with Google'}</button>
|
||||
</div>
|
||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:4px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>IMAP (Receiving)</div>
|
||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your IMAP server, e.g. imap.gmail.com, imap.migadu.com, a LAN host, or a Tailscale IP for Dovecot.')}</label><input id="uf-imap-host" class="settings-input" placeholder="imap.example.com"></div>
|
||||
<div class="settings-row"><label class="settings-label">Port${_hint('993 for IMAPS (most providers), 143 for plain or STARTTLS. Local servers often use a custom port like 31143.')}</label><input id="uf-imap-port" class="settings-input" type="number" placeholder="993" style="max-width:100px"></div>
|
||||
<div class="settings-row"><label class="settings-label">Username${_hint('Yes — your full email address goes here too (e.g. you@gmail.com). Same as the Email field above for almost every provider.')}</label><input id="uf-imap-user" class="settings-input" placeholder="you@example.com"></div>
|
||||
<div class="settings-row"><label class="settings-label">Password${_hint('For Gmail, iCloud, and Yahoo: paste your App Password (NOT your normal account password). For Migadu and Fastmail, your mailbox password usually works. Outlook / Office 365 generally requires OAuth and will not work with this password form.')}</label><input id="uf-imap-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div>
|
||||
<div class="uf-password-section"><div class="settings-row"><label class="settings-label">Password${_hint('For Gmail, iCloud, and Yahoo: paste your App Password (NOT your normal account password). For Migadu and Fastmail, your mailbox password usually works. Outlook / Office 365 generally requires OAuth and will not work with this password form.')}</label><input id="uf-imap-pass" class="settings-input" type="password" placeholder="${placeholderPass}"></div></div>
|
||||
<div class="settings-row"><label class="settings-label">STARTTLS${_hint('Turn ON for port 143/587 to upgrade plain to TLS. Turn OFF for port 993 (IMAPS — already encrypted) or a local server with no TLS configured.')}</label><label class="admin-switch" style="margin-left:0"><input type="checkbox" id="uf-imap-starttls" checked><span class="admin-slider"></span></label></div>
|
||||
<div style="font-size:11px;font-weight:600;opacity:0.6;margin:8px 0 2px;display:flex;align-items:center;gap:5px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--accent, var(--red));flex-shrink:0;" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>SMTP (Sending) <span style="font-weight:normal;opacity:0.7">— optional, leave blank for read-only</span></div>
|
||||
<div class="settings-row"><label class="settings-label">Host${_hint('Your outgoing-mail server, e.g. smtp.gmail.com. Leave blank to make this account read-only.')}</label><input id="uf-smtp-host" class="settings-input" placeholder="smtp.example.com"></div>
|
||||
@@ -4491,6 +4556,16 @@ async function initUnifiedIntegrations() {
|
||||
</div>`;
|
||||
};
|
||||
|
||||
// Show/hide the OAuth section and password fields based on provider selection.
|
||||
function _syncOauthUI(providerKey) {
|
||||
const p = PROVIDERS[providerKey];
|
||||
const isOauth = !!(p && p.oauth);
|
||||
el('uf-oauth-section').style.display = isOauth ? '' : 'none';
|
||||
formEl.querySelectorAll('.uf-password-section').forEach(r => {
|
||||
r.style.display = isOauth ? 'none' : '';
|
||||
});
|
||||
}
|
||||
|
||||
// Custom dropdown wire-up — the native <select> stays in the DOM as the
|
||||
// data source and accessibility target, but the visible UI is a button +
|
||||
// popup so each provider row can render with its SVG logo. Selecting an
|
||||
@@ -4547,6 +4622,7 @@ async function initUnifiedIntegrations() {
|
||||
el('uf-email-provider').addEventListener('change', (e) => {
|
||||
const key = e.target.value;
|
||||
_renderProviderNote(key);
|
||||
_syncOauthUI(key);
|
||||
const p = PROVIDERS[key];
|
||||
if (!p) return;
|
||||
el('uf-imap-host').value = p.imap.host;
|
||||
@@ -4562,6 +4638,23 @@ async function initUnifiedIntegrations() {
|
||||
}
|
||||
});
|
||||
|
||||
// Init OAuth UI for accounts already connected via OAuth.
|
||||
if (existing && existing.oauth_provider === 'google') _syncOauthUI('google_workspace');
|
||||
|
||||
// "Connect with Google" — save the account first, then redirect to OAuth.
|
||||
el('uf-oauth-btn').addEventListener('click', async () => {
|
||||
const body = _collectBody();
|
||||
if (!body.name) body.name = body.from_address;
|
||||
if (!body.name) { el('uf-email-msg').textContent = 'Enter a Name or Email first'; el('uf-email-msg').style.color = 'var(--red)'; return; }
|
||||
const url = isEdit ? `/api/email/accounts/${editId}` : '/api/email/accounts';
|
||||
const method = isEdit ? 'PUT' : 'POST';
|
||||
const r = await fetch(url, { method, credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
const d = await r.json();
|
||||
if (!(d.ok || d.id)) { el('uf-email-msg').textContent = d.error || 'Save failed'; el('uf-email-msg').style.color = 'var(--red)'; return; }
|
||||
const accId = isEdit ? editId : d.id;
|
||||
window.location.href = `/api/email/oauth/google/authorize?account_id=${encodeURIComponent(accId)}`;
|
||||
});
|
||||
|
||||
// "Same as IMAP" toggle — hide the SMTP creds rows when on.
|
||||
const _syncSmtpSame = () => {
|
||||
const same = el('uf-smtp-same').checked;
|
||||
@@ -4574,6 +4667,7 @@ async function initUnifiedIntegrations() {
|
||||
if (existing) {
|
||||
el('uf-email-name').value = existing.name || '';
|
||||
el('uf-email-from').value = existing.from_address || '';
|
||||
el('uf-display-name').value = existing.display_name || '';
|
||||
el('uf-imap-host').value = existing.imap_host || '';
|
||||
el('uf-imap-port').value = existing.imap_port || 993;
|
||||
el('uf-imap-user').value = existing.imap_user || '';
|
||||
@@ -4622,6 +4716,7 @@ async function initUnifiedIntegrations() {
|
||||
const body = {
|
||||
name: el('uf-email-name').value.trim(),
|
||||
from_address: el('uf-email-from').value.trim(),
|
||||
display_name: el('uf-display-name').value.trim(),
|
||||
imap_host: el('uf-imap-host').value.trim(),
|
||||
imap_port: parseInt(el('uf-imap-port').value) || 993,
|
||||
imap_user: el('uf-imap-user').value.trim(),
|
||||
@@ -5650,6 +5745,40 @@ export function close() {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle redirect back from Google OAuth2 — open settings to integrations and show status.
|
||||
(function _handleOauthRedirect() {
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
if (!sp.has('email_oauth_success') && !sp.has('email_oauth_error')) return;
|
||||
// Strip params from URL without a page reload.
|
||||
const clean = window.location.pathname + window.location.hash;
|
||||
window.history.replaceState(null, '', clean);
|
||||
const success = sp.has('email_oauth_success');
|
||||
const errMsg = sp.get('email_oauth_error') || '';
|
||||
// Open settings → integrations after the app has initialised.
|
||||
function _tryOpen() {
|
||||
if (window.settingsModule && typeof window.settingsModule.open === 'function') {
|
||||
window.settingsModule.open('integrations');
|
||||
// Brief toast-style banner.
|
||||
const banner = document.createElement('div');
|
||||
banner.textContent = success
|
||||
? '✓ Google account connected — email is ready'
|
||||
: `Google OAuth failed: ${errMsg || 'unknown error'}`;
|
||||
Object.assign(banner.style, {
|
||||
position: 'fixed', bottom: '24px', left: '50%', transform: 'translateX(-50%)',
|
||||
background: success ? 'var(--accent, #50fa7b)' : 'var(--red, #ff5555)',
|
||||
color: '#000', padding: '8px 18px', borderRadius: '6px', fontSize: '12px',
|
||||
fontWeight: '600', zIndex: '99999', pointerEvents: 'none',
|
||||
boxShadow: '0 2px 12px rgba(0,0,0,0.3)',
|
||||
});
|
||||
document.body.appendChild(banner);
|
||||
setTimeout(() => banner.remove(), 4000);
|
||||
} else {
|
||||
setTimeout(_tryOpen, 100);
|
||||
}
|
||||
}
|
||||
_tryOpen();
|
||||
})();
|
||||
|
||||
const settingsModule = { open, close, initIntegrations, initUnifiedIntegrations, syncAdminVisibility, refreshAiModelEndpoints };
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export const TOOL_WINDOW_SELECTOR = 'body > .modal, body > .research-overlay, body > .notes-pane-backdrop';
|
||||
|
||||
export function topToolWindowZ(options = {}) {
|
||||
const {
|
||||
exclude = null,
|
||||
root = globalThis.document,
|
||||
getStyle = globalThis.getComputedStyle,
|
||||
floor = 250,
|
||||
} = options;
|
||||
let top = floor;
|
||||
if (!root || typeof root.querySelectorAll !== 'function' || typeof getStyle !== 'function') return top;
|
||||
root.querySelectorAll(TOOL_WINDOW_SELECTOR).forEach(el => {
|
||||
if (!el || el === exclude) return;
|
||||
if (el.classList?.contains('hidden') || el.classList?.contains('modal-minimized')) return;
|
||||
const cs = getStyle(el);
|
||||
if (cs.display === 'none' || cs.visibility === 'hidden') return;
|
||||
const z = parseInt(cs.zIndex, 10);
|
||||
if (Number.isFinite(z)) top = Math.max(top, z);
|
||||
});
|
||||
return top;
|
||||
}
|
||||
|
||||
export function nextToolWindowZ(options = {}) {
|
||||
const { current = null } = options;
|
||||
const top = topToolWindowZ(options);
|
||||
const currentZ = parseInt(current, 10);
|
||||
if (Number.isFinite(currentZ) && currentZ > top) return currentZ;
|
||||
return top + 1;
|
||||
}
|
||||
+21
-6
@@ -8,6 +8,7 @@ import themeModule from './theme.js';
|
||||
import * as Modals from './modalManager.js';
|
||||
import spinnerModule from './spinner.js';
|
||||
import { registerMenuDismiss, dismissTopMenu, dismissOrRemove } from './escMenuStack.js';
|
||||
import { nextToolWindowZ, topToolWindowZ } from './toolWindowZOrder.js';
|
||||
|
||||
let toastEl = null;
|
||||
let autoScrollEnabled = true;
|
||||
@@ -1088,14 +1089,22 @@ if ('ontouchstart' in window) {
|
||||
|
||||
// ---- Bring modal to front on click ----
|
||||
{
|
||||
let topModalZ = 250;
|
||||
const raiseModalToFront = (modal, floor = 250) => {
|
||||
const z = nextToolWindowZ({
|
||||
exclude: modal,
|
||||
current: getComputedStyle(modal).zIndex,
|
||||
floor,
|
||||
});
|
||||
modal.style.setProperty('z-index', String(z), 'important');
|
||||
return z;
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
const modalContent = e.target.closest('.modal-content');
|
||||
if (!modalContent) return;
|
||||
const modal = modalContent.closest('.modal');
|
||||
if (!modal) return;
|
||||
topModalZ += 1;
|
||||
modal.style.zIndex = topModalZ;
|
||||
raiseModalToFront(modal);
|
||||
});
|
||||
|
||||
// Backdrop tap to close — delegated for all modals
|
||||
@@ -1190,9 +1199,15 @@ if (!window._odyEscExpandGuard) {
|
||||
// Re-entry guard: setting style.zIndex itself fires the observer that
|
||||
// calls us back. Skip if this element is already pinned to the top
|
||||
// (matches the current counter) so we don't spin into an infinite loop.
|
||||
const cur = parseInt(m.style.zIndex, 10) || 0;
|
||||
if (cur === _zCounter) return;
|
||||
m.style.zIndex = String(++_zCounter);
|
||||
const cur = parseInt(getComputedStyle(m).zIndex, 10) || 0;
|
||||
if (cur === _zCounter && cur > topToolWindowZ({ exclude: m })) return;
|
||||
const z = nextToolWindowZ({
|
||||
exclude: m,
|
||||
current: cur,
|
||||
floor: _zCounter,
|
||||
});
|
||||
_zCounter = Math.max(_zCounter, z);
|
||||
if (z !== cur) m.style.setProperty('z-index', String(z), 'important');
|
||||
};
|
||||
new MutationObserver((muts) => {
|
||||
for (const m of muts) {
|
||||
|
||||
+18
-5
@@ -328,6 +328,7 @@
|
||||
|
||||
let mode = 'login'; // 'login' | 'signup' | 'setup'
|
||||
let signupAllowed = false;
|
||||
let policy = { password_min_length: 8, reserved_usernames: [] };
|
||||
|
||||
const rememberToggle = document.getElementById('rememberToggle');
|
||||
|
||||
@@ -360,10 +361,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
// Check auth status and fetch policy in parallel, but don't block the
|
||||
// authenticated redirect on the policy response.
|
||||
const policyPromise = fetch('/api/auth/policy', { credentials: 'same-origin' }).catch(() => null);
|
||||
try {
|
||||
const res = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
||||
const data = await res.json();
|
||||
const statusRes = await fetch('/api/auth/status', { credentials: 'same-origin' });
|
||||
const data = await statusRes.json();
|
||||
if (data.authenticated) {
|
||||
window.location.replace('/');
|
||||
return;
|
||||
@@ -374,6 +377,10 @@
|
||||
} else {
|
||||
setMode('login');
|
||||
}
|
||||
const policyRes = await policyPromise;
|
||||
if (policyRes && policyRes.ok) {
|
||||
policy = await policyRes.json();
|
||||
}
|
||||
} catch (e) {
|
||||
setMode('login');
|
||||
}
|
||||
@@ -426,8 +433,14 @@
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
errEl.textContent = 'Password must be at least 8 characters';
|
||||
if (password.length < policy.password_min_length) {
|
||||
errEl.textContent = `Password must be at least ${policy.password_min_length} characters`;
|
||||
errEl.style.display = 'block';
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (policy.reserved_usernames.includes(username.toLowerCase())) {
|
||||
errEl.textContent = 'This username is reserved';
|
||||
errEl.style.display = 'block';
|
||||
submitBtn.disabled = false;
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user