feat: complete Epic 1 production foundation
This commit is contained in:
@@ -0,0 +1,488 @@
|
||||
(function() {
|
||||
var STEP_ORDER = ['operation', 'test', 'publish_operation', 'agent', 'key', 'mcp_connection', 'first_call'];
|
||||
var state = {
|
||||
epoch: 0,
|
||||
workspaceId: null,
|
||||
snapshot: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
open: false,
|
||||
dismissed: false,
|
||||
controller: null,
|
||||
refreshPromise: null,
|
||||
refreshQueued: false,
|
||||
startedRequested: false,
|
||||
};
|
||||
var channel = null;
|
||||
var trigger = null;
|
||||
var panel = null;
|
||||
var heading = null;
|
||||
var subtitle = null;
|
||||
var collapseButton = null;
|
||||
var lastFocused = null;
|
||||
|
||||
function tKey(key) { return window.t ? window.t(key) : key; }
|
||||
function currentWorkspace() { return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; }
|
||||
function preferenceKey() { return 'crank_onboarding_ui:' + (state.workspaceId || 'none'); }
|
||||
function safePreference() {
|
||||
try { return JSON.parse(localStorage.getItem(preferenceKey())) || {}; } catch (_error) { return {}; }
|
||||
}
|
||||
function writePreference(value) {
|
||||
try { localStorage.setItem(preferenceKey(), JSON.stringify(value)); } catch (_error) {}
|
||||
}
|
||||
function updatePreference(value) {
|
||||
writePreference(Object.assign({}, safePreference(), value));
|
||||
}
|
||||
function injectStylesheet() {
|
||||
if (document.querySelector('link[data-crank-onboarding-css]')) return;
|
||||
var link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = (window.APP_BASE || '/') + 'css/onboarding.css';
|
||||
link.dataset.crankOnboardingCss = 'true';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
function button(label, testId, handler, className) {
|
||||
var node = document.createElement('button');
|
||||
node.type = 'button';
|
||||
node.textContent = label;
|
||||
if (testId) node.dataset.testid = testId;
|
||||
node.className = className || 'onboarding-icon-button';
|
||||
node.addEventListener('click', handler);
|
||||
return node;
|
||||
}
|
||||
function normalizeStep(raw) {
|
||||
var id = raw.id || raw.kind;
|
||||
if (id === 'publish') id = 'publish_operation';
|
||||
if (id === 'connection') id = 'mcp_connection';
|
||||
var completed = raw.completed === true || raw.status === 'complete';
|
||||
return {
|
||||
id: id,
|
||||
completed: completed,
|
||||
status: raw.status || (completed ? 'complete' : 'pending'),
|
||||
actionCode: raw.action_code || raw.action || '',
|
||||
reasonCode: raw.reason_code || '',
|
||||
};
|
||||
}
|
||||
function normalizedSteps() {
|
||||
var raw = state.snapshot && Array.isArray(state.snapshot.steps) ? state.snapshot.steps : [];
|
||||
var mapped = raw.map(normalizeStep);
|
||||
return STEP_ORDER.map(function(id) {
|
||||
return mapped.find(function(item) { return item.id === id; }) || { id: id, completed: false, status: 'pending', actionCode: '', reasonCode: '' };
|
||||
});
|
||||
}
|
||||
function completedCount() { return normalizedSteps().filter(function(step) { return step.completed; }).length; }
|
||||
function currentStep(steps) {
|
||||
return steps.find(function(step) { return !step.completed && step.status === 'current'; })
|
||||
|| steps.find(function(step) { return !step.completed; }) || null;
|
||||
}
|
||||
function stepLabel(id) { return tKey('onboarding.step.' + id); }
|
||||
function actionLabel(id) { return tKey('onboarding.action.' + id); }
|
||||
function deepLink(step) {
|
||||
var snapshot = state.snapshot || {};
|
||||
var returnTo = encodeURIComponent(window.location.pathname + window.location.search);
|
||||
if (step.id === 'operation') return '/wizard/?onboarding=1&return=' + returnTo;
|
||||
if (step.id === 'test' || step.id === 'publish_operation') {
|
||||
if (!snapshot.operation_id) return '/wizard/?onboarding=1&return=' + returnTo;
|
||||
return '/wizard/?mode=edit&operationId=' + encodeURIComponent(snapshot.operation_id)
|
||||
+ '&onboarding=1&step=5&return=' + returnTo;
|
||||
}
|
||||
if (step.id === 'agent') {
|
||||
return '/agents?onboarding=1&action=create&operationId=' + encodeURIComponent(snapshot.operation_id || '')
|
||||
+ '&operationVersion=' + encodeURIComponent(snapshot.operation_version || '') + '&return=' + returnTo;
|
||||
}
|
||||
if (step.id === 'key' || step.id === 'mcp_connection') {
|
||||
return '/api-keys?onboarding=1&action=create&agentId=' + encodeURIComponent(snapshot.agent_id || '')
|
||||
+ '&agentRevision=' + encodeURIComponent(snapshot.catalog_revision || '') + '&return=' + returnTo;
|
||||
}
|
||||
if (step.id === 'first_call' && snapshot.first_call && snapshot.first_call.log_id) {
|
||||
return '/logs?log_id=' + encodeURIComponent(snapshot.first_call.log_id);
|
||||
}
|
||||
return '/logs?agent_id=' + encodeURIComponent(snapshot.agent_id || '');
|
||||
}
|
||||
function navigate(step) { window.location.href = deepLink(step); }
|
||||
function recordStarted() {
|
||||
if (state.startedRequested || !state.snapshot || state.snapshot.completed) return;
|
||||
state.startedRequested = true;
|
||||
recordPresentation('started', { stable: true }).then(function(response) {
|
||||
if (!response) state.startedRequested = false;
|
||||
});
|
||||
}
|
||||
function setOpen(open) {
|
||||
state.open = open;
|
||||
panel.hidden = !open;
|
||||
trigger.setAttribute('aria-expanded', String(open));
|
||||
if (open) {
|
||||
lastFocused = document.activeElement;
|
||||
render();
|
||||
var focusTarget = panel.querySelector('[aria-current="step"] .onboarding-step-action') || panel.querySelector('button');
|
||||
if (focusTarget) focusTarget.focus();
|
||||
recordStarted();
|
||||
} else if (lastFocused && typeof lastFocused.focus === 'function') {
|
||||
lastFocused.focus();
|
||||
}
|
||||
}
|
||||
function render() {
|
||||
if (!trigger || !panel) return;
|
||||
var count = completedCount();
|
||||
trigger.textContent = state.dismissed
|
||||
? tKey('onboarding.resume')
|
||||
: tKey('onboarding.title') + ' · ' + count + '/7';
|
||||
trigger.setAttribute('aria-label', state.dismissed ? tKey('onboarding.resume') : trigger.textContent);
|
||||
if (heading) heading.textContent = tKey('onboarding.title');
|
||||
if (subtitle) subtitle.textContent = tKey('onboarding.subtitle');
|
||||
if (collapseButton) collapseButton.setAttribute('aria-label', tKey('onboarding.collapse'));
|
||||
var body = panel.querySelector('.onboarding-body');
|
||||
var hadActionFocus = state.open
|
||||
&& document.activeElement
|
||||
&& body.contains(document.activeElement)
|
||||
&& document.activeElement.classList.contains('onboarding-step-action');
|
||||
body.replaceChildren();
|
||||
var status = document.createElement('div');
|
||||
status.className = 'onboarding-status';
|
||||
status.setAttribute('role', 'status');
|
||||
status.setAttribute('aria-live', 'polite');
|
||||
status.id = 'crank-onboarding-progress';
|
||||
status.textContent = state.loading ? tKey('onboarding.loading') : tKey('onboarding.progress').replace('{count}', String(count));
|
||||
body.appendChild(status);
|
||||
if (state.error) {
|
||||
var error = document.createElement('div');
|
||||
error.className = 'onboarding-error';
|
||||
error.setAttribute('role', 'alert');
|
||||
var message = document.createElement('div');
|
||||
message.textContent = tKey('onboarding.error');
|
||||
error.appendChild(message);
|
||||
var errorCode = state.error.code
|
||||
|| (state.error.payload && state.error.payload.error && (state.error.payload.error.code || state.error.payload.error.error_code))
|
||||
|| (state.error.payload && (state.error.payload.code || state.error.payload.error_code));
|
||||
var support = [
|
||||
errorCode ? tKey('onboarding.error_code') + ': ' + errorCode : '',
|
||||
state.error.requestId ? tKey('onboarding.request_id') + ': ' + state.error.requestId : '',
|
||||
state.error.traceId ? tKey('onboarding.trace_id') + ': ' + state.error.traceId : '',
|
||||
].filter(Boolean);
|
||||
if (support.length) {
|
||||
var supportIds = document.createElement('div');
|
||||
supportIds.className = 'onboarding-error-ids';
|
||||
supportIds.textContent = support.join(' · ');
|
||||
error.appendChild(supportIds);
|
||||
}
|
||||
error.appendChild(button(tKey('onboarding.retry'), 'onboarding-retry', refresh, 'onboarding-step-action'));
|
||||
body.appendChild(error);
|
||||
if (state.focusError && state.open) {
|
||||
state.focusError = false;
|
||||
var retry = error.querySelector('[data-testid="onboarding-retry"]');
|
||||
if (retry) retry.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!state.snapshot) return;
|
||||
var steps = normalizedSteps();
|
||||
var active = currentStep(steps);
|
||||
if (state.snapshot.completed || !active) {
|
||||
var complete = document.createElement('div');
|
||||
complete.className = 'onboarding-completion';
|
||||
complete.dataset.testid = 'onboarding-completion';
|
||||
complete.textContent = tKey('onboarding.completed');
|
||||
var firstCall = state.snapshot.first_call;
|
||||
if (firstCall) {
|
||||
var evidence = document.createElement('dl');
|
||||
evidence.className = 'onboarding-first-call-evidence';
|
||||
[
|
||||
[tKey('onboarding.tool'), firstCall.tool_name],
|
||||
[tKey('onboarding.timestamp'), firstCall.occurred_at],
|
||||
[tKey('onboarding.request_id'), firstCall.request_id],
|
||||
[tKey('onboarding.trace_id'), firstCall.trace_id],
|
||||
].forEach(function(entry) {
|
||||
if (!entry[1]) return;
|
||||
var term = document.createElement('dt');
|
||||
term.textContent = entry[0];
|
||||
var value = document.createElement('dd');
|
||||
value.textContent = entry[1];
|
||||
evidence.appendChild(term);
|
||||
evidence.appendChild(value);
|
||||
});
|
||||
complete.appendChild(evidence);
|
||||
if (firstCall.log_id) {
|
||||
var link = document.createElement('a');
|
||||
link.href = '/logs?log_id=' + encodeURIComponent(firstCall.log_id);
|
||||
link.textContent = tKey('onboarding.open_invocation');
|
||||
complete.appendChild(link);
|
||||
}
|
||||
}
|
||||
body.appendChild(complete);
|
||||
} else {
|
||||
var list = document.createElement('ol');
|
||||
list.className = 'onboarding-list';
|
||||
list.setAttribute('aria-label', tKey('onboarding.progress_label'));
|
||||
list.setAttribute('aria-describedby', 'crank-onboarding-progress');
|
||||
steps.forEach(function(step, index) {
|
||||
var item = document.createElement('li');
|
||||
item.className = 'onboarding-step' + (step.completed ? ' is-complete' : '') + (step.status === 'regressed' ? ' is-regressed' : '');
|
||||
if (step === active) item.setAttribute('aria-current', 'step');
|
||||
var marker = document.createElement('span');
|
||||
marker.className = 'onboarding-step-marker';
|
||||
marker.textContent = step.completed ? '✓' : String(index + 1);
|
||||
marker.setAttribute('aria-hidden', 'true');
|
||||
var content = document.createElement('div');
|
||||
var title = document.createElement('div');
|
||||
title.className = 'onboarding-step-title';
|
||||
title.textContent = stepLabel(step.id);
|
||||
content.appendChild(title);
|
||||
if (step === active) {
|
||||
var reason = document.createElement('div');
|
||||
reason.className = 'onboarding-step-reason';
|
||||
reason.textContent = tKey(step.status === 'regressed' ? 'onboarding.regressed' : 'onboarding.action_needed');
|
||||
content.appendChild(reason);
|
||||
content.appendChild(button(actionLabel(step.id), null, function() { navigate(step); }, 'onboarding-step-action'));
|
||||
}
|
||||
item.appendChild(marker);
|
||||
item.appendChild(content);
|
||||
list.appendChild(item);
|
||||
});
|
||||
body.appendChild(list);
|
||||
}
|
||||
var footer = document.createElement('div');
|
||||
footer.className = 'onboarding-footer';
|
||||
footer.appendChild(button(tKey('onboarding.refresh'), 'onboarding-refresh', refresh, 'onboarding-link-button'));
|
||||
footer.appendChild(button(tKey('onboarding.dismiss'), 'onboarding-dismiss', dismiss, 'onboarding-link-button'));
|
||||
body.appendChild(footer);
|
||||
if (hadActionFocus) {
|
||||
var replacementAction = panel.querySelector('[aria-current="step"] .onboarding-step-action');
|
||||
if (replacementAction) replacementAction.focus();
|
||||
}
|
||||
}
|
||||
function refresh() {
|
||||
var workspace = currentWorkspace();
|
||||
var workspaceId = workspace ? workspace.id : null;
|
||||
if (state.refreshPromise && workspaceId === state.workspaceId) {
|
||||
state.refreshQueued = true;
|
||||
return state.refreshPromise;
|
||||
}
|
||||
if (workspaceId !== state.workspaceId) state.refreshQueued = false;
|
||||
var promise = performRefresh(workspaceId);
|
||||
state.refreshPromise = promise;
|
||||
promise.finally(function() {
|
||||
if (state.refreshPromise !== promise) return;
|
||||
state.refreshPromise = null;
|
||||
if (state.refreshQueued) {
|
||||
state.refreshQueued = false;
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
return promise;
|
||||
}
|
||||
async function performRefresh(workspaceId) {
|
||||
var workspaceChanged = workspaceId !== state.workspaceId;
|
||||
var epoch = ++state.epoch;
|
||||
if (state.controller) state.controller.abort();
|
||||
state.controller = typeof AbortController === 'function' ? new AbortController() : null;
|
||||
state.workspaceId = workspaceId;
|
||||
if (workspaceChanged) {
|
||||
state.dismissed = Boolean(safePreference().dismissed);
|
||||
state.startedRequested = false;
|
||||
if (state.open) setOpen(false);
|
||||
}
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
state.focusError = false;
|
||||
render();
|
||||
if (!workspaceId || !window.CrankApi || typeof window.CrankApi.getOnboarding !== 'function') {
|
||||
state.loading = false;
|
||||
state.error = new Error(tKey('onboarding.error'));
|
||||
render();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var snapshot = await window.CrankApi.getOnboarding(workspaceId);
|
||||
if (epoch !== state.epoch || workspaceId !== (currentWorkspace() && currentWorkspace().id)) return;
|
||||
state.snapshot = snapshot;
|
||||
var preference = safePreference();
|
||||
if (window.location.pathname === '/'
|
||||
&& !preference.opened
|
||||
&& !preference.dismissed
|
||||
&& !snapshot.completed) {
|
||||
updatePreference({ opened: true });
|
||||
setOpen(true);
|
||||
}
|
||||
renderDeepLinkRecovery(snapshot);
|
||||
} catch (error) {
|
||||
if (epoch !== state.epoch) return;
|
||||
state.error = error;
|
||||
state.focusError = state.open;
|
||||
} finally {
|
||||
if (epoch === state.epoch) {
|
||||
state.loading = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
}
|
||||
async function recordPresentation(eventName, options) {
|
||||
if (!state.snapshot || !state.workspaceId || !window.CrankApi.recordOnboardingEvent) return;
|
||||
var expectedRevision = Number(state.snapshot.revision || 0);
|
||||
var expectedEpoch = state.epoch;
|
||||
var expectedWorkspaceId = state.workspaceId;
|
||||
var key = options && options.stable
|
||||
? ['ui', eventName, state.workspaceId, 'v1'].join(':')
|
||||
: ['ui', eventName, state.workspaceId, expectedRevision].join(':');
|
||||
try {
|
||||
var response = await window.CrankApi.recordOnboardingEvent(state.workspaceId, {
|
||||
event: eventName,
|
||||
idempotency_key: key,
|
||||
expected_revision: expectedRevision,
|
||||
}, options && options.keepalive ? { keepalive: true } : null);
|
||||
var currentRevision = Number(state.snapshot && state.snapshot.revision || 0);
|
||||
if (response
|
||||
&& expectedEpoch === state.epoch
|
||||
&& expectedWorkspaceId === state.workspaceId
|
||||
&& expectedWorkspaceId === (currentWorkspace() && currentWorkspace().id)
|
||||
&& currentRevision === expectedRevision) {
|
||||
state.snapshot = response;
|
||||
render();
|
||||
}
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error.status === 409) await refresh();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function dismiss() {
|
||||
updatePreference({ dismissed: true, opened: true });
|
||||
state.dismissed = true;
|
||||
setOpen(false);
|
||||
render();
|
||||
await recordPresentation('dismissed');
|
||||
await recordPresentation('abandoned', { stable: true });
|
||||
}
|
||||
function resume() {
|
||||
updatePreference({ dismissed: false, opened: true });
|
||||
state.dismissed = false;
|
||||
setOpen(true);
|
||||
recordPresentation('resumed');
|
||||
}
|
||||
function renderDeepLinkRecovery(snapshot) {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if (params.get('onboarding') !== '1') return;
|
||||
var stale = false;
|
||||
if (window.location.pathname === '/agents') {
|
||||
var expectedOperation = params.get('operationId') || '';
|
||||
var expectedVersion = Number(params.get('operationVersion') || 0);
|
||||
stale = Boolean(expectedOperation) && (
|
||||
snapshot.operation_id !== expectedOperation
|
||||
|| (expectedVersion && snapshot.operation_version !== expectedVersion)
|
||||
);
|
||||
} else if (window.location.pathname === '/api-keys') {
|
||||
var expectedAgent = params.get('agentId') || '';
|
||||
var expectedRevision = Number(params.get('agentRevision') || 0);
|
||||
stale = Boolean(expectedAgent) && (
|
||||
snapshot.agent_id !== expectedAgent
|
||||
|| (expectedRevision && snapshot.catalog_revision !== expectedRevision)
|
||||
);
|
||||
}
|
||||
var existing = document.getElementById('onboarding-deep-link-stale');
|
||||
if (!stale) {
|
||||
if (existing) existing.remove();
|
||||
return;
|
||||
}
|
||||
if (existing) return;
|
||||
var recovery = document.createElement('div');
|
||||
recovery.id = 'onboarding-deep-link-stale';
|
||||
recovery.className = 'onboarding-deep-link-stale';
|
||||
recovery.setAttribute('role', 'alert');
|
||||
recovery.dataset.testid = 'onboarding-deep-link-stale';
|
||||
var message = document.createElement('div');
|
||||
message.textContent = tKey('onboarding.deep_link_stale');
|
||||
recovery.appendChild(message);
|
||||
recovery.appendChild(button(tKey('onboarding.reselect'), 'onboarding-reselect', async function() {
|
||||
var resetButton = this;
|
||||
resetButton.disabled = true;
|
||||
try {
|
||||
if (window.CrankApi && typeof window.CrankApi.resetOnboardingSelection === 'function') {
|
||||
await window.CrankApi.resetOnboardingSelection(state.workspaceId, Number(snapshot.revision || 0));
|
||||
}
|
||||
var clean = new URLSearchParams(window.location.search);
|
||||
['onboarding', 'action', 'operationId', 'operationVersion', 'agentId', 'agentRevision', 'return'].forEach(function(key) {
|
||||
clean.delete(key);
|
||||
});
|
||||
window.location.replace(window.location.pathname + (clean.toString() ? '?' + clean.toString() : ''));
|
||||
} catch (error) {
|
||||
resetButton.disabled = false;
|
||||
state.error = error;
|
||||
if (!state.open) setOpen(true);
|
||||
render();
|
||||
}
|
||||
}, 'onboarding-step-action'));
|
||||
document.body.appendChild(recovery);
|
||||
}
|
||||
function mount() {
|
||||
if (document.getElementById('crank-onboarding-root')) return;
|
||||
injectStylesheet();
|
||||
var root = document.createElement('div');
|
||||
root.id = 'crank-onboarding-root';
|
||||
trigger = button(tKey('onboarding.title'), 'onboarding-trigger', function() {
|
||||
if (state.dismissed) {
|
||||
resume();
|
||||
return;
|
||||
}
|
||||
setOpen(!state.open);
|
||||
}, 'onboarding-trigger');
|
||||
trigger.setAttribute('aria-controls', 'crank-onboarding-panel');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
panel = document.createElement('aside');
|
||||
panel.id = 'crank-onboarding-panel';
|
||||
panel.className = 'onboarding-panel';
|
||||
panel.dataset.testid = 'onboarding-checklist';
|
||||
panel.setAttribute('aria-labelledby', 'crank-onboarding-title');
|
||||
panel.hidden = true;
|
||||
var head = document.createElement('div');
|
||||
head.className = 'onboarding-head';
|
||||
var headingWrap = document.createElement('div');
|
||||
heading = document.createElement('h2');
|
||||
heading.id = 'crank-onboarding-title';
|
||||
heading.className = 'onboarding-title';
|
||||
heading.textContent = tKey('onboarding.title');
|
||||
subtitle = document.createElement('p');
|
||||
subtitle.className = 'onboarding-subtitle';
|
||||
subtitle.textContent = tKey('onboarding.subtitle');
|
||||
headingWrap.appendChild(heading);
|
||||
headingWrap.appendChild(subtitle);
|
||||
var actions = document.createElement('div');
|
||||
actions.className = 'onboarding-head-actions';
|
||||
collapseButton = button('−', 'onboarding-collapse', function() { setOpen(false); }, 'onboarding-icon-button');
|
||||
collapseButton.setAttribute('aria-label', tKey('onboarding.collapse'));
|
||||
actions.appendChild(collapseButton);
|
||||
head.appendChild(headingWrap);
|
||||
head.appendChild(actions);
|
||||
var body = document.createElement('div');
|
||||
body.className = 'onboarding-body';
|
||||
panel.appendChild(head);
|
||||
panel.appendChild(body);
|
||||
root.appendChild(trigger);
|
||||
root.appendChild(panel);
|
||||
document.body.appendChild(root);
|
||||
refresh();
|
||||
}
|
||||
function signalRefresh() {
|
||||
if (channel) channel.postMessage({ type: 'refresh' });
|
||||
try { localStorage.setItem('crank_onboarding_signal', String(Date.now())); } catch (_error) {}
|
||||
}
|
||||
window.CrankOnboarding = { refresh: refresh, signalRefresh: signalRefresh };
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||
mount();
|
||||
if (typeof BroadcastChannel === 'function') {
|
||||
channel = new BroadcastChannel('crank-onboarding');
|
||||
channel.addEventListener('message', function(event) {
|
||||
if (event && event.data && event.data.type !== 'refresh') return;
|
||||
refresh();
|
||||
});
|
||||
}
|
||||
});
|
||||
window.addEventListener('crank:workspacechange', function() {
|
||||
state.snapshot = null;
|
||||
refresh();
|
||||
});
|
||||
window.addEventListener('crank:sessionchange', refresh);
|
||||
window.addEventListener('crank:langchange', function() { render(); });
|
||||
window.addEventListener('storage', function(event) { if (event.key === 'crank_onboarding_signal') refresh(); });
|
||||
document.addEventListener('visibilitychange', function() { if (!document.hidden) refresh(); });
|
||||
document.addEventListener('keydown', function(event) { if (event.key === 'Escape' && state.open) setOpen(false); });
|
||||
}());
|
||||
Reference in New Issue
Block a user