Changeset 0.8.0.1 (#43)
This commit is contained in:
@@ -1003,6 +1003,7 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.admin-user-actions button:hover { border-color: var(--border-light); color: var(--text); }
|
||||
.admin-user-actions .btn-danger { color: var(--danger); }
|
||||
.admin-user-actions .btn-danger:hover { border-color: var(--danger); }
|
||||
.inline-select { background: var(--bg-surface); border: 1px solid var(--border); color: var(--text-2); border-radius: 4px; padding: 3px 8px; font-size: 12px; cursor: pointer; }
|
||||
|
||||
.admin-model-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: 14px; min-height: 44px; }
|
||||
.admin-model-row .model-name { flex: 1; font-weight: 500; }
|
||||
@@ -1077,6 +1078,7 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.badge-user { background: var(--bg-raised); color: var(--text-3); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||
.badge-inactive { background: var(--danger); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||
.badge-pending { background: rgba(234,179,8,0.85); color: #000; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||
.badge-private { background: rgba(139,92,246,0.85); color: #fff; font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||
.admin-user-row.user-inactive { opacity: 0.7; }
|
||||
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
|
||||
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
|
||||
|
||||
@@ -317,6 +317,7 @@
|
||||
<button class="admin-tab" data-tab="providers">Providers</button>
|
||||
<button class="admin-tab" data-tab="models">Models</button>
|
||||
<button class="admin-tab" data-tab="presets">Presets</button>
|
||||
<button class="admin-tab" data-tab="teams">Teams</button>
|
||||
<button class="admin-tab" data-tab="settings">Settings</button>
|
||||
<button class="admin-tab" data-tab="stats">Stats</button>
|
||||
</div>
|
||||
@@ -352,6 +353,7 @@
|
||||
<div class="form-group"><label>API Key</label><input type="password" id="adminProvKey" placeholder="sk-..."></div>
|
||||
<div class="form-group"><label>Default Model</label><input type="text" id="adminProvModel" placeholder="gpt-4o"></div>
|
||||
</div>
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminProvPrivate"> Private provider (data stays on-prem)</label>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateProvBtn">Save</button><button class="btn-small" id="adminCancelProvBtn">Cancel</button></div>
|
||||
</div>
|
||||
<div id="adminProviderList"></div>
|
||||
@@ -399,6 +401,39 @@
|
||||
</div>
|
||||
<div id="adminPresetList"></div>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminTeamsTab" style="display:none">
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-small btn-primary" id="adminAddTeamBtn">+ New Team</button>
|
||||
</div>
|
||||
<div id="adminAddTeamForm" style="display:none" class="admin-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Team Name</label><input type="text" id="adminTeamName" placeholder="Engineering"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input type="text" id="adminTeamDesc" placeholder="Core engineering team"></div>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="adminCreateTeamBtn">Create</button><button class="btn-small" id="adminCancelTeamBtn">Cancel</button></div>
|
||||
</div>
|
||||
<div id="adminTeamList"></div>
|
||||
<!-- Team detail / member management (shown when editing a team) -->
|
||||
<div id="adminTeamDetail" style="display:none">
|
||||
<button class="notes-back-btn" id="adminTeamBackBtn">← Back to teams</button>
|
||||
<h3 id="adminTeamDetailName" style="margin:8px 0 12px;font-size:15px"></h3>
|
||||
<section class="settings-section" style="margin-bottom:12px;padding:10px;background:rgba(255,255,255,0.03);border-radius:8px">
|
||||
<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (data stays on-prem)</label>
|
||||
<p class="section-hint" style="margin:4px 0 0">Team members can only use providers marked as "private".</p>
|
||||
</section>
|
||||
<div class="admin-toolbar">
|
||||
<button class="btn-small btn-primary" id="adminAddMemberBtn">+ Add Member</button>
|
||||
</div>
|
||||
<div id="adminAddMemberForm" style="display:none" class="admin-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>User</label><select id="adminMemberUser"></select></div>
|
||||
<div class="form-group"><label>Team Role</label><select id="adminMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
|
||||
</div>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="adminAddMemberSubmit">Add</button><button class="btn-small" id="adminCancelMemberBtn">Cancel</button></div>
|
||||
</div>
|
||||
<div id="adminMemberList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminSettingsTab" style="display:none">
|
||||
<section class="settings-section">
|
||||
<h3>Registration</h3>
|
||||
|
||||
@@ -263,9 +263,9 @@ const API = {
|
||||
adminGetStats() { return this._get('/api/v1/admin/stats'); },
|
||||
|
||||
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
|
||||
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||||
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault, isPrivate) {
|
||||
return this._post('/api/v1/admin/configs', {
|
||||
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
|
||||
name, provider, endpoint, api_key: apiKey, model_default: modelDefault, is_private: !!isPrivate
|
||||
});
|
||||
},
|
||||
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
|
||||
@@ -285,6 +285,20 @@ const API = {
|
||||
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
|
||||
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
|
||||
|
||||
// ── Admin Teams ─────────────────────────
|
||||
adminListTeams() { return this._get('/api/v1/admin/teams'); },
|
||||
adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
|
||||
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
|
||||
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },
|
||||
adminDeleteTeam(id) { return this._del(`/api/v1/admin/teams/${id}`); },
|
||||
adminListMembers(teamId) { return this._get(`/api/v1/admin/teams/${teamId}/members`); },
|
||||
adminAddMember(teamId, userId, role) { return this._post(`/api/v1/admin/teams/${teamId}/members`, { user_id: userId, role }); },
|
||||
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
|
||||
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
|
||||
|
||||
// ── User Teams ──────────────────────────
|
||||
listMyTeams() { return this._get('/api/v1/teams/mine'); },
|
||||
|
||||
// ── User Presets ─────────────────────────
|
||||
listPresets() { return this._get('/api/v1/presets'); },
|
||||
createPreset(preset) { return this._post('/api/v1/presets', preset); },
|
||||
|
||||
@@ -980,6 +980,7 @@ function initListeners() {
|
||||
document.getElementById('adminProvEndpoint').value = '';
|
||||
document.getElementById('adminProvKey').value = '';
|
||||
document.getElementById('adminProvModel').value = '';
|
||||
document.getElementById('adminProvPrivate').checked = false;
|
||||
const f = document.getElementById('adminAddProviderForm');
|
||||
f.style.display = f.style.display === 'none' ? '' : 'none';
|
||||
});
|
||||
@@ -1061,6 +1062,60 @@ function initListeners() {
|
||||
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
|
||||
});
|
||||
|
||||
// Admin — Teams
|
||||
document.getElementById('adminAddTeamBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddTeamForm').style.display = '';
|
||||
document.getElementById('adminTeamName').focus();
|
||||
});
|
||||
document.getElementById('adminCancelTeamBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
document.getElementById('adminTeamName').value = '';
|
||||
document.getElementById('adminTeamDesc').value = '';
|
||||
});
|
||||
document.getElementById('adminCreateTeamBtn')?.addEventListener('click', async () => {
|
||||
const name = document.getElementById('adminTeamName').value.trim();
|
||||
const desc = document.getElementById('adminTeamDesc').value.trim();
|
||||
if (!name) return UI.toast('Team name required', 'error');
|
||||
try {
|
||||
await API.adminCreateTeam(name, desc);
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
document.getElementById('adminTeamName').value = '';
|
||||
document.getElementById('adminTeamDesc').value = '';
|
||||
UI.toast('Team created');
|
||||
await UI.loadAdminTeams(true);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
document.getElementById('adminTeamBackBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminTeamDetail').style.display = 'none';
|
||||
document.getElementById('adminTeamList').style.display = '';
|
||||
});
|
||||
document.getElementById('adminAddMemberBtn')?.addEventListener('click', async () => {
|
||||
document.getElementById('adminAddMemberForm').style.display = '';
|
||||
await UI.loadMemberUserDropdown(UI._teamEditId);
|
||||
});
|
||||
document.getElementById('adminTeamPrivatePolicy')?.addEventListener('change', async function() {
|
||||
try {
|
||||
await API.adminUpdateTeam(UI._teamEditId, {
|
||||
settings: JSON.stringify({ require_private_providers: this.checked })
|
||||
});
|
||||
UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed');
|
||||
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
|
||||
});
|
||||
document.getElementById('adminCancelMemberBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('adminAddMemberForm').style.display = 'none';
|
||||
});
|
||||
document.getElementById('adminAddMemberSubmit')?.addEventListener('click', async () => {
|
||||
const userId = document.getElementById('adminMemberUser').value;
|
||||
const role = document.getElementById('adminMemberRole').value;
|
||||
if (!userId) return UI.toast('Select a user', 'error');
|
||||
try {
|
||||
await API.adminAddMember(UI._teamEditId, userId, role);
|
||||
document.getElementById('adminAddMemberForm').style.display = 'none';
|
||||
UI.toast('Member added');
|
||||
await UI.loadTeamMembers(UI._teamEditId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
// Admin — banner controls
|
||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
@@ -1424,6 +1479,7 @@ function editGlobalProvider(id) {
|
||||
document.getElementById('adminProvKey').value = '';
|
||||
document.getElementById('adminProvKey').placeholder = c.has_key ? '(unchanged)' : 'sk-...';
|
||||
document.getElementById('adminProvModel').value = c.model_default || '';
|
||||
document.getElementById('adminProvPrivate').checked = !!c.is_private;
|
||||
document.getElementById('adminCreateProvBtn').textContent = 'Update';
|
||||
}
|
||||
|
||||
@@ -1434,6 +1490,7 @@ async function createGlobalProvider() {
|
||||
const ep = document.getElementById('adminProvEndpoint').value.trim();
|
||||
const key = document.getElementById('adminProvKey').value;
|
||||
const model = document.getElementById('adminProvModel').value.trim();
|
||||
const isPrivate = document.getElementById('adminProvPrivate').checked;
|
||||
|
||||
if (_editingProviderId) {
|
||||
// Update mode
|
||||
@@ -1442,12 +1499,13 @@ async function createGlobalProvider() {
|
||||
if (ep) updates.endpoint = ep;
|
||||
if (key) updates.api_key = key;
|
||||
updates.model_default = model;
|
||||
updates.is_private = isPrivate;
|
||||
await API.adminUpdateGlobalConfig(_editingProviderId, updates);
|
||||
UI.toast('Provider updated', 'success');
|
||||
} else {
|
||||
// Create mode
|
||||
if (!name || !ep || !key) { UI.toast('Name, endpoint, and API key required', 'warning'); return; }
|
||||
await API.adminCreateGlobalConfig(name, prov, ep, key, model);
|
||||
await API.adminCreateGlobalConfig(name, prov, ep, key, model, isPrivate);
|
||||
UI.toast('Provider added', 'success');
|
||||
}
|
||||
_editingProviderId = null;
|
||||
@@ -1585,6 +1643,44 @@ async function deleteAdminPreset(id, name) {
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Team Admin Functions ────────────────────
|
||||
|
||||
async function toggleTeamActive(id, active) {
|
||||
try {
|
||||
await API.adminUpdateTeam(id, { is_active: active });
|
||||
UI.toast(active ? 'Team activated' : 'Team deactivated');
|
||||
await UI.loadAdminTeams(true);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteTeam(id, name) {
|
||||
if (!confirm(`Delete team "${name}"? All members will be removed.`)) return;
|
||||
try {
|
||||
await API.adminDeleteTeam(id);
|
||||
UI.toast('Team deleted');
|
||||
await UI.loadAdminTeams(true);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function updateTeamMember(teamId, memberId, role) {
|
||||
try {
|
||||
await API.adminUpdateMember(teamId, memberId, role);
|
||||
UI.toast('Role updated');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
await UI.loadTeamMembers(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTeamMember(teamId, memberId, email) {
|
||||
if (!confirm(`Remove ${email} from team?`)) return;
|
||||
try {
|
||||
await API.adminRemoveMember(teamId, memberId);
|
||||
UI.toast('Member removed');
|
||||
await UI.loadTeamMembers(teamId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Notes Panel ─────────────────────────────
|
||||
|
||||
var _editingNoteId = null;
|
||||
|
||||
97
src/js/ui.js
97
src/js/ui.js
@@ -745,6 +745,7 @@ const UI = {
|
||||
if (tab === 'providers') await this.loadAdminProviders();
|
||||
if (tab === 'models') await this.loadAdminModels();
|
||||
if (tab === 'presets') await this.loadAdminPresets();
|
||||
if (tab === 'teams') await this.loadAdminTeams();
|
||||
if (tab === 'settings') await this.loadAdminSettings();
|
||||
},
|
||||
|
||||
@@ -799,6 +800,7 @@ const UI = {
|
||||
return `<div class="admin-provider-row">
|
||||
<span class="provider-name">${esc(c.name)}</span>
|
||||
<span class="provider-meta">${esc(c.provider)}</span>
|
||||
${c.is_private ? '<span class="badge-private">private</span>' : ''}
|
||||
<span class="provider-endpoint">${esc(c.endpoint || '')}</span>
|
||||
<button class="btn-edit" onclick="editGlobalProvider('${c.id}')" title="Edit">✎</button>
|
||||
<button class="btn-delete" onclick="deleteGlobalProvider('${c.id}')" title="Delete">✕</button>
|
||||
@@ -895,6 +897,101 @@ const UI = {
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
// ── Teams Admin ─────────────────────────
|
||||
_teamEditId: null,
|
||||
|
||||
async loadAdminTeams(quiet) {
|
||||
const el = document.getElementById('adminTeamList');
|
||||
const detail = document.getElementById('adminTeamDetail');
|
||||
if (!quiet) {
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
detail.style.display = 'none';
|
||||
el.style.display = '';
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
}
|
||||
try {
|
||||
const resp = await API.adminListTeams();
|
||||
const teams = resp.data || [];
|
||||
el.innerHTML = teams.map(t => `
|
||||
<div class="admin-user-row">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(t.name)}</strong>
|
||||
${t.is_active ? '' : '<span class="badge-pending">inactive</span>'}
|
||||
</div>
|
||||
<div class="text-muted">${esc(t.description || 'No description')} · ${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<button class="btn-small" onclick="UI.openTeamDetail('${t.id}', '${esc(t.name)}')">Members</button>
|
||||
<button class="admin-model-toggle ${t.is_active ? 'enabled' : ''}" onclick="toggleTeamActive('${t.id}', ${!t.is_active})">${t.is_active ? '✓ Active' : 'Inactive'}</button>
|
||||
<button class="btn-delete" onclick="deleteTeam('${t.id}', '${esc(t.name)}')" title="Delete">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-hint">No teams yet — create one to organize users and set policies</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async openTeamDetail(teamId, teamName) {
|
||||
this._teamEditId = teamId;
|
||||
document.getElementById('adminTeamList').style.display = 'none';
|
||||
document.getElementById('adminAddTeamForm').style.display = 'none';
|
||||
const detail = document.getElementById('adminTeamDetail');
|
||||
detail.style.display = '';
|
||||
document.getElementById('adminTeamDetailName').textContent = teamName;
|
||||
document.getElementById('adminAddMemberForm').style.display = 'none';
|
||||
|
||||
// Load team settings for policy checkbox
|
||||
try {
|
||||
const team = await API.adminGetTeam(teamId);
|
||||
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
|
||||
document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers;
|
||||
} catch (e) { /* proceed with unchecked default */ }
|
||||
|
||||
await this.loadTeamMembers(teamId);
|
||||
},
|
||||
|
||||
async loadTeamMembers(teamId) {
|
||||
const el = document.getElementById('adminMemberList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.adminListMembers(teamId);
|
||||
const members = resp.data || [];
|
||||
|
||||
el.innerHTML = members.map(m => `
|
||||
<div class="admin-user-row">
|
||||
<div class="admin-user-info">
|
||||
<div><strong>${esc(m.display_name || m.email)}</strong>
|
||||
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}">${m.role}</span>
|
||||
${m.user_role === 'admin' ? '<span class="badge-admin">sys-admin</span>' : ''}
|
||||
</div>
|
||||
<div class="text-muted">${esc(m.email)}</div>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<select onchange="updateTeamMember('${teamId}', '${m.id}', this.value)" class="inline-select">
|
||||
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
|
||||
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Team Admin</option>
|
||||
</select>
|
||||
<button class="btn-delete" onclick="removeTeamMember('${teamId}', '${m.id}', '${esc(m.email)}')" title="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-hint">No members — add users to this team</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadMemberUserDropdown(teamId) {
|
||||
const sel = document.getElementById('adminMemberUser');
|
||||
sel.innerHTML = '<option value="">Loading...</option>';
|
||||
try {
|
||||
const resp = await API.adminListUsers(1, 200);
|
||||
const users = resp.data || [];
|
||||
// Also get existing members to exclude
|
||||
const memberResp = await API.adminListMembers(teamId);
|
||||
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
|
||||
const available = users.filter(u => u.is_active && !existingIds.has(u.id));
|
||||
sel.innerHTML = '<option value="">Select user...</option>' +
|
||||
available.map(u => `<option value="${u.id}">${esc(u.username || u.email)}</option>`).join('');
|
||||
} catch (e) { sel.innerHTML = `<option value="">Error loading users</option>`; }
|
||||
},
|
||||
|
||||
async loadAdminSettings() {
|
||||
try {
|
||||
const data = await API.adminGetSettings();
|
||||
|
||||
Reference in New Issue
Block a user