#6930 - Use ScopedToken when share Multimedia files #2094
|
@ -29,7 +29,8 @@ module.exports = Self => {
|
||||||
http: {
|
http: {
|
||||||
path: `/:id/downloadFile`,
|
path: `/:id/downloadFile`,
|
||||||
verb: 'GET'
|
verb: 'GET'
|
||||||
}
|
},
|
||||||
|
accessScopes: ['read:multimedia']
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.downloadFile = async function(ctx, id) {
|
Self.downloadFile = async function(ctx, id) {
|
||||||
|
|
|
@ -42,7 +42,8 @@ module.exports = Self => {
|
||||||
http: {
|
http: {
|
||||||
path: `/:id/download`,
|
path: `/:id/download`,
|
||||||
verb: 'GET'
|
verb: 'GET'
|
||||||
}
|
},
|
||||||
|
accessScopes: ['read:multimedia']
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.download = async function(id, fileCabinet, filter) {
|
Self.download = async function(id, fileCabinet, filter) {
|
||||||
|
|
|
@ -47,7 +47,8 @@ module.exports = Self => {
|
||||||
http: {
|
http: {
|
||||||
path: `/:collection/:size/:id/download`,
|
path: `/:collection/:size/:id/download`,
|
||||||
verb: 'GET'
|
verb: 'GET'
|
||||||
}
|
},
|
||||||
|
accessScopes: ['read:multimedia']
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.download = async function(ctx, collection, size, id) {
|
Self.download = async function(ctx, collection, size, id) {
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
|
||||||
|
module.exports = Self => {
|
||||||
|
Self.remoteMethodCtx('shareToken', {
|
||||||
|
description: 'Returns token to view files or images and share it',
|
||||||
|
accessType: 'WRITE',
|
||||||
|
accepts: [],
|
||||||
|
returns: {
|
||||||
|
type: 'Object',
|
||||||
|
root: true
|
||||||
|
},
|
||||||
|
http: {
|
||||||
|
path: `/shareToken`,
|
||||||
|
verb: 'GET'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self.shareToken = async function(ctx) {
|
||||||
|
const {accessToken: token} = ctx.req;
|
||||||
|
|
||||||
|
const user = await Self.findById(token.userId);
|
||||||
|
const multimediaToken = await user.accessTokens.create({
|
||||||
|
scopes: ['read:multimedia']
|
||||||
|
});
|
||||||
|
|
||||||
|
return {multimediaToken};
|
||||||
|
};
|
||||||
|
};
|
|
@ -0,0 +1,27 @@
|
||||||
|
const {models} = require('vn-loopback/server/server');
|
||||||
|
describe('Share Token', () => {
|
||||||
|
let ctx = null;
|
||||||
|
beforeAll(async() => {
|
||||||
|
const unAuthCtx = {
|
||||||
|
req: {
|
||||||
|
headers: {},
|
||||||
|
connection: {
|
||||||
|
remoteAddress: '127.0.0.1'
|
||||||
|
},
|
||||||
|
getLocale: () => 'en'
|
||||||
|
},
|
||||||
|
args: {}
|
||||||
|
};
|
||||||
|
let login = await models.VnUser.signIn(unAuthCtx, 'salesAssistant', 'nightmare');
|
||||||
|
let accessToken = await models.AccessToken.findById(login.token);
|
||||||
|
ctx = {req: {accessToken: accessToken}};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should renew token', async() => {
|
||||||
|
const multimediaToken = await models.VnUser.shareToken(ctx);
|
||||||
|
|
||||||
|
expect(Object.keys(multimediaToken).length).toEqual(1);
|
||||||
|
expect(multimediaToken.multimediaToken.userId).toEqual(ctx.req.accessToken.userId);
|
||||||
|
expect(multimediaToken.multimediaToken.scopes[0]).toEqual('read:multimedia');
|
||||||
|
});
|
||||||
|
});
|
|
@ -13,6 +13,7 @@ module.exports = function(Self) {
|
||||||
require('../methods/vn-user/privileges')(Self);
|
require('../methods/vn-user/privileges')(Self);
|
||||||
require('../methods/vn-user/validate-auth')(Self);
|
require('../methods/vn-user/validate-auth')(Self);
|
||||||
require('../methods/vn-user/renew-token')(Self);
|
require('../methods/vn-user/renew-token')(Self);
|
||||||
|
require('../methods/vn-user/share-token')(Self);
|
||||||
require('../methods/vn-user/update-user')(Self);
|
require('../methods/vn-user/update-user')(Self);
|
||||||
|
|
||||||
Self.definition.settings.acls = Self.definition.settings.acls.filter(acl => acl.property !== 'create');
|
Self.definition.settings.acls = Self.definition.settings.acls.filter(acl => acl.property !== 'create');
|
||||||
|
|
|
@ -98,30 +98,41 @@
|
||||||
"principalType": "ROLE",
|
"principalType": "ROLE",
|
||||||
"principalId": "$everyone",
|
"principalId": "$everyone",
|
||||||
"permission": "ALLOW"
|
"permission": "ALLOW"
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
"property": "recoverPassword",
|
"property": "recoverPassword",
|
||||||
"accessType": "EXECUTE",
|
"accessType": "EXECUTE",
|
||||||
"principalType": "ROLE",
|
"principalType": "ROLE",
|
||||||
"principalId": "$everyone",
|
"principalId": "$everyone",
|
||||||
"permission": "ALLOW"
|
"permission": "ALLOW"
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
"property": "validateAuth",
|
"property": "validateAuth",
|
||||||
"accessType": "EXECUTE",
|
"accessType": "EXECUTE",
|
||||||
"principalType": "ROLE",
|
"principalType": "ROLE",
|
||||||
"principalId": "$everyone",
|
"principalId": "$everyone",
|
||||||
"permission": "ALLOW"
|
"permission": "ALLOW"
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
"property": "privileges",
|
"property": "privileges",
|
||||||
"accessType": "*",
|
"accessType": "*",
|
||||||
"principalType": "ROLE",
|
"principalType": "ROLE",
|
||||||
"principalId": "$authenticated",
|
"principalId": "$authenticated",
|
||||||
"permission": "ALLOW"
|
"permission": "ALLOW"
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
"property": "renewToken",
|
"property": "renewToken",
|
||||||
"accessType": "WRITE",
|
"accessType": "WRITE",
|
||||||
"principalType": "ROLE",
|
"principalType": "ROLE",
|
||||||
"principalId": "$authenticated",
|
"principalId": "$authenticated",
|
||||||
"permission": "ALLOW"
|
"permission": "ALLOW"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"property": "shareToken",
|
||||||
|
"accessType": "WRITE",
|
||||||
|
"principalType": "ROLE",
|
||||||
|
"principalId": "$authenticated",
|
||||||
|
"permission": "ALLOW"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"scopes": {
|
"scopes": {
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
-- Place your SQL code here
|
||||||
|
|
||||||
|
|
|
@ -83,22 +83,27 @@ export default class Auth {
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoginOk(json, now, remember) {
|
onLoginOk(json, now, remember) {
|
||||||
this.vnToken.set(json.data.token, now, json.data.ttl, remember);
|
return this.$http.get('VnUsers/ShareToken', {
|
||||||
|
headers: {Authorization: json.data.token}
|
||||||
return this.loadAcls().then(() => {
|
}).then(({data}) => {
|
||||||
|
this.vnToken.set(json.data.token, data.multimediaToken.id, now, json.data.ttl, remember);
|
||||||
|
this.loadAcls().then(() => {
|
||||||
let continueHash = this.$state.params.continue;
|
let continueHash = this.$state.params.continue;
|
||||||
if (continueHash)
|
if (continueHash)
|
||||||
this.$window.location = continueHash;
|
this.$window.location = continueHash;
|
||||||
else
|
else
|
||||||
this.$state.go('home');
|
this.$state.go('home');
|
||||||
});
|
});
|
||||||
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
|
this.$http.post('Accounts/logout', null, {headers: {'Authorization': this.vnToken.tokenMultimedia},
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
let promise = this.$http.post('VnUsers/logout', null, {
|
let promise = this.$http.post('VnUsers/logout', null, {
|
||||||
headers: {Authorization: this.vnToken.token}
|
headers: {Authorization: this.vnToken.token}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
this.vnToken.unset();
|
this.vnToken.unset();
|
||||||
this.loggedIn = false;
|
this.loggedIn = false;
|
||||||
this.vnModules.reset();
|
this.vnModules.reset();
|
||||||
|
|
|
@ -19,7 +19,7 @@ function interceptor($q, vnApp, $translate) {
|
||||||
|
|
||||||
if (config.url.charAt(0) !== '/' && apiPath)
|
if (config.url.charAt(0) !== '/' && apiPath)
|
||||||
config.url = `${apiPath}${config.url}`;
|
config.url = `${apiPath}${config.url}`;
|
||||||
if (token)
|
if (token && !config.headers.Authorization)
|
||||||
config.headers.Authorization = token;
|
config.headers.Authorization = token;
|
||||||
if ($translate.use())
|
if ($translate.use())
|
||||||
config.headers['Accept-Language'] = $translate.use();
|
config.headers['Accept-Language'] = $translate.use();
|
||||||
|
|
|
@ -24,21 +24,22 @@ export default class Token {
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
set(token, created, ttl, remember) {
|
set(token, tokenMultimedia, created, ttl, remember) {
|
||||||
this.unset();
|
this.unset();
|
||||||
|
|
||||||
Object.assign(this, {
|
Object.assign(this, {
|
||||||
token,
|
token,
|
||||||
|
tokenMultimedia,
|
||||||
created,
|
created,
|
||||||
ttl,
|
ttl,
|
||||||
remember
|
remember
|
||||||
});
|
});
|
||||||
this.vnInterceptor.setToken(token);
|
this.vnInterceptor.setToken(token, tokenMultimedia);
|
||||||
try {
|
try {
|
||||||
if (remember)
|
if (remember)
|
||||||
this.setStorage(localStorage, token, created, ttl);
|
this.setStorage(localStorage, token, tokenMultimedia, created, ttl);
|
||||||
else
|
else
|
||||||
this.setStorage(sessionStorage, token, created, ttl);
|
this.setStorage(sessionStorage, token, tokenMultimedia, created, ttl);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
@ -46,6 +47,7 @@ export default class Token {
|
||||||
|
|
||||||
unset() {
|
unset() {
|
||||||
this.token = null;
|
this.token = null;
|
||||||
|
this.tokenMultimedia = null;
|
||||||
this.created = null;
|
this.created = null;
|
||||||
this.ttl = null;
|
this.ttl = null;
|
||||||
this.remember = null;
|
this.remember = null;
|
||||||
|
@ -57,13 +59,15 @@ export default class Token {
|
||||||
|
|
||||||
getStorage(storage) {
|
getStorage(storage) {
|
||||||
this.token = storage.getItem('vnToken');
|
this.token = storage.getItem('vnToken');
|
||||||
|
this.tokenMultimedia = storage.getItem('vnTokenMultimedia');
|
||||||
if (!this.token) return;
|
if (!this.token) return;
|
||||||
const created = storage.getItem('vnTokenCreated');
|
const created = storage.getItem('vnTokenCreated');
|
||||||
this.created = created && new Date(created);
|
this.created = created && new Date(created);
|
||||||
this.ttl = storage.getItem('vnTokenTtl');
|
this.ttl = storage.getItem('vnTokenTtl');
|
||||||
}
|
}
|
||||||
|
|
||||||
setStorage(storage, token, created, ttl) {
|
setStorage(storage, token, tokenMultimedia, created, ttl) {
|
||||||
|
storage.setItem('vnTokenMultimedia', tokenMultimedia);
|
||||||
storage.setItem('vnToken', token);
|
storage.setItem('vnToken', token);
|
||||||
storage.setItem('vnTokenCreated', created.toJSON());
|
storage.setItem('vnTokenCreated', created.toJSON());
|
||||||
storage.setItem('vnTokenTtl', ttl);
|
storage.setItem('vnTokenTtl', ttl);
|
||||||
|
@ -71,6 +75,7 @@ export default class Token {
|
||||||
|
|
||||||
removeStorage(storage) {
|
removeStorage(storage) {
|
||||||
storage.removeItem('vnToken');
|
storage.removeItem('vnToken');
|
||||||
|
storage.removeItem('vnTokenMultimedia');
|
||||||
storage.removeItem('vnTokenCreated');
|
storage.removeItem('vnTokenCreated');
|
||||||
storage.removeItem('vnTokenTtl');
|
storage.removeItem('vnTokenTtl');
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,8 +23,7 @@ export class Layout extends Component {
|
||||||
if (!this.$.$root.user) return;
|
if (!this.$.$root.user) return;
|
||||||
|
|
||||||
const userId = this.$.$root.user.id;
|
const userId = this.$.$root.user.id;
|
||||||
const token = this.vnToken.token;
|
return `/api/Images/user/160x160/${userId}/download?access_token=${this.vnToken.tokenMultimedia}`;
|
||||||
return `/api/Images/user/160x160/${userId}/download?access_token=${token}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh() {
|
refresh() {
|
||||||
|
|
|
@ -31,7 +31,7 @@
|
||||||
ng-click="$ctrl.showDescriptor($event, userLog)">
|
ng-click="$ctrl.showDescriptor($event, userLog)">
|
||||||
<img
|
<img
|
||||||
ng-if="::userLog.user.image"
|
ng-if="::userLog.user.image"
|
||||||
ng-src="/api/Images/user/160x160/{{::userLog.userFk}}/download?access_token={{::$ctrl.vnToken.token}}">
|
ng-src="/api/Images/user/160x160/{{::userLog.userFk}}/download?access_token={{::$ctrl.vnToken.tokenMultimedia}}">
|
||||||
</img>
|
</img>
|
||||||
</vn-avatar>
|
</vn-avatar>
|
||||||
</div>
|
</div>
|
||||||
|
@ -181,7 +181,7 @@
|
||||||
val="{{::nickname}}">
|
val="{{::nickname}}">
|
||||||
<img
|
<img
|
||||||
ng-if="::image"
|
ng-if="::image"
|
||||||
ng-src="/api/Images/user/160x160/{{::id}}/download?access_token={{::$ctrl.vnToken.token}}">
|
ng-src="/api/Images/user/160x160/{{::id}}/download?access_token={{::$ctrl.vnToken.tokenMultimedia}}">
|
||||||
</img>
|
</img>
|
||||||
</vn-avatar>
|
</vn-avatar>
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -13,7 +13,7 @@ export function run($window, $rootScope, vnAuth, vnApp, vnToken, $state) {
|
||||||
if (!collection || !size || !id) return;
|
if (!collection || !size || !id) return;
|
||||||
|
|
||||||
const basePath = `/api/Images/${collection}/${size}/${id}`;
|
const basePath = `/api/Images/${collection}/${size}/${id}`;
|
||||||
return `${basePath}/download?access_token=${vnToken.token}`;
|
return `${basePath}/download?access_token=${vnToken.tokenMultimedia}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
$window.validations = {};
|
$window.validations = {};
|
||||||
|
|
|
@ -15,7 +15,8 @@ module.exports = Self => {
|
||||||
http: {
|
http: {
|
||||||
path: `/logout`,
|
path: `/logout`,
|
||||||
verb: 'POST'
|
verb: 'POST'
|
||||||
}
|
},
|
||||||
|
accessScopes: ['DEFAULT', 'read:multimedia']
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.logout = async ctx => Self.app.models.VnUser.logout(ctx.req.accessToken.id);
|
Self.logout = async ctx => Self.app.models.VnUser.logout(ctx.req.accessToken.id);
|
||||||
|
|
|
@ -32,7 +32,8 @@ module.exports = Self => {
|
||||||
http: {
|
http: {
|
||||||
path: `/:id/downloadFile`,
|
path: `/:id/downloadFile`,
|
||||||
verb: 'GET'
|
verb: 'GET'
|
||||||
}
|
},
|
||||||
|
accessScopes: ['read:multimedia']
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.downloadFile = async function(ctx, id) {
|
Self.downloadFile = async function(ctx, id) {
|
||||||
|
|
|
@ -114,7 +114,7 @@
|
||||||
<vn-td center shrink>
|
<vn-td center shrink>
|
||||||
<a ng-show="balance.hasPdf"
|
<a ng-show="balance.hasPdf"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
href="api/InvoiceOuts/{{::balance.id}}/download?access_token={{::$ctrl.vnToken.token}}">
|
href="api/InvoiceOuts/{{::balance.id}}/download?access_token={{::$ctrl.vnToken.tokenMultimedia}}">
|
||||||
<vn-icon-button
|
<vn-icon-button
|
||||||
icon="cloud_download"
|
icon="cloud_download"
|
||||||
title="{{'Download PDF' | translate}}">
|
title="{{'Download PDF' | translate}}">
|
||||||
|
|
|
@ -31,7 +31,8 @@ module.exports = Self => {
|
||||||
http: {
|
http: {
|
||||||
path: '/:id/download',
|
path: '/:id/download',
|
||||||
verb: 'GET'
|
verb: 'GET'
|
||||||
}
|
},
|
||||||
|
accessScopes: ['read:multimedia']
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.download = async function(ctx, id, options) {
|
Self.download = async function(ctx, id, options) {
|
||||||
|
|
|
@ -31,7 +31,8 @@ module.exports = Self => {
|
||||||
http: {
|
http: {
|
||||||
path: '/downloadZip',
|
path: '/downloadZip',
|
||||||
verb: 'GET'
|
verb: 'GET'
|
||||||
}
|
},
|
||||||
|
accessScopes: ['read:multimedia']
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.downloadZip = async function(ctx, ids, options) {
|
Self.downloadZip = async function(ctx, ids, options) {
|
||||||
|
|
|
@ -37,7 +37,7 @@
|
||||||
<vn-menu vn-id="showInvoiceMenu">
|
<vn-menu vn-id="showInvoiceMenu">
|
||||||
<vn-list>
|
<vn-list>
|
||||||
<a class="vn-item"
|
<a class="vn-item"
|
||||||
href="api/InvoiceOuts/{{$ctrl.id}}/download?access_token={{$ctrl.vnToken.token}}"
|
href="api/InvoiceOuts/{{$ctrl.id}}/download?access_token={{$ctrl.vnToken.tokenMultimedia}}"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
name="showInvoicePdf"
|
name="showInvoicePdf"
|
||||||
translate>
|
translate>
|
||||||
|
|
|
@ -25,7 +25,7 @@ export default class Controller extends Section {
|
||||||
openPdf() {
|
openPdf() {
|
||||||
if (this.checked.length <= 1) {
|
if (this.checked.length <= 1) {
|
||||||
const [invoiceOutId] = this.checked;
|
const [invoiceOutId] = this.checked;
|
||||||
const url = `api/InvoiceOuts/${invoiceOutId}/download?access_token=${this.vnToken.token}`;
|
const url = `api/InvoiceOuts/${invoiceOutId}/download?access_token=${this.vnToken.tokenMultimedia}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
} else {
|
} else {
|
||||||
const invoiceOutIds = this.checked;
|
const invoiceOutIds = this.checked;
|
||||||
|
|
|
@ -11,6 +11,7 @@ module.exports = Self => {
|
||||||
path: `/download`,
|
path: `/download`,
|
||||||
verb: 'POST',
|
verb: 'POST',
|
||||||
},
|
},
|
||||||
|
accessScopes: ['read:multimedia']
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.download = async() => {
|
Self.download = async() => {
|
||||||
|
|
|
@ -29,7 +29,8 @@ module.exports = Self => {
|
||||||
http: {
|
http: {
|
||||||
path: '/downloadCmrsZip',
|
path: '/downloadCmrsZip',
|
||||||
verb: 'GET'
|
verb: 'GET'
|
||||||
}
|
},
|
||||||
|
accessScopes: ['read:multimedia']
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.downloadCmrsZip = async function(ctx, ids, options) {
|
Self.downloadCmrsZip = async function(ctx, ids, options) {
|
||||||
|
|
|
@ -29,7 +29,8 @@ module.exports = Self => {
|
||||||
http: {
|
http: {
|
||||||
path: '/downloadZip',
|
path: '/downloadZip',
|
||||||
verb: 'GET'
|
verb: 'GET'
|
||||||
}
|
},
|
||||||
|
accessScopes: ['read:multimedia']
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.downloadZip = async function(ctx, id, options) {
|
Self.downloadZip = async function(ctx, id, options) {
|
||||||
|
|
|
@ -34,7 +34,9 @@ module.exports = Self => {
|
||||||
http: {
|
http: {
|
||||||
path: '/:id/driver-route-pdf',
|
path: '/:id/driver-route-pdf',
|
||||||
verb: 'GET'
|
verb: 'GET'
|
||||||
}
|
},
|
||||||
|
accessScopes: ['read:multimedia']
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.driverRoutePdf = (ctx, id) => Self.printReport(ctx, id, 'driver-route');
|
Self.driverRoutePdf = (ctx, id) => Self.printReport(ctx, id, 'driver-route');
|
||||||
|
|
|
@ -40,7 +40,7 @@ export default class Controller extends Section {
|
||||||
const stringRoutesIds = routesIds.join(',');
|
const stringRoutesIds = routesIds.join(',');
|
||||||
|
|
||||||
if (this.checked.length <= 1) {
|
if (this.checked.length <= 1) {
|
||||||
const url = `api/Routes/${stringRoutesIds}/driver-route-pdf?access_token=${this.vnToken.token}`;
|
const url = `api/Routes/${stringRoutesIds}/driver-route-pdf?access_token=${this.vnToken.tokenMultimedia}`;
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
} else {
|
} else {
|
||||||
const serializedParams = this.$httpParamSerializer({
|
const serializedParams = this.$httpParamSerializer({
|
||||||
|
|
|
@ -29,7 +29,8 @@ module.exports = Self => {
|
||||||
http: {
|
http: {
|
||||||
path: `/:id/downloadFile`,
|
path: `/:id/downloadFile`,
|
||||||
verb: 'GET'
|
verb: 'GET'
|
||||||
}
|
},
|
||||||
|
accessScopes: ['read:multimedia']
|
||||||
});
|
});
|
||||||
|
|
||||||
Self.downloadFile = async function(ctx, id) {
|
Self.downloadFile = async function(ctx, id) {
|
||||||
|
|
Loading…
Reference in New Issue