feat: Añadir funcionalidad de gestión de apps en la tienda de apps
This commit is contained in:
@@ -1725,6 +1725,531 @@ var PAGES = {};
|
||||
var PERMS = {
|
||||
ADMIN: 'Administrador',
|
||||
};
|
||||
|
||||
var TS_INSTALLED_APPS_CACHE = null;
|
||||
var TS_INSTALLED_APPS_CACHE_KEY = '';
|
||||
var TS_INSTALLED_APPS_LOADING = false;
|
||||
var TS_ESSENTIAL_APPS = new Set(['index', 'personas', 'dataman']);
|
||||
var TS_LOCKED_APPS = new Set(['index', 'personas', 'dataman']);
|
||||
|
||||
function TS_getAppInstallStorageKey() {
|
||||
var dbName = 'telesec';
|
||||
try {
|
||||
dbName = typeof getDBName === 'function' ? getDBName() : 'telesec';
|
||||
} catch (e) {
|
||||
dbName = 'telesec';
|
||||
}
|
||||
var personaId = SUB_LOGGED_IN_ID || 'anon';
|
||||
return 'TELESEC_INSTALLED_APPS::' + dbName + '::' + personaId;
|
||||
}
|
||||
|
||||
function TS_getAppInstallDocId() {
|
||||
var dbName = 'telesec';
|
||||
try {
|
||||
dbName = typeof getDBName === 'function' ? getDBName() : 'telesec';
|
||||
} catch (e) {
|
||||
dbName = 'telesec';
|
||||
}
|
||||
var personaId = SUB_LOGGED_IN_ID || 'anon';
|
||||
return 'installed_apps::' + dbName + '::' + personaId;
|
||||
}
|
||||
|
||||
function TS_buildDefaultInstalledSet() {
|
||||
var installedSet = new Set();
|
||||
TS_ESSENTIAL_APPS.forEach((key) => {
|
||||
if (!PAGES[key]) return;
|
||||
if (TS_isSystemApp(key)) return;
|
||||
if (PAGES[key].ExternalApp === true) return;
|
||||
if (PAGES[key].Esconder === true) return;
|
||||
installedSet.add(key);
|
||||
});
|
||||
TS_LOCKED_APPS.forEach((key) => {
|
||||
if (PAGES[key] && PAGES[key].Esconder !== true) {
|
||||
installedSet.add(key);
|
||||
}
|
||||
});
|
||||
return installedSet;
|
||||
}
|
||||
|
||||
function TS_parseInstalledAppsPayload(payload) {
|
||||
if (!payload || !Array.isArray(payload.apps)) return null;
|
||||
var set = new Set();
|
||||
payload.apps.forEach((appKey) => {
|
||||
if (typeof appKey === 'string' && appKey.trim() !== '') {
|
||||
set.add(appKey);
|
||||
}
|
||||
});
|
||||
return set;
|
||||
}
|
||||
|
||||
function TS_readInstalledAppsFallback() {
|
||||
try {
|
||||
var raw = localStorage.getItem(TS_getAppInstallStorageKey());
|
||||
if (!raw) return null;
|
||||
var parsed = JSON.parse(raw);
|
||||
return TS_parseInstalledAppsPayload(parsed);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function TS_writeInstalledAppsFallback(appSet) {
|
||||
var apps = [];
|
||||
if (appSet && typeof appSet.forEach === 'function') {
|
||||
appSet.forEach((appKey) => {
|
||||
if (typeof appKey === 'string' && appKey.trim() !== '') {
|
||||
apps.push(appKey);
|
||||
}
|
||||
});
|
||||
}
|
||||
localStorage.setItem(
|
||||
TS_getAppInstallStorageKey(),
|
||||
JSON.stringify({ version: 1, apps: apps.sort(), updatedAt: CurrentISOTime() })
|
||||
);
|
||||
}
|
||||
|
||||
function TS_setInstalledAppsCache(appSet) {
|
||||
TS_LOCKED_APPS.forEach((key) => {
|
||||
if (PAGES[key] && PAGES[key].Esconder !== true) {
|
||||
appSet.add(key);
|
||||
}
|
||||
});
|
||||
TS_INSTALLED_APPS_CACHE = appSet;
|
||||
TS_INSTALLED_APPS_CACHE_KEY = TS_getAppInstallDocId();
|
||||
TS_writeInstalledAppsFallback(appSet);
|
||||
}
|
||||
|
||||
function TS_persistInstalledAppsToDB(appSet) {
|
||||
if (!window.DB || typeof DB.put !== 'function') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
var apps = [];
|
||||
appSet.forEach((appKey) => {
|
||||
if (typeof appKey === 'string' && appKey.trim() !== '') {
|
||||
apps.push(appKey);
|
||||
}
|
||||
});
|
||||
return DB.put('config', TS_getAppInstallDocId(), {
|
||||
version: 1,
|
||||
apps: apps.sort(),
|
||||
updatedAt: CurrentISOTime(),
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((e) => {
|
||||
console.warn('Error saving installed apps in DB', e);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function TS_loadInstalledAppsFromDB(forceReload = false) {
|
||||
var expectedKey = TS_getAppInstallDocId();
|
||||
if (!forceReload && TS_INSTALLED_APPS_CACHE && TS_INSTALLED_APPS_CACHE_KEY === expectedKey) {
|
||||
return Promise.resolve(TS_INSTALLED_APPS_CACHE);
|
||||
}
|
||||
if (TS_INSTALLED_APPS_LOADING) {
|
||||
return Promise.resolve(TS_INSTALLED_APPS_CACHE);
|
||||
}
|
||||
TS_INSTALLED_APPS_LOADING = true;
|
||||
|
||||
var fallbackSet = TS_readInstalledAppsFallback() || TS_buildDefaultInstalledSet();
|
||||
TS_setInstalledAppsCache(fallbackSet);
|
||||
|
||||
if (!window.DB || typeof DB.get !== 'function') {
|
||||
TS_INSTALLED_APPS_LOADING = false;
|
||||
return Promise.resolve(TS_INSTALLED_APPS_CACHE);
|
||||
}
|
||||
|
||||
return DB.get('config', expectedKey)
|
||||
.then((raw) => {
|
||||
if (raw == null) {
|
||||
TS_INSTALLED_APPS_LOADING = false;
|
||||
return TS_persistInstalledAppsToDB(TS_INSTALLED_APPS_CACHE).then(() => TS_INSTALLED_APPS_CACHE);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (typeof raw === 'string') {
|
||||
TS_decrypt(
|
||||
raw,
|
||||
SECRET,
|
||||
(decrypted) => {
|
||||
var parsed = TS_parseInstalledAppsPayload(decrypted);
|
||||
if (parsed) {
|
||||
TS_setInstalledAppsCache(parsed);
|
||||
}
|
||||
TS_INSTALLED_APPS_LOADING = false;
|
||||
resolve(TS_INSTALLED_APPS_CACHE);
|
||||
},
|
||||
'config',
|
||||
expectedKey
|
||||
);
|
||||
} else {
|
||||
var parsed = TS_parseInstalledAppsPayload(raw);
|
||||
if (parsed) {
|
||||
TS_setInstalledAppsCache(parsed);
|
||||
}
|
||||
TS_INSTALLED_APPS_LOADING = false;
|
||||
resolve(TS_INSTALLED_APPS_CACHE);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('Error loading installed apps from DB', e);
|
||||
TS_INSTALLED_APPS_LOADING = false;
|
||||
return TS_INSTALLED_APPS_CACHE;
|
||||
});
|
||||
}
|
||||
|
||||
function TS_getInstalledAppsSet() {
|
||||
var expectedKey = TS_getAppInstallDocId();
|
||||
if (!TS_INSTALLED_APPS_CACHE || TS_INSTALLED_APPS_CACHE_KEY !== expectedKey) {
|
||||
TS_loadInstalledAppsFromDB().then(() => {
|
||||
SetPages();
|
||||
});
|
||||
return null;
|
||||
}
|
||||
return TS_INSTALLED_APPS_CACHE;
|
||||
}
|
||||
|
||||
function TS_setInstalledAppsSet(appSet) {
|
||||
TS_setInstalledAppsCache(appSet);
|
||||
TS_persistInstalledAppsToDB(appSet);
|
||||
}
|
||||
|
||||
function TS_isSystemApp(appKey) {
|
||||
if (appKey === 'login') return true;
|
||||
if (!PAGES[appKey]) return false;
|
||||
return PAGES[appKey].SystemApp === true;
|
||||
}
|
||||
|
||||
function TS_isLockedApp(appKey) {
|
||||
return TS_LOCKED_APPS.has(appKey);
|
||||
}
|
||||
|
||||
function TS_isMandatoryApp(appKey) {
|
||||
return TS_isSystemApp(appKey) || TS_isLockedApp(appKey);
|
||||
}
|
||||
|
||||
function TS_isAppInstalled(appKey) {
|
||||
if (TS_isMandatoryApp(appKey)) return true;
|
||||
var installedSet = TS_getInstalledAppsSet();
|
||||
if (installedSet == null) {
|
||||
return true;
|
||||
}
|
||||
return installedSet.has(appKey);
|
||||
}
|
||||
|
||||
function TS_installApp(appKey) {
|
||||
if (!PAGES[appKey] || TS_isSystemApp(appKey)) return;
|
||||
var installedSet = TS_getInstalledAppsSet();
|
||||
if (installedSet == null) {
|
||||
installedSet = TS_buildDefaultInstalledSet();
|
||||
}
|
||||
installedSet.add(appKey);
|
||||
if (appKey === 'supercafe' && PAGES.pagos) {
|
||||
installedSet.add('pagos');
|
||||
}
|
||||
TS_setInstalledAppsSet(installedSet);
|
||||
}
|
||||
|
||||
function TS_uninstallApp(appKey) {
|
||||
if (!PAGES[appKey] || TS_isMandatoryApp(appKey)) return;
|
||||
var installedSet = TS_getInstalledAppsSet();
|
||||
if (installedSet == null) {
|
||||
installedSet = TS_buildDefaultInstalledSet();
|
||||
}
|
||||
installedSet.delete(appKey);
|
||||
TS_setInstalledAppsSet(installedSet);
|
||||
}
|
||||
|
||||
function TS_resetInstalledApps() {
|
||||
var defaults = TS_buildDefaultInstalledSet();
|
||||
TS_setInstalledAppsSet(defaults);
|
||||
}
|
||||
|
||||
function TS_getAppCatalog() {
|
||||
return Object.keys(PAGES)
|
||||
.filter((key) => PAGES[key].Esconder !== true)
|
||||
.map((key) => {
|
||||
return {
|
||||
key: key,
|
||||
title: PAGES[key].Title || key,
|
||||
icon: PAGES[key].icon || 'static/appico/application_enterprise.png',
|
||||
installed: TS_isAppInstalled(key),
|
||||
system: TS_isSystemApp(key),
|
||||
canAccess: !PAGES[key].AccessControl || checkRole(key),
|
||||
requiresRole: PAGES[key].AccessControl === true,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.title.localeCompare(b.title, 'es'));
|
||||
}
|
||||
|
||||
var TS_EXTERNAL_APPS_CACHE = [];
|
||||
var TS_EXTERNAL_APPS_CACHE_KEY = '';
|
||||
var TS_EXTERNAL_APPS_LOADING = false;
|
||||
var TS_EXTERNAL_APPS_READY = false;
|
||||
|
||||
function TS_getExternalAppsStorageKey() {
|
||||
var dbName = 'telesec';
|
||||
try {
|
||||
dbName = typeof getDBName === 'function' ? getDBName() : 'telesec';
|
||||
} catch (e) {
|
||||
dbName = 'telesec';
|
||||
}
|
||||
var personaId = SUB_LOGGED_IN_ID || 'anon';
|
||||
return 'TELESEC_EXTERNAL_APPS::' + dbName + '::' + personaId;
|
||||
}
|
||||
|
||||
function TS_getExternalAppsDocId() {
|
||||
var dbName = 'telesec';
|
||||
try {
|
||||
dbName = typeof getDBName === 'function' ? getDBName() : 'telesec';
|
||||
} catch (e) {
|
||||
dbName = 'telesec';
|
||||
}
|
||||
var personaId = SUB_LOGGED_IN_ID || 'anon';
|
||||
return 'external_apps::' + dbName + '::' + personaId;
|
||||
}
|
||||
|
||||
function TS_parseExternalAppsPayload(payload) {
|
||||
if (!payload || !Array.isArray(payload.apps)) return [];
|
||||
return payload.apps.filter((entry) => {
|
||||
return (
|
||||
entry &&
|
||||
typeof entry.appKey === 'string' &&
|
||||
entry.appKey.trim() !== '' &&
|
||||
typeof entry.code === 'string' &&
|
||||
entry.code.trim() !== ''
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function TS_readExternalAppsFallback() {
|
||||
try {
|
||||
var raw = localStorage.getItem(TS_getExternalAppsStorageKey());
|
||||
if (!raw) return [];
|
||||
return TS_parseExternalAppsPayload(JSON.parse(raw));
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function TS_writeExternalAppsFallback(appsList) {
|
||||
localStorage.setItem(
|
||||
TS_getExternalAppsStorageKey(),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
apps: appsList,
|
||||
updatedAt: CurrentISOTime(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function TS_setExternalAppsCache(appsList) {
|
||||
TS_EXTERNAL_APPS_CACHE = Array.isArray(appsList) ? appsList : [];
|
||||
TS_EXTERNAL_APPS_CACHE_KEY = TS_getExternalAppsDocId();
|
||||
TS_EXTERNAL_APPS_READY = true;
|
||||
TS_writeExternalAppsFallback(TS_EXTERNAL_APPS_CACHE);
|
||||
}
|
||||
|
||||
function TS_saveExternalAppsToDB(appsList) {
|
||||
if (!window.DB || typeof DB.put !== 'function') {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return DB.put('config', TS_getExternalAppsDocId(), {
|
||||
version: 1,
|
||||
apps: appsList,
|
||||
updatedAt: CurrentISOTime(),
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((e) => {
|
||||
console.warn('Error saving external apps in DB', e);
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function TS_evalExternalAppCode(code, expectedAppKey = '') {
|
||||
if (typeof code !== 'string' || code.trim() === '') {
|
||||
throw new Error('Código vacío en app externa.');
|
||||
}
|
||||
|
||||
var beforeKeys = Object.keys(PAGES);
|
||||
var beforeSet = new Set(beforeKeys);
|
||||
try {
|
||||
new Function(code)();
|
||||
} catch (e) {
|
||||
throw new Error('Error ejecutando app externa: ' + (e && e.message ? e.message : e));
|
||||
}
|
||||
|
||||
var afterKeys = Object.keys(PAGES);
|
||||
var newKeys = afterKeys.filter((key) => !beforeSet.has(key));
|
||||
var appKey = expectedAppKey || '';
|
||||
|
||||
if (!appKey) {
|
||||
if (newKeys.length === 0) {
|
||||
throw new Error('La app externa no registró ninguna entrada en PAGES.');
|
||||
}
|
||||
appKey = newKeys[0];
|
||||
}
|
||||
|
||||
if (!PAGES[appKey]) {
|
||||
throw new Error('No se pudo detectar la app externa cargada.');
|
||||
}
|
||||
|
||||
if (beforeSet.has(appKey) && PAGES[appKey].ExternalApp !== true) {
|
||||
throw new Error('La app externa intenta sobrescribir una app interna: ' + appKey);
|
||||
}
|
||||
|
||||
PAGES[appKey].ExternalApp = true;
|
||||
PAGES[appKey].SystemApp = false;
|
||||
|
||||
return {
|
||||
appKey: appKey,
|
||||
title: PAGES[appKey].Title || appKey,
|
||||
icon: PAGES[appKey].icon || 'static/appico/application_enterprise.png',
|
||||
};
|
||||
}
|
||||
|
||||
function TS_applyExternalAppsRegistry(appsList) {
|
||||
appsList.forEach((entry) => {
|
||||
try {
|
||||
if (entry.appKey && PAGES[entry.appKey] && PAGES[entry.appKey].ExternalApp === true) {
|
||||
return;
|
||||
}
|
||||
TS_evalExternalAppCode(entry.code, entry.appKey || '');
|
||||
} catch (e) {
|
||||
console.warn('Error loading external app', entry && entry.appKey, e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function TS_loadExternalAppsFromDB(forceReload = false) {
|
||||
var expectedKey = TS_getExternalAppsDocId();
|
||||
if (!forceReload && TS_EXTERNAL_APPS_READY && TS_EXTERNAL_APPS_CACHE_KEY === expectedKey) {
|
||||
return Promise.resolve(TS_EXTERNAL_APPS_CACHE);
|
||||
}
|
||||
if (TS_EXTERNAL_APPS_LOADING) {
|
||||
return Promise.resolve(TS_EXTERNAL_APPS_CACHE);
|
||||
}
|
||||
TS_EXTERNAL_APPS_LOADING = true;
|
||||
|
||||
var fallback = TS_readExternalAppsFallback();
|
||||
TS_setExternalAppsCache(fallback);
|
||||
TS_applyExternalAppsRegistry(TS_EXTERNAL_APPS_CACHE);
|
||||
|
||||
if (!window.DB || typeof DB.get !== 'function') {
|
||||
TS_EXTERNAL_APPS_LOADING = false;
|
||||
return Promise.resolve(TS_EXTERNAL_APPS_CACHE);
|
||||
}
|
||||
|
||||
return DB.get('config', expectedKey)
|
||||
.then((raw) => {
|
||||
if (raw == null) {
|
||||
TS_EXTERNAL_APPS_LOADING = false;
|
||||
return TS_saveExternalAppsToDB(TS_EXTERNAL_APPS_CACHE).then(() => TS_EXTERNAL_APPS_CACHE);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
if (typeof raw === 'string') {
|
||||
TS_decrypt(
|
||||
raw,
|
||||
SECRET,
|
||||
(decrypted) => {
|
||||
var parsed = TS_parseExternalAppsPayload(decrypted);
|
||||
TS_setExternalAppsCache(parsed);
|
||||
TS_applyExternalAppsRegistry(TS_EXTERNAL_APPS_CACHE);
|
||||
TS_EXTERNAL_APPS_LOADING = false;
|
||||
resolve(TS_EXTERNAL_APPS_CACHE);
|
||||
},
|
||||
'config',
|
||||
expectedKey
|
||||
);
|
||||
} else {
|
||||
var parsed = TS_parseExternalAppsPayload(raw);
|
||||
TS_setExternalAppsCache(parsed);
|
||||
TS_applyExternalAppsRegistry(TS_EXTERNAL_APPS_CACHE);
|
||||
TS_EXTERNAL_APPS_LOADING = false;
|
||||
resolve(TS_EXTERNAL_APPS_CACHE);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('Error loading external apps from DB', e);
|
||||
TS_EXTERNAL_APPS_LOADING = false;
|
||||
return TS_EXTERNAL_APPS_CACHE;
|
||||
});
|
||||
}
|
||||
|
||||
function TS_getExternalAppsCatalog() {
|
||||
var expectedKey = TS_getExternalAppsDocId();
|
||||
if (!TS_EXTERNAL_APPS_READY || TS_EXTERNAL_APPS_CACHE_KEY !== expectedKey) {
|
||||
TS_loadExternalAppsFromDB().then(() => {
|
||||
SetPages();
|
||||
});
|
||||
return [];
|
||||
}
|
||||
return TS_EXTERNAL_APPS_CACHE.map((entry) => {
|
||||
return {
|
||||
appKey: entry.appKey,
|
||||
title: entry.title || entry.appKey,
|
||||
sourceType: entry.sourceType || 'file',
|
||||
source: entry.source || '',
|
||||
installedAt: entry.installedAt || '',
|
||||
installed: TS_isAppInstalled(entry.appKey),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function TS_installExternalAppFromCode(code, sourceType = 'file', sourceRef = '') {
|
||||
return TS_loadExternalAppsFromDB().then(() => {
|
||||
var info = TS_evalExternalAppCode(code, '');
|
||||
var next = TS_EXTERNAL_APPS_CACHE.filter((entry) => entry.appKey !== info.appKey);
|
||||
var item = {
|
||||
id: safeuuid('extapp-'),
|
||||
appKey: info.appKey,
|
||||
title: info.title,
|
||||
icon: info.icon,
|
||||
sourceType: sourceType,
|
||||
source: sourceRef,
|
||||
code: code,
|
||||
installedAt: CurrentISOTime(),
|
||||
updatedAt: CurrentISOTime(),
|
||||
};
|
||||
next.push(item);
|
||||
TS_setExternalAppsCache(next);
|
||||
TS_saveExternalAppsToDB(next);
|
||||
TS_installApp(info.appKey);
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
function TS_uninstallExternalApp(appKey) {
|
||||
return TS_loadExternalAppsFromDB().then(() => {
|
||||
var next = TS_EXTERNAL_APPS_CACHE.filter((entry) => entry.appKey !== appKey);
|
||||
TS_setExternalAppsCache(next);
|
||||
TS_saveExternalAppsToDB(next);
|
||||
TS_uninstallApp(appKey);
|
||||
if (PAGES[appKey] && PAGES[appKey].ExternalApp === true) {
|
||||
delete PAGES[appKey];
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function TS_resetAppsToDefault() {
|
||||
return TS_loadExternalAppsFromDB().then(() => {
|
||||
TS_EXTERNAL_APPS_CACHE.forEach((entry) => {
|
||||
var appKey = entry.appKey;
|
||||
if (PAGES[appKey] && PAGES[appKey].ExternalApp === true) {
|
||||
delete PAGES[appKey];
|
||||
}
|
||||
});
|
||||
TS_setExternalAppsCache([]);
|
||||
TS_saveExternalAppsToDB([]);
|
||||
TS_resetInstalledApps();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function checkRole(role) {
|
||||
var roles = SUB_LOGGED_IN_DETAILS.Roles || '';
|
||||
var rolesArr = roles.split(',');
|
||||
@@ -1735,15 +2260,24 @@ function checkRole(role) {
|
||||
}
|
||||
}
|
||||
function SetPages() {
|
||||
var expectedExternalKey = TS_getExternalAppsDocId();
|
||||
if (!TS_EXTERNAL_APPS_READY || TS_EXTERNAL_APPS_CACHE_KEY !== expectedExternalKey) {
|
||||
TS_loadExternalAppsFromDB().then(() => {
|
||||
SetPages();
|
||||
});
|
||||
}
|
||||
document.getElementById('appendApps2').innerHTML = '';
|
||||
Object.keys(PAGES).forEach((key) => {
|
||||
if (PAGES[key].Esconder == true) {
|
||||
return;
|
||||
}
|
||||
if (!TS_isAppInstalled(key)) {
|
||||
return;
|
||||
}
|
||||
if (PAGES[key].AccessControl == true) {
|
||||
var roles = SUB_LOGGED_IN_DETAILS.Roles || '';
|
||||
var rolesArr = roles.split(',');
|
||||
if (rolesArr.includes('ADMIN') || rolesArr.includes(key) || AC_BYPASS) {
|
||||
if (rolesArr.includes('ADMIN') || rolesArr.includes(PAGES[key].AccessControlRole || key) || AC_BYPASS) {
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user