feat: connect alpine workspace management to admin api
This commit is contained in:
@@ -2,20 +2,20 @@
|
||||
|
||||
## Current
|
||||
|
||||
### `feat/alpine-logs-usage`
|
||||
### `feat/alpine-settings-workspace`
|
||||
|
||||
Status: completed
|
||||
|
||||
DoD:
|
||||
- `Logs` page читает live invocation logs из `admin-api`
|
||||
- `Logs` page использует workspace-scoped filters и polling вместо seed data
|
||||
- `Usage` page читает live usage overview из `admin-api`
|
||||
- `Usage` page строит chart/table/stat cards из реальных usage aggregates
|
||||
- `Workspace setup` page использует live create/edit workspace flow через `admin-api`
|
||||
- список workspace обновляется после create/edit без локальных моков
|
||||
- members и invitations читаются из live backend
|
||||
- create workspace умеет отправлять initial invites
|
||||
- `test-ui` остается нетронутым как fallback
|
||||
|
||||
## Next
|
||||
|
||||
- `feat/alpine-settings-workspace`
|
||||
- `feat/alpine-login-auth-gap`
|
||||
|
||||
## Backlog
|
||||
|
||||
|
||||
+17
-10
@@ -13,6 +13,7 @@
|
||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
<script src="js/workspace.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -101,6 +102,12 @@
|
||||
|
||||
<!-- ── Sidebar nav ── -->
|
||||
<nav class="settings-nav" id="settings-nav">
|
||||
<button class="settings-nav-item" data-section="workspace">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M2 5.5h12M4 2.5h8a1.5 1.5 0 011.5 1.5v8A1.5 1.5 0 0112 13.5H4A1.5 1.5 0 012.5 12V4A1.5 1.5 0 014 2.5z"/>
|
||||
</svg>
|
||||
<span>Workspace</span>
|
||||
</button>
|
||||
<button class="settings-nav-item active" data-section="profile">
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="5" r="3"/>
|
||||
@@ -126,7 +133,7 @@
|
||||
<!-- ── Content ── -->
|
||||
<div id="settings-content">
|
||||
|
||||
<div id="section-workspace" style="display:none;">
|
||||
<div id="section-workspace" class="section-anchor">
|
||||
<div class="section-card" style="margin-bottom: 20px;">
|
||||
<div class="section-card-header">
|
||||
<div>
|
||||
@@ -137,21 +144,21 @@
|
||||
<div class="section-card-body" style="padding-bottom:8px;">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.ws.name">Workspace name</label>
|
||||
<input class="field-input" type="text" value="acme-workspace" autocomplete="off">
|
||||
<input class="field-input" id="settings-ws-slug" type="text" value="acme-workspace" autocomplete="off">
|
||||
<div class="field-hint">Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.</div>
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.ws.display">Display name</label>
|
||||
<input class="field-input" type="text" value="Acme Corp" autocomplete="off">
|
||||
<input class="field-input" id="settings-ws-display-name" type="text" value="Acme Corp" autocomplete="off">
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label">Description <span style="font-size:11px;color:var(--text-muted);">(optional)</span></label>
|
||||
<textarea class="field-textarea" rows="2" placeholder="What does this workspace do?">Primary production workspace for CRM and e-commerce API operations.</textarea>
|
||||
<textarea class="field-textarea" id="settings-ws-description" rows="2" placeholder="What does this workspace do?">Primary production workspace for CRM and e-commerce API operations.</textarea>
|
||||
</div>
|
||||
<div class="field-row">
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.ws.tz">Timezone</label>
|
||||
<select class="field-select">
|
||||
<select class="field-select" id="settings-ws-timezone">
|
||||
<option selected>UTC</option>
|
||||
<option>America/New_York</option>
|
||||
<option>America/Los_Angeles</option>
|
||||
@@ -162,10 +169,10 @@
|
||||
</div>
|
||||
<div class="field-group">
|
||||
<label class="field-label" data-i18n="settings.ws.protocol">Default protocol</label>
|
||||
<select class="field-select">
|
||||
<option selected>REST / HTTP</option>
|
||||
<option>GraphQL</option>
|
||||
<option>gRPC</option>
|
||||
<select class="field-select" id="settings-ws-protocol">
|
||||
<option value="rest" selected>REST / HTTP</option>
|
||||
<option value="graphql">GraphQL</option>
|
||||
<option value="grpc">gRPC</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,7 +195,7 @@
|
||||
</div>
|
||||
|
||||
<div style="display:flex;justify-content:flex-end;padding-top:4px;">
|
||||
<button class="btn-primary" type="button"><span data-i18n="btn.save">Save changes</span></button>
|
||||
<button class="btn-primary" id="settings-ws-save-btn" type="button"><span data-i18n="btn.save">Save changes</span></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<script>try{if(!localStorage.getItem('crank_user'))window.location.replace('login.html');}catch(e){}</script>
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ws-setup-page">
|
||||
@@ -304,14 +305,14 @@
|
||||
<div class="danger-zone-title">Export all data</div>
|
||||
<div class="danger-zone-desc">Download a ZIP archive of all operation configs, schemas, mappings and invocation history as JSON.</div>
|
||||
</div>
|
||||
<button class="btn-danger" type="button">Export</button>
|
||||
<button class="btn-danger" id="export-workspace-btn" type="button">Export</button>
|
||||
</div>
|
||||
<div class="danger-zone-action">
|
||||
<div class="danger-zone-text">
|
||||
<div class="danger-zone-title">Delete workspace</div>
|
||||
<div class="danger-zone-desc">Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.</div>
|
||||
</div>
|
||||
<button class="btn-danger" type="button" onclick="if(confirm('Delete workspace? This cannot be undone.')) { localStorage.clear(); window.location.href = 'login.html'; }">Delete workspace</button>
|
||||
<button class="btn-danger" id="delete-workspace-btn" type="button">Delete workspace</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,6 +92,27 @@
|
||||
listWorkspaces: function() {
|
||||
return get('/workspaces');
|
||||
},
|
||||
createWorkspace: function(payload) {
|
||||
return post('/workspaces', payload);
|
||||
},
|
||||
getWorkspace: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId));
|
||||
},
|
||||
updateWorkspace: function(workspaceId, payload) {
|
||||
return patch('/workspaces/' + encodeURIComponent(workspaceId), payload);
|
||||
},
|
||||
listMemberships: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/members');
|
||||
},
|
||||
listInvitations: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations');
|
||||
},
|
||||
createInvitation: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations', payload);
|
||||
},
|
||||
deleteInvitation: function(workspaceId, invitationId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/invitations/' + encodeURIComponent(invitationId));
|
||||
},
|
||||
listOperations: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations');
|
||||
},
|
||||
|
||||
+147
-52
@@ -1,25 +1,39 @@
|
||||
// Populate profile from stored user
|
||||
try {
|
||||
var u = JSON.parse(localStorage.getItem('crank_user'));
|
||||
if (u) {
|
||||
document.getElementById('profile-avatar').textContent = u.initials || 'AT';
|
||||
document.getElementById('profile-display-name').textContent = u.name || 'Operator';
|
||||
document.getElementById('profile-email').textContent = u.email || '';
|
||||
var fi = document.getElementById('field-firstname');
|
||||
var fe = document.getElementById('field-email');
|
||||
if (fi && u.name) fi.value = u.name;
|
||||
if (fe && u.email) fe.value = u.email;
|
||||
}
|
||||
} catch (e) {}
|
||||
function populateProfile() {
|
||||
try {
|
||||
var user = JSON.parse(localStorage.getItem('crank_user'));
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Inject language switcher into section-profile card body, before Save button
|
||||
(function() {
|
||||
document.getElementById('profile-avatar').textContent = user.initials || 'AT';
|
||||
document.getElementById('profile-display-name').textContent = user.name || 'Operator';
|
||||
document.getElementById('profile-email').textContent = user.email || '';
|
||||
|
||||
var firstName = document.getElementById('field-firstname');
|
||||
var email = document.getElementById('field-email');
|
||||
if (firstName && user.name) {
|
||||
firstName.value = user.name;
|
||||
}
|
||||
if (email && user.email) {
|
||||
email.value = user.email;
|
||||
}
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
function injectLanguageSwitcher() {
|
||||
var profileSection = document.getElementById('section-profile');
|
||||
if (!profileSection) return;
|
||||
if (!profileSection) {
|
||||
return;
|
||||
}
|
||||
|
||||
var cardBody = profileSection.querySelector('.section-card-body');
|
||||
if (!cardBody) return;
|
||||
if (!cardBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('html/fragments/lang-switcher.html')
|
||||
.then(function(r) { return r.text(); })
|
||||
.then(function(response) { return response.text(); })
|
||||
.then(function(html) {
|
||||
var saveRow = cardBody.querySelector('div[style*="justify-content:flex-end"]');
|
||||
if (saveRow) {
|
||||
@@ -27,47 +41,128 @@ try {
|
||||
} else {
|
||||
cardBody.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
if (typeof applyLang === 'function') applyLang();
|
||||
if (typeof applyLang === 'function') {
|
||||
applyLang();
|
||||
}
|
||||
});
|
||||
})();
|
||||
}
|
||||
|
||||
// Settings sidebar navigation
|
||||
var navItems = document.querySelectorAll('.settings-nav-item[data-section]');
|
||||
navItems.forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
navItems.forEach(function(b) { b.classList.remove('active'); });
|
||||
this.classList.add('active');
|
||||
var target = document.getElementById('section-' + this.dataset.section);
|
||||
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
function bindSectionNavigation() {
|
||||
var navItems = document.querySelectorAll('.settings-nav-item[data-section]');
|
||||
navItems.forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
navItems.forEach(function (item) { item.classList.remove('active'); });
|
||||
this.classList.add('active');
|
||||
var target = document.getElementById('section-' + this.dataset.section);
|
||||
if (target) {
|
||||
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Hash navigation (e.g. settings.html#security)
|
||||
if (window.location.hash) {
|
||||
var hash = window.location.hash.slice(1);
|
||||
var btn = document.querySelector('.settings-nav-item[data-section="' + hash + '"]');
|
||||
if (btn) setTimeout(function() { btn.click(); }, 100);
|
||||
}
|
||||
// Default: scroll to profile
|
||||
else {
|
||||
var profileBtn = document.querySelector('.settings-nav-item[data-section="profile"]');
|
||||
if (profileBtn) profileBtn.classList.add('active');
|
||||
}
|
||||
var initialSection = window.location.hash ? window.location.hash.slice(1) : 'profile';
|
||||
var initialButton = document.querySelector('.settings-nav-item[data-section="' + initialSection + '"]')
|
||||
|| document.querySelector('.settings-nav-item[data-section="profile"]');
|
||||
if (initialButton) {
|
||||
setTimeout(function () { initialButton.click(); }, 50);
|
||||
}
|
||||
|
||||
// IntersectionObserver scroll-spy: highlight active sidebar item as user scrolls
|
||||
var observer = new IntersectionObserver(function(entries) {
|
||||
entries.forEach(function(entry) {
|
||||
if (entry.isIntersecting) {
|
||||
var sectionId = entry.target.id; // e.g. "section-profile"
|
||||
var name = sectionId.replace('section-', ''); // e.g. "profile"
|
||||
document.querySelectorAll('.settings-nav-item[data-section]').forEach(function(b) {
|
||||
b.classList.toggle('active', b.dataset.section === name);
|
||||
var observer = new IntersectionObserver(function (entries) {
|
||||
entries.forEach(function (entry) {
|
||||
if (!entry.isIntersecting) {
|
||||
return;
|
||||
}
|
||||
var section = entry.target.id.replace('section-', '');
|
||||
document.querySelectorAll('.settings-nav-item[data-section]').forEach(function (button) {
|
||||
button.classList.toggle('active', button.dataset.section === section);
|
||||
});
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
|
||||
document.querySelectorAll('[id^="section-"]').forEach(function (section) {
|
||||
if (section.style.display !== 'none') {
|
||||
observer.observe(section);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
}
|
||||
|
||||
// Observe all visible sections (not hidden ones)
|
||||
document.querySelectorAll('[id^="section-"]').forEach(function(el) {
|
||||
if (el.style.display !== 'none') observer.observe(el);
|
||||
async function loadWorkspaceSettings() {
|
||||
if (!window.CrankApi || !window.whenWorkspacesReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
await window.whenWorkspacesReady();
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
if (!workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var record = await window.CrankApi.getWorkspace(workspace.id);
|
||||
var item = record.workspace;
|
||||
var settings = item.settings || {};
|
||||
|
||||
document.getElementById('settings-ws-slug').value = item.slug || '';
|
||||
document.getElementById('settings-ws-display-name').value = item.display_name || '';
|
||||
document.getElementById('settings-ws-description').value = settings.description || '';
|
||||
document.getElementById('settings-ws-timezone').value = settings.timezone || 'UTC';
|
||||
document.getElementById('settings-ws-protocol').value = settings.default_protocol || 'rest';
|
||||
|
||||
document.querySelectorAll('#section-workspace .lang-btn').forEach(function (button) {
|
||||
button.classList.toggle('active', button.dataset.lang === (settings.language || (localStorage.getItem('crank_lang') || 'en')));
|
||||
});
|
||||
|
||||
var saveButton = document.getElementById('settings-ws-save-btn');
|
||||
if (saveButton.dataset.bound === 'true') {
|
||||
return;
|
||||
}
|
||||
saveButton.dataset.bound = 'true';
|
||||
|
||||
saveButton.addEventListener('click', async function () {
|
||||
var saveButton = this;
|
||||
var original = saveButton.textContent;
|
||||
saveButton.disabled = true;
|
||||
saveButton.textContent = 'Saving…';
|
||||
|
||||
try {
|
||||
await window.CrankApi.updateWorkspace(item.id, {
|
||||
slug: document.getElementById('settings-ws-slug').value.trim(),
|
||||
display_name: document.getElementById('settings-ws-display-name').value.trim(),
|
||||
settings: {
|
||||
description: document.getElementById('settings-ws-description').value.trim(),
|
||||
timezone: document.getElementById('settings-ws-timezone').value,
|
||||
default_protocol: document.getElementById('settings-ws-protocol').value,
|
||||
language: localStorage.getItem('crank_lang') || 'en',
|
||||
color: (workspace.settings && workspace.settings.color) || '#0d9488',
|
||||
},
|
||||
});
|
||||
|
||||
if (window.refreshWorkspaces) {
|
||||
var workspaces = await window.refreshWorkspaces();
|
||||
var updated = workspaces.find(function (entry) { return entry.id === item.id; });
|
||||
if (updated && window.setCurrentWorkspace) {
|
||||
window.setCurrentWorkspace(updated);
|
||||
}
|
||||
}
|
||||
|
||||
saveButton.textContent = 'Saved ✓';
|
||||
} catch (error) {
|
||||
alert(error.message || 'Failed to save workspace settings');
|
||||
saveButton.textContent = original;
|
||||
} finally {
|
||||
setTimeout(function () {
|
||||
saveButton.disabled = false;
|
||||
saveButton.textContent = original;
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
} catch (_error) {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
populateProfile();
|
||||
injectLanguageSwitcher();
|
||||
bindSectionNavigation();
|
||||
loadWorkspaceSettings();
|
||||
});
|
||||
|
||||
+401
-135
@@ -1,193 +1,459 @@
|
||||
var selectedColor = '#0d9488';
|
||||
var slugManual = false;
|
||||
var isCreateMode = false;
|
||||
var formDirty = false;
|
||||
var workspaceFormState = {
|
||||
selectedColor: '#0d9488',
|
||||
slugManual: false,
|
||||
isCreateMode: false,
|
||||
formDirty: false,
|
||||
workspaceId: null,
|
||||
workspaceRecord: null,
|
||||
memberships: [],
|
||||
invitations: [],
|
||||
};
|
||||
|
||||
function initPage() {
|
||||
function normalizeSlug(value) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
function uiRoleToApi(value) {
|
||||
if (value === 'developer') {
|
||||
return 'operator';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function roleLabel(value) {
|
||||
if (value === 'operator') {
|
||||
return 'Developer';
|
||||
}
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function roleClass(value) {
|
||||
if (value === 'operator') {
|
||||
return 'role-developer';
|
||||
}
|
||||
return 'role-' + value;
|
||||
}
|
||||
|
||||
function currentLanguage() {
|
||||
return localStorage.getItem('crank_lang') || 'en';
|
||||
}
|
||||
|
||||
function setFormDirty(value) {
|
||||
workspaceFormState.formDirty = value;
|
||||
}
|
||||
|
||||
function formElements() {
|
||||
return {
|
||||
name: document.getElementById('ws-name'),
|
||||
slug: document.getElementById('ws-slug'),
|
||||
desc: document.getElementById('ws-desc'),
|
||||
protocol: document.getElementById('ws-protocol'),
|
||||
timezone: document.getElementById('ws-timezone'),
|
||||
submit: document.getElementById('submit-btn'),
|
||||
avatar: document.getElementById('ws-avatar-preview'),
|
||||
};
|
||||
}
|
||||
|
||||
function workspacePayload() {
|
||||
var elements = formElements();
|
||||
return {
|
||||
slug: elements.slug.value.trim(),
|
||||
display_name: elements.name.value.trim(),
|
||||
settings: {
|
||||
description: elements.desc.value.trim(),
|
||||
default_protocol: elements.protocol.value,
|
||||
timezone: elements.timezone.value,
|
||||
language: currentLanguage(),
|
||||
color: workspaceFormState.selectedColor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function applyWorkspaceRecord(record) {
|
||||
var workspace = record.workspace;
|
||||
var settings = workspace.settings || {};
|
||||
var elements = formElements();
|
||||
|
||||
workspaceFormState.workspaceId = workspace.id;
|
||||
workspaceFormState.workspaceRecord = record;
|
||||
workspaceFormState.selectedColor = settings.color || '#0d9488';
|
||||
workspaceFormState.slugManual = true;
|
||||
|
||||
elements.name.value = workspace.display_name || '';
|
||||
elements.slug.value = workspace.slug || '';
|
||||
elements.desc.value = settings.description || '';
|
||||
elements.protocol.value = settings.default_protocol || 'rest';
|
||||
elements.timezone.value = settings.timezone || 'UTC';
|
||||
elements.avatar.textContent = (workspace.display_name || workspace.slug || '?').charAt(0).toUpperCase();
|
||||
elements.avatar.style.background = workspaceFormState.selectedColor;
|
||||
|
||||
document.querySelectorAll('.ws-color-swatch').forEach(function (swatch) {
|
||||
swatch.classList.toggle('active', swatch.dataset.color === workspaceFormState.selectedColor);
|
||||
});
|
||||
|
||||
setFormDirty(false);
|
||||
}
|
||||
|
||||
function addMemberRow(container, membership) {
|
||||
var row = document.createElement('div');
|
||||
row.className = 'member-row';
|
||||
row.innerHTML =
|
||||
'<div class="member-avatar" style="background:linear-gradient(135deg,' + workspaceFormState.selectedColor + ',#6366f1);">' +
|
||||
membership.user.display_name.slice(0, 2).toUpperCase() +
|
||||
'</div>' +
|
||||
'<div class="member-info">' +
|
||||
'<div class="member-name">' + membership.user.display_name + '</div>' +
|
||||
'<div class="member-email">' + membership.user.email + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="member-last-active">—</div>' +
|
||||
'<span class="member-role-badge ' + roleClass(membership.role) + '">' + roleLabel(membership.role) + '</span>';
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
function renderMembers() {
|
||||
var container = document.getElementById('members-list');
|
||||
var label = document.getElementById('members-count-label');
|
||||
if (!container || !label) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = '';
|
||||
workspaceFormState.memberships.forEach(function (membership) {
|
||||
addMemberRow(container, membership);
|
||||
});
|
||||
label.textContent = workspaceFormState.memberships.length + ' member' + (workspaceFormState.memberships.length === 1 ? '' : 's');
|
||||
}
|
||||
|
||||
function renderInvitations() {
|
||||
var pending = document.getElementById('pending-invites');
|
||||
var count = document.getElementById('pending-count');
|
||||
if (!pending || !count) {
|
||||
return;
|
||||
}
|
||||
|
||||
var items = workspaceFormState.invitations.filter(function (record) {
|
||||
return record.invitation.status === 'pending';
|
||||
});
|
||||
|
||||
count.textContent = String(items.length);
|
||||
if (!items.length) {
|
||||
pending.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
pending.style.display = '';
|
||||
pending.innerHTML =
|
||||
'<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin-bottom:10px;">' +
|
||||
'Pending invites <span id="pending-count" style="background:rgba(139,148,158,0.15);border-radius:10px;padding:1px 7px;font-size:10px;">' + items.length + '</span>' +
|
||||
'</div>';
|
||||
|
||||
items.forEach(function (record) {
|
||||
var invitation = record.invitation;
|
||||
var row = document.createElement('div');
|
||||
row.className = 'member-row';
|
||||
row.innerHTML =
|
||||
'<div class="member-avatar" style="background:var(--bg-canvas);border:1.5px dashed var(--border);color:var(--text-muted);font-size:16px;">?</div>' +
|
||||
'<div class="member-info">' +
|
||||
'<div class="member-name">' + invitation.email + '</div>' +
|
||||
'<div class="member-email">Invited ' + invitation.created_at.slice(0, 10) + ' · pending</div>' +
|
||||
'</div>' +
|
||||
'<div class="member-last-active">—</div>' +
|
||||
'<span class="member-role-badge" style="background:rgba(139,148,158,0.12);color:var(--text-muted);border:1px solid var(--border);">' + roleLabel(invitation.role) + '</span>' +
|
||||
'<button class="member-remove-btn" type="button" onclick="revokeInviteById(\'' + invitation.id + '\')" title="Revoke invite">' +
|
||||
'<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="2" y1="2" x2="14" y2="14"/><line x1="14" y1="2" x2="2" y2="14"/></svg>' +
|
||||
'</button>';
|
||||
pending.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadWorkspaceAccessData() {
|
||||
if (workspaceFormState.isCreateMode || !window.CrankApi || !workspaceFormState.workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
var membershipsResponse = await window.CrankApi.listMemberships(workspaceFormState.workspaceId);
|
||||
var invitationsResponse = await window.CrankApi.listInvitations(workspaceFormState.workspaceId);
|
||||
workspaceFormState.memberships = membershipsResponse && membershipsResponse.items ? membershipsResponse.items : [];
|
||||
workspaceFormState.invitations = invitationsResponse && invitationsResponse.items ? invitationsResponse.items : [];
|
||||
renderMembers();
|
||||
renderInvitations();
|
||||
}
|
||||
|
||||
function updatePageMode() {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
isCreateMode = params.get('mode') === 'create';
|
||||
workspaceFormState.isCreateMode = params.get('mode') === 'create';
|
||||
|
||||
if (isCreateMode) {
|
||||
var submit = document.getElementById('submit-btn');
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
document.title = 'Crank — Create workspace';
|
||||
document.getElementById('page-title').textContent = 'Create a new workspace';
|
||||
document.getElementById('page-subtitle').textContent = 'A workspace is a shared environment for your team\'s MCP operations and agents. Each workspace gets its own MCP endpoint namespace.';
|
||||
document.getElementById('section-invite').style.display = '';
|
||||
document.getElementById('footer-note').style.display = '';
|
||||
var btn = document.getElementById('submit-btn');
|
||||
btn.textContent = 'Create workspace';
|
||||
btn.disabled = true;
|
||||
btn.style.opacity = '0.5';
|
||||
btn.style.cursor = 'not-allowed';
|
||||
submit.textContent = 'Create workspace';
|
||||
submit.disabled = true;
|
||||
submit.style.opacity = '0.5';
|
||||
submit.style.cursor = 'not-allowed';
|
||||
} else {
|
||||
// Edit mode — prefill from current workspace
|
||||
document.getElementById('section-members').style.display = '';
|
||||
document.getElementById('section-danger').style.display = '';
|
||||
var ws = getCurrentWs();
|
||||
document.getElementById('ws-name').value = ws.name;
|
||||
document.getElementById('ws-slug').value = ws.slug;
|
||||
document.getElementById('ws-avatar-preview').textContent = ws.letter;
|
||||
document.getElementById('ws-avatar-preview').style.background = ws.color;
|
||||
selectedColor = ws.color;
|
||||
slugManual = true;
|
||||
// Activate matching color swatch
|
||||
document.querySelectorAll('.ws-color-swatch').forEach(function(s) {
|
||||
s.classList.toggle('active', s.dataset.color === ws.color);
|
||||
});
|
||||
// Scroll to #section-members if hash present
|
||||
if (window.location.hash === '#section-members') {
|
||||
setTimeout(function() {
|
||||
document.getElementById('section-members').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
}
|
||||
submit.textContent = 'Save changes';
|
||||
}
|
||||
}
|
||||
|
||||
// Apply language state to lang buttons
|
||||
var lang = localStorage.getItem('crank_lang') || 'en';
|
||||
document.querySelectorAll('.lang-btn').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.dataset.lang === lang);
|
||||
function onWsNameInput(value) {
|
||||
var elements = formElements();
|
||||
var normalized = value.trim();
|
||||
elements.avatar.textContent = normalized ? normalized.charAt(0).toUpperCase() : '?';
|
||||
if (!workspaceFormState.slugManual) {
|
||||
elements.slug.value = normalizeSlug(value);
|
||||
}
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
validateCreateForm();
|
||||
}
|
||||
setFormDirty(true);
|
||||
}
|
||||
|
||||
function onWsSlugInput(value) {
|
||||
formElements().slug.value = normalizeSlug(value);
|
||||
workspaceFormState.slugManual = true;
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
validateCreateForm();
|
||||
}
|
||||
setFormDirty(true);
|
||||
}
|
||||
|
||||
function pickColor(element) {
|
||||
workspaceFormState.selectedColor = element.dataset.color;
|
||||
document.querySelectorAll('.ws-color-swatch').forEach(function (swatch) {
|
||||
swatch.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
function onWsNameInput(val) {
|
||||
var letter = val.trim() ? val.trim()[0].toUpperCase() : '?';
|
||||
document.getElementById('ws-avatar-preview').textContent = letter;
|
||||
if (!slugManual) {
|
||||
var slug = val.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
||||
document.getElementById('ws-slug').value = slug;
|
||||
}
|
||||
if (isCreateMode) validateCreateForm();
|
||||
formDirty = true;
|
||||
}
|
||||
|
||||
function onWsSlugInput(val) {
|
||||
var clean = val.toLowerCase().replace(/[^a-z0-9-]/g, '').replace(/-+/g, '-');
|
||||
document.getElementById('ws-slug').value = clean;
|
||||
slugManual = true;
|
||||
if (isCreateMode) validateCreateForm();
|
||||
formDirty = true;
|
||||
}
|
||||
|
||||
function pickColor(el) {
|
||||
selectedColor = el.dataset.color;
|
||||
document.querySelectorAll('.ws-color-swatch').forEach(function(s) { s.classList.remove('active'); });
|
||||
el.classList.add('active');
|
||||
document.getElementById('ws-avatar-preview').style.background = selectedColor;
|
||||
formDirty = true;
|
||||
element.classList.add('active');
|
||||
formElements().avatar.style.background = workspaceFormState.selectedColor;
|
||||
setFormDirty(true);
|
||||
}
|
||||
|
||||
function validateCreateForm() {
|
||||
var name = document.getElementById('ws-name').value.trim();
|
||||
var slug = document.getElementById('ws-slug').value.trim();
|
||||
var btn = document.getElementById('submit-btn');
|
||||
var valid = name.length > 0 && slug.length > 0;
|
||||
btn.disabled = !valid;
|
||||
btn.style.opacity = valid ? '1' : '0.5';
|
||||
btn.style.cursor = valid ? 'pointer' : 'not-allowed';
|
||||
var elements = formElements();
|
||||
var valid = Boolean(elements.name.value.trim() && elements.slug.value.trim());
|
||||
elements.submit.disabled = !valid;
|
||||
elements.submit.style.opacity = valid ? '1' : '0.5';
|
||||
elements.submit.style.cursor = valid ? 'pointer' : 'not-allowed';
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
var name = document.getElementById('ws-name').value.trim();
|
||||
var slug = document.getElementById('ws-slug').value.trim();
|
||||
if (!name || !slug) return;
|
||||
function inviteRows() {
|
||||
return Array.from(document.querySelectorAll('#invite-rows .invite-row')).map(function (row) {
|
||||
return {
|
||||
email: row.querySelector('input').value.trim(),
|
||||
role: uiRoleToApi(row.querySelector('select').value),
|
||||
};
|
||||
}).filter(function (row) {
|
||||
return row.email;
|
||||
});
|
||||
}
|
||||
|
||||
formDirty = false;
|
||||
async function submitForm() {
|
||||
var payload = workspacePayload();
|
||||
if (!payload.display_name || !payload.slug || !window.CrankApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreateMode) {
|
||||
if (window.WS_LIST) {
|
||||
WS_LIST.push({ slug: slug, name: slug, role: 'Owner', letter: name[0].toUpperCase(), color: selectedColor });
|
||||
var submit = document.getElementById('submit-btn');
|
||||
var originalLabel = submit.textContent;
|
||||
submit.disabled = true;
|
||||
submit.textContent = workspaceFormState.isCreateMode ? 'Creating…' : 'Saving…';
|
||||
|
||||
try {
|
||||
var response;
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
response = await window.CrankApi.createWorkspace(payload);
|
||||
} else {
|
||||
response = await window.CrankApi.updateWorkspace(workspaceFormState.workspaceId, payload);
|
||||
}
|
||||
localStorage.setItem('crank_workspace', slug);
|
||||
window.location.href = (window.APP_BASE||'')+'index.html';
|
||||
} else {
|
||||
// Update current workspace in WS_LIST
|
||||
if (window.WS_LIST) {
|
||||
var current = localStorage.getItem('crank_workspace') || 'acme-workspace';
|
||||
var idx = WS_LIST.findIndex(function(w) { return w.slug === current; });
|
||||
if (idx !== -1) {
|
||||
WS_LIST[idx].name = slug;
|
||||
WS_LIST[idx].slug = slug;
|
||||
WS_LIST[idx].letter = name[0].toUpperCase();
|
||||
WS_LIST[idx].color = selectedColor;
|
||||
|
||||
var record = response && response.workspace ? response : { workspace: response.workspace || response };
|
||||
var workspace = record.workspace;
|
||||
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
var invites = inviteRows();
|
||||
for (var index = 0; index < invites.length; index += 1) {
|
||||
await window.CrankApi.createInvitation(workspace.id, {
|
||||
email: invites[index].email,
|
||||
role: invites[index].role,
|
||||
});
|
||||
}
|
||||
}
|
||||
localStorage.setItem('crank_workspace', slug);
|
||||
// Show saved feedback
|
||||
var btn = document.getElementById('submit-btn');
|
||||
var orig = btn.textContent;
|
||||
btn.textContent = 'Saved ✓';
|
||||
btn.disabled = true;
|
||||
setTimeout(function() { btn.textContent = orig; btn.disabled = false; }, 1800);
|
||||
|
||||
var workspaces = await window.refreshWorkspaces();
|
||||
var mapped = workspaces.find(function (item) { return item.id === workspace.id; }) || {
|
||||
id: workspace.id,
|
||||
slug: workspace.slug,
|
||||
name: workspace.display_name,
|
||||
role: 'Owner',
|
||||
letter: (workspace.display_name || workspace.slug).charAt(0).toUpperCase(),
|
||||
color: payload.settings.color || '#0d9488',
|
||||
status: workspace.status,
|
||||
settings: payload.settings,
|
||||
};
|
||||
|
||||
window.setCurrentWorkspace(mapped);
|
||||
workspaceFormState.workspaceId = workspace.id;
|
||||
workspaceFormState.workspaceRecord = { workspace: workspace };
|
||||
setFormDirty(false);
|
||||
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
window.location.href = (window.APP_BASE || '') + 'html/workspace-setup.html';
|
||||
return;
|
||||
}
|
||||
|
||||
applyWorkspaceRecord({ workspace: workspace });
|
||||
await loadWorkspaceAccessData();
|
||||
submit.textContent = 'Saved ✓';
|
||||
setTimeout(function () {
|
||||
submit.textContent = originalLabel;
|
||||
submit.disabled = false;
|
||||
}, 1400);
|
||||
} catch (error) {
|
||||
alert(error.message || 'Failed to save workspace');
|
||||
submit.disabled = false;
|
||||
submit.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// Create mode invite rows
|
||||
function addInviteRow() {
|
||||
var rows = document.getElementById('invite-rows');
|
||||
var div = document.createElement('div');
|
||||
div.className = 'invite-row';
|
||||
div.innerHTML =
|
||||
var node = document.createElement('div');
|
||||
node.className = 'invite-row';
|
||||
node.innerHTML =
|
||||
'<input class="form-input" type="email" placeholder="colleague@company.com" autocomplete="off" style="flex:1;margin-bottom:0;">' +
|
||||
'<select class="form-input" style="width:130px;margin-bottom:0;">' +
|
||||
'<option value="admin">Admin</option>' +
|
||||
'<option value="developer" selected>Developer</option>' +
|
||||
'<option value="viewer">Viewer</option>' +
|
||||
'</select>' +
|
||||
'<button class="invite-row-remove" onclick="removeInviteRow(this)" title="Remove">' +
|
||||
'<svg width="12" height="12"><use href="' + (window.APP_BASE||'') + 'icons/general/close.svg#icon"/></svg>' +
|
||||
'<button class="invite-row-remove" type="button" onclick="removeInviteRow(this)" title="Remove">' +
|
||||
'<svg width="12" height="12"><use href="' + (window.APP_BASE || '') + 'icons/general/close.svg#icon"/></svg>' +
|
||||
'</button>';
|
||||
rows.appendChild(div);
|
||||
rows.appendChild(node);
|
||||
}
|
||||
|
||||
function removeInviteRow(btn) {
|
||||
btn.closest('.invite-row').remove();
|
||||
function removeInviteRow(button) {
|
||||
button.closest('.invite-row').remove();
|
||||
}
|
||||
|
||||
// Members management (edit mode)
|
||||
function toggleInviteForm() {
|
||||
var f = document.getElementById('invite-form');
|
||||
var s = document.getElementById('invite-success');
|
||||
if (f.style.display === 'none') {
|
||||
f.style.display = '';
|
||||
s.style.display = 'none';
|
||||
var form = document.getElementById('invite-form');
|
||||
var success = document.getElementById('invite-success');
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.style.display === 'none') {
|
||||
form.style.display = '';
|
||||
if (success) {
|
||||
success.style.display = 'none';
|
||||
}
|
||||
document.getElementById('invite-email').focus();
|
||||
} else {
|
||||
f.style.display = 'none';
|
||||
form.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function sendInvite() {
|
||||
async function sendInvite() {
|
||||
if (!window.CrankApi || !workspaceFormState.workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
var email = document.getElementById('invite-email').value.trim();
|
||||
if (!email || !email.includes('@')) return;
|
||||
document.getElementById('invite-success').style.display = '';
|
||||
document.getElementById('invite-email').value = '';
|
||||
var role = uiRoleToApi(document.getElementById('invite-role').value);
|
||||
if (!email || email.indexOf('@') === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.CrankApi.createInvitation(workspaceFormState.workspaceId, { email: email, role: role });
|
||||
document.getElementById('invite-email').value = '';
|
||||
document.getElementById('invite-success').style.display = '';
|
||||
await loadWorkspaceAccessData();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Failed to send invite');
|
||||
}
|
||||
}
|
||||
|
||||
function updateRole(select, email) {
|
||||
console.log('Role updated:', email, '->', select.value);
|
||||
function updateRole() {
|
||||
alert('Role management is not available yet in the backend.');
|
||||
}
|
||||
|
||||
function removeMember(btn, name) {
|
||||
if (!confirm('Remove ' + name + ' from this workspace?')) return;
|
||||
btn.closest('.member-row').remove();
|
||||
var rows = document.querySelectorAll('#members-list .member-row');
|
||||
var label = document.getElementById('members-count-label');
|
||||
if (label) label.textContent = rows.length + ' member' + (rows.length !== 1 ? 's' : '');
|
||||
function removeMember() {
|
||||
alert('Member removal is not available yet in the backend.');
|
||||
}
|
||||
|
||||
function revokeInvite(btn) {
|
||||
btn.closest('.member-row').remove();
|
||||
var pending = document.getElementById('pending-invites');
|
||||
if (pending && !pending.querySelector('.member-row')) pending.style.display = 'none';
|
||||
async function revokeInviteById(invitationId) {
|
||||
if (!window.CrankApi || !workspaceFormState.workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.CrankApi.deleteInvitation(workspaceFormState.workspaceId, invitationId);
|
||||
await loadWorkspaceAccessData();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Failed to revoke invite');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
initPage();
|
||||
if (isCreateMode) document.getElementById('ws-name').focus();
|
||||
});
|
||||
function revokeInvite(button) {
|
||||
var row = button.closest('.member-row');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
row.remove();
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (formDirty) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
async function initPage() {
|
||||
updatePageMode();
|
||||
|
||||
var language = currentLanguage();
|
||||
document.querySelectorAll('.lang-btn').forEach(function (button) {
|
||||
button.classList.toggle('active', button.dataset.lang === language);
|
||||
});
|
||||
|
||||
document.getElementById('export-workspace-btn').addEventListener('click', function () {
|
||||
alert('Workspace export is not available yet in the backend.');
|
||||
});
|
||||
|
||||
document.getElementById('delete-workspace-btn').addEventListener('click', function () {
|
||||
alert('Workspace deletion is not available yet in the backend.');
|
||||
});
|
||||
|
||||
if (workspaceFormState.isCreateMode) {
|
||||
document.getElementById('ws-name').focus();
|
||||
return;
|
||||
}
|
||||
|
||||
await window.whenWorkspacesReady();
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
if (!workspace || !window.CrankApi) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var record = await window.CrankApi.getWorkspace(workspace.id);
|
||||
applyWorkspaceRecord(record);
|
||||
await loadWorkspaceAccessData();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Failed to load workspace');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initPage);
|
||||
|
||||
window.addEventListener('beforeunload', function (event) {
|
||||
if (workspaceFormState.formDirty) {
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
+16
-1
@@ -12,14 +12,16 @@ function workspaceColor(index) {
|
||||
function mapWorkspace(record, index) {
|
||||
var workspace = record && record.workspace ? record.workspace : record;
|
||||
var displayName = workspace.display_name || workspace.slug || workspace.id;
|
||||
var settings = workspace.settings || {};
|
||||
return {
|
||||
id: workspace.id,
|
||||
slug: workspace.slug,
|
||||
name: displayName,
|
||||
role: 'Owner',
|
||||
letter: displayName.charAt(0).toUpperCase(),
|
||||
color: workspaceColor(index),
|
||||
color: settings.color || workspaceColor(index),
|
||||
status: workspace.status,
|
||||
settings: settings,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -113,6 +115,11 @@ async function loadWorkspaces() {
|
||||
return workspaceLoadPromise;
|
||||
}
|
||||
|
||||
async function refreshWorkspaces() {
|
||||
workspaceLoadPromise = null;
|
||||
return loadWorkspaces();
|
||||
}
|
||||
|
||||
function initWorkspaceSwitcher() {
|
||||
renderWorkspaceList();
|
||||
loadWorkspaces();
|
||||
@@ -134,8 +141,16 @@ function switchWorkspace(workspaceId) {
|
||||
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace }));
|
||||
}
|
||||
|
||||
function setCurrentWorkspace(workspace) {
|
||||
persistCurrentWorkspace(workspace);
|
||||
renderWorkspaceList();
|
||||
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace }));
|
||||
}
|
||||
|
||||
window.getCurrentWorkspace = getCurrentWs;
|
||||
window.whenWorkspacesReady = loadWorkspaces;
|
||||
window.refreshWorkspaces = refreshWorkspaces;
|
||||
window.setCurrentWorkspace = setCurrentWorkspace;
|
||||
window.hasWorkspaceApiFailure = function() { return workspaceLoadFailed; };
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher);
|
||||
|
||||
@@ -307,21 +307,21 @@ UI-файлы:
|
||||
|
||||
Что еще не хватает:
|
||||
|
||||
- `switch current workspace` как backend-aware UI state, а не `localStorage`;
|
||||
- нормальная серверная модель current user + memberships;
|
||||
- endpoint на удаление участника или изменение роли, если UI хочет это поддерживать
|
||||
- `switch current workspace` все еще живет на клиенте, а не в session/backend;
|
||||
- endpoint на удаление участника или изменение роли, если UI хочет это поддерживать;
|
||||
- delete/export workspace lifecycle пока не реализован на backend
|
||||
|
||||
Отдельный конфликт:
|
||||
|
||||
- UI создает workspace локально и сразу считает его текущим;
|
||||
- backend уже умеет создавать workspace, но у нас нет полноценной user-session модели;
|
||||
- значит выбор текущего workspace пока придется держать на клиенте, но список и metadata брать с сервера.
|
||||
- current workspace по-прежнему client-side;
|
||||
- memberships и invitations уже live, но role-management пока read-only;
|
||||
- `settings` page не должна дублировать этот flow, пока у нее нет своего backend-контракта
|
||||
|
||||
Простой итог:
|
||||
|
||||
- create/edit workspace уже можно подключать;
|
||||
- members/invites тоже частично готовы;
|
||||
- но полноценный switch и role management еще не закрыты.
|
||||
- `workspace-setup` уже подключен к live backend;
|
||||
- create/edit workspace, refresh списка workspace и invitations работают;
|
||||
- role management и workspace deletion остаются отдельным следующим этапом.
|
||||
|
||||
### 4.8. Settings
|
||||
|
||||
@@ -339,7 +339,7 @@ UI-файлы:
|
||||
|
||||
Что уже можно подключить:
|
||||
|
||||
- частично `GET /api/admin/workspaces/{workspace_id}`
|
||||
- workspace block через `GET /api/admin/workspaces/{workspace_id}`
|
||||
- `PATCH /api/admin/workspaces/{workspace_id}`
|
||||
|
||||
Что еще не хватает:
|
||||
@@ -359,8 +359,9 @@ UI-файлы:
|
||||
|
||||
Простой итог:
|
||||
|
||||
- эту страницу нельзя подключать целиком сразу;
|
||||
- сначала надо отделить workspace settings от пользовательского профиля.
|
||||
- workspace block уже можно держать live;
|
||||
- profile/security остаются временно mock/read-only;
|
||||
- страницу целиком считать интегрированной пока нельзя.
|
||||
|
||||
### 4.9. Login
|
||||
|
||||
|
||||
Reference in New Issue
Block a user