mirror of
https://github.com/Tautulli/Tautulli.git
synced 2025-07-07 05:31:15 -07:00
Refactor Plex OAuth code
This commit is contained in:
parent
8514cf1975
commit
c0b960bccf
5 changed files with 181 additions and 237 deletions
|
@ -291,6 +291,7 @@ ${next.modalIncludes()}
|
||||||
<script src="${http_root}js/bootstrap.min.js"></script>
|
<script src="${http_root}js/bootstrap.min.js"></script>
|
||||||
<script src="${http_root}js/bootstrap-hover-dropdown.min.js"></script>
|
<script src="${http_root}js/bootstrap-hover-dropdown.min.js"></script>
|
||||||
<script src="${http_root}js/pnotify.custom.min.js"></script>
|
<script src="${http_root}js/pnotify.custom.min.js"></script>
|
||||||
|
<script src="${http_root}js/platform.min.js"></script>
|
||||||
<script src="${http_root}js/script.js${cache_param}"></script>
|
<script src="${http_root}js/script.js${cache_param}"></script>
|
||||||
<script src="${http_root}js/jquery.qrcode.min.js"></script>
|
<script src="${http_root}js/jquery.qrcode.min.js"></script>
|
||||||
<script src="${http_root}js/jquery.tripleclick.min.js"></script>
|
<script src="${http_root}js/jquery.tripleclick.min.js"></script>
|
||||||
|
|
|
@ -489,4 +489,145 @@ function PopupCenter(url, title, w, h) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return newWindow;
|
return newWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localStorage.getItem('Tautulli_ClientId')) {
|
||||||
|
localStorage.setItem('Tautulli_ClientId', uuidv4());
|
||||||
|
}
|
||||||
|
|
||||||
|
function uuidv4() {
|
||||||
|
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
||||||
|
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var x_plex_headers = {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-Plex-Product': '',
|
||||||
|
'X-Plex-Version': '',
|
||||||
|
'X-Plex-Client-Identifier': localStorage.getItem('Tautulli_ClientId'),
|
||||||
|
'X-Plex-Platform': platform.name,
|
||||||
|
'X-Plex-Platform-Version': platform.version,
|
||||||
|
'X-Plex-Device': platform.os.toString(),
|
||||||
|
'X-Plex-Device-Name': platform.name
|
||||||
|
};
|
||||||
|
|
||||||
|
var plex_oauth_window = null;
|
||||||
|
const plex_oauth_loader = '<style>' +
|
||||||
|
'.login-loader-container {' +
|
||||||
|
'font-family: \'Open Sans\', Arial, sans-serif;' +
|
||||||
|
'position: absolute;' +
|
||||||
|
'top: 0;' +
|
||||||
|
'right: 0;' +
|
||||||
|
'bottom: 0;' +
|
||||||
|
'left: 0;' +
|
||||||
|
'}' +
|
||||||
|
'.login-loader-message {' +
|
||||||
|
'color: #282A2D;' +
|
||||||
|
'text-align: center;' +
|
||||||
|
'position: absolute;' +
|
||||||
|
'left: 50%;' +
|
||||||
|
'top: 25%;' +
|
||||||
|
'transform: translate(-50%, -50%);' +
|
||||||
|
'}' +
|
||||||
|
'.login-loader {' +
|
||||||
|
'border: 5px solid #ccc;' +
|
||||||
|
'-webkit-animation: spin 1s linear infinite;' +
|
||||||
|
'animation: spin 1s linear infinite;' +
|
||||||
|
'border-top: 5px solid #282A2D;' +
|
||||||
|
'border-radius: 50%;' +
|
||||||
|
'width: 50px;' +
|
||||||
|
'height: 50px;' +
|
||||||
|
'position: relative;' +
|
||||||
|
'left: calc(50% - 25px);' +
|
||||||
|
'}' +
|
||||||
|
'</style>' +
|
||||||
|
'<div class"login-loader-container">' +
|
||||||
|
'<div class="login-loader-message">' +
|
||||||
|
'<div class="login-loader"></div>' +
|
||||||
|
'<br>' +
|
||||||
|
'Redirecting to Plex Login...' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
function closePlexOAuthWindow() {
|
||||||
|
if (plex_oauth_window) {
|
||||||
|
plex_oauth_window.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlexOAuthPin = function () {
|
||||||
|
var deferred = $.Deferred();
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: 'https://plex.tv/api/v2/pins?strong=true',
|
||||||
|
type: 'POST',
|
||||||
|
headers: x_plex_headers,
|
||||||
|
success: function(data) {
|
||||||
|
plex_oauth_window.location = 'https://app.plex.tv/auth/#!?clientID=' + x_plex_headers['X-Plex-Client-Identifier'] + '&code=' + data.code;
|
||||||
|
deferred.resolve({pin: data.id, code: data.code});
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
closePlexOAuthWindow();
|
||||||
|
deferred.reject();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return deferred;
|
||||||
|
};
|
||||||
|
|
||||||
|
var polling = null;
|
||||||
|
|
||||||
|
function PlexOAuth(success, error, pre) {
|
||||||
|
if (typeof pre === "function") {
|
||||||
|
pre()
|
||||||
|
}
|
||||||
|
clearTimeout(polling);
|
||||||
|
closePlexOAuthWindow();
|
||||||
|
plex_oauth_window = PopupCenter('', 'Plex-OAuth', 600, 700);
|
||||||
|
$(plex_oauth_window.document.body).html(plex_oauth_loader);
|
||||||
|
|
||||||
|
getPlexOAuthPin().then(function (data) {
|
||||||
|
const pin = data.pin;
|
||||||
|
const code = data.code;
|
||||||
|
var keep_polling = true;
|
||||||
|
|
||||||
|
(function poll() {
|
||||||
|
polling = setTimeout(function () {
|
||||||
|
$.ajax({
|
||||||
|
url: 'https://plex.tv/api/v2/pins/' + pin,
|
||||||
|
type: 'GET',
|
||||||
|
headers: x_plex_headers,
|
||||||
|
success: function (data) {
|
||||||
|
if (data.authToken){
|
||||||
|
keep_polling = false;
|
||||||
|
closePlexOAuthWindow();
|
||||||
|
if (typeof success === "function") {
|
||||||
|
success(data.authToken)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
keep_polling = false;
|
||||||
|
closePlexOAuthWindow();
|
||||||
|
if (typeof error === "function") {
|
||||||
|
error()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: function () {
|
||||||
|
if (keep_polling){
|
||||||
|
poll();
|
||||||
|
} else {
|
||||||
|
clearTimeout(polling);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout: 1000
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
})();
|
||||||
|
}, function () {
|
||||||
|
closePlexOAuthWindow();
|
||||||
|
if (typeof error === "function") {
|
||||||
|
error()
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
|
@ -116,90 +116,17 @@
|
||||||
<script>
|
<script>
|
||||||
var login_accordion = new Accordion($('#login-methods'), false, false);
|
var login_accordion = new Accordion($('#login-methods'), false, false);
|
||||||
|
|
||||||
if (!localStorage.getItem('Tautulli_ClientId')) {
|
function OAuthSuccessCallback(authToken) {
|
||||||
localStorage.setItem('Tautulli_ClientId', uuidv4());
|
signIn(true, authToken);
|
||||||
|
}
|
||||||
|
function OAuthErrorCallback() {
|
||||||
|
$('#sign-in-alert').text('Error communicating with Plex.tv.').show();
|
||||||
}
|
}
|
||||||
|
|
||||||
function uuidv4() {
|
|
||||||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
|
|
||||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const x_plex_headers = {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-Plex-Product': '${plexpy.common.PRODUCT}',
|
|
||||||
'X-Plex-Version': '${plexpy.common.RELEASE}',
|
|
||||||
'X-Plex-Client-Identifier': localStorage.getItem('Tautulli_ClientId'),
|
|
||||||
'X-Plex-Platform': platform.name,
|
|
||||||
'X-Plex-Platform-Version': platform.version,
|
|
||||||
'X-Plex-Device': platform.os.toString(),
|
|
||||||
'X-Plex-Device-Name': platform.name
|
|
||||||
};
|
|
||||||
|
|
||||||
getPlexOAuthPin = function () {
|
|
||||||
var deferred = $.Deferred();
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: 'https://plex.tv/api/v2/pins?strong=true',
|
|
||||||
type: 'POST',
|
|
||||||
headers: x_plex_headers,
|
|
||||||
success: function(data) {
|
|
||||||
deferred.resolve({pin: data.id, code: data.code});
|
|
||||||
},
|
|
||||||
error: function() {
|
|
||||||
deferred.reject();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return deferred;
|
|
||||||
};
|
|
||||||
|
|
||||||
var polling = null;
|
|
||||||
$('#sign-in-plex').click(function() {
|
$('#sign-in-plex').click(function() {
|
||||||
clearTimeout(polling);
|
x_plex_headers['X-Plex-Product'] = '${plexpy.common.PRODUCT}';
|
||||||
|
x_plex_headers['X-Plex-Version'] = '${plexpy.common.RELEASE}';
|
||||||
getPlexOAuthPin().then(function (data) {
|
PlexOAuth(OAuthSuccessCallback, OAuthErrorCallback);
|
||||||
const pin = data.pin;
|
|
||||||
const code = data.code;
|
|
||||||
var keep_polling = true;
|
|
||||||
|
|
||||||
var plex_oauth_window = PopupCenter(
|
|
||||||
'https://app.plex.tv/auth/#!?clientID=' + x_plex_headers['X-Plex-Client-Identifier'] + '&code=' + code,
|
|
||||||
'Plex-OAuth', 600, 700);
|
|
||||||
|
|
||||||
(function poll() {
|
|
||||||
polling = setTimeout(function () {
|
|
||||||
$.ajax({
|
|
||||||
url: 'https://plex.tv/api/v2/pins/' + pin,
|
|
||||||
type: 'GET',
|
|
||||||
headers: x_plex_headers,
|
|
||||||
success: function (data) {
|
|
||||||
if (data.authToken){
|
|
||||||
keep_polling = false;
|
|
||||||
if (plex_oauth_window) {
|
|
||||||
plex_oauth_window.close();
|
|
||||||
}
|
|
||||||
signIn(true, data.authToken);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: function () {
|
|
||||||
keep_polling = false;
|
|
||||||
$('#sign-in-alert').text('Error communicating with Plex.tv.').show();
|
|
||||||
},
|
|
||||||
complete: function () {
|
|
||||||
if (keep_polling){
|
|
||||||
poll();
|
|
||||||
} else {
|
|
||||||
clearTimeout(polling);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
timeout: 1000
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
})();
|
|
||||||
}, function () {
|
|
||||||
$('#sign-in-alert').text('Error communicating with Plex.tv.').show();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#login-form').submit(function(event) {
|
$('#login-form').submit(function(event) {
|
||||||
|
|
|
@ -2255,86 +2255,23 @@ $(document).ready(function() {
|
||||||
window.open(pms_web_url, '_blank');
|
window.open(pms_web_url, '_blank');
|
||||||
});
|
});
|
||||||
|
|
||||||
const x_plex_headers = {
|
function OAuthPreFunction() {
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-Plex-Product': '${plexpy.common.PRODUCT}',
|
|
||||||
'X-Plex-Version': '${plexpy.common.RELEASE}',
|
|
||||||
'X-Plex-Client-Identifier': '${plexpy.generate_uuid()}',
|
|
||||||
'X-Plex-Platform': '${plexpy.common.PLATFORM}',
|
|
||||||
'X-Plex-Platform-Version': '${plexpy.common.PLATFORM_RELEASE}',
|
|
||||||
'X-Plex-Device': '${plexpy.common.PLATFORM} ${plexpy.common.PLATFORM_RELEASE}',
|
|
||||||
'X-Plex-Device-Name': '${plexpy.common.PLATFORM_DEVICE_NAME}'
|
|
||||||
};
|
|
||||||
|
|
||||||
getPlexOAuthPin = function () {
|
|
||||||
var deferred = $.Deferred();
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: 'https://plex.tv/api/v2/pins?strong=true',
|
|
||||||
type: 'POST',
|
|
||||||
headers: x_plex_headers,
|
|
||||||
success: function(data) {
|
|
||||||
deferred.resolve({pin: data.id, code: data.code});
|
|
||||||
},
|
|
||||||
error: function() {
|
|
||||||
deferred.reject();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return deferred;
|
|
||||||
};
|
|
||||||
|
|
||||||
var polling = null;
|
|
||||||
$('#sign-in-plex').click(function() {
|
|
||||||
$("#token_verify").html('<i class="fa fa-refresh fa-spin"></i>').fadeIn('fast');
|
$("#token_verify").html('<i class="fa fa-refresh fa-spin"></i>').fadeIn('fast');
|
||||||
|
}
|
||||||
|
function OAuthSuccessCallback(authToken) {
|
||||||
|
$("#pms_token").val(authToken);
|
||||||
|
$("#pms_uuid").val(x_plex_headers['X-Plex-Client-Identifier']);
|
||||||
|
$("#token_verify").html('<i class="fa fa-check"></i>').fadeIn('fast');
|
||||||
|
getServerOptions(authToken);
|
||||||
|
}
|
||||||
|
function OAuthErrorCallback() {
|
||||||
|
$("#token_verify").html('<i class="fa fa-close"></i>').fadeIn('fast');
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(polling);
|
$('#sign-in-plex').click(function() {
|
||||||
|
x_plex_headers['X-Plex-Product'] = '${plexpy.common.PRODUCT}';
|
||||||
getPlexOAuthPin().then(function (data) {
|
x_plex_headers['X-Plex-Version'] = '${plexpy.common.RELEASE}';
|
||||||
const pin = data.pin;
|
PlexOAuth(OAuthSuccessCallback, OAuthErrorCallback, OAuthPreFunction);
|
||||||
const code = data.code;
|
|
||||||
var keep_polling = true;
|
|
||||||
|
|
||||||
var plex_oauth_window = PopupCenter(
|
|
||||||
'https://app.plex.tv/auth/#!?clientID=' + x_plex_headers['X-Plex-Client-Identifier'] + '&code=' + code,
|
|
||||||
'Plex-OAuth', 600, 700);
|
|
||||||
|
|
||||||
(function poll() {
|
|
||||||
polling = setTimeout(function () {
|
|
||||||
$.ajax({
|
|
||||||
url: 'https://plex.tv/api/v2/pins/' + pin,
|
|
||||||
type: 'GET',
|
|
||||||
headers: x_plex_headers,
|
|
||||||
success: function (data) {
|
|
||||||
if (data.authToken){
|
|
||||||
var authToken = data.authToken;
|
|
||||||
keep_polling = false;
|
|
||||||
if (plex_oauth_window) {
|
|
||||||
plex_oauth_window.close();
|
|
||||||
}
|
|
||||||
$("#pms_token").val(authToken);
|
|
||||||
$("#pms_uuid").val(x_plex_headers['X-Plex-Client-Identifier']);
|
|
||||||
$("#token_verify").html('<i class="fa fa-check"></i>').fadeIn('fast');
|
|
||||||
getServerOptions(authToken)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: function () {
|
|
||||||
keep_polling = false;
|
|
||||||
$("#token_verify").html('<i class="fa fa-close"></i>').fadeIn('fast');
|
|
||||||
},
|
|
||||||
complete: function () {
|
|
||||||
if (keep_polling){
|
|
||||||
poll();
|
|
||||||
} else {
|
|
||||||
clearTimeout(polling);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
timeout: 1000
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
})();
|
|
||||||
}, function () {
|
|
||||||
$("#token_verify").html('<i class="fa fa-close"></i>').fadeIn('fast');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load database import modal
|
// Load database import modal
|
||||||
|
|
|
@ -212,6 +212,7 @@
|
||||||
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
|
<script src="${http_root}js/jquery-2.1.4.min.js"></script>
|
||||||
<script src="${http_root}js/bootstrap.min.js"></script>
|
<script src="${http_root}js/bootstrap.min.js"></script>
|
||||||
<script src="${http_root}js/selectize.min.js"></script>
|
<script src="${http_root}js/selectize.min.js"></script>
|
||||||
|
<script src="${http_root}js/platform.min.js"></script>
|
||||||
<script src="${http_root}js/script.js${cache_param}"></script>
|
<script src="${http_root}js/script.js${cache_param}"></script>
|
||||||
<script src="${http_root}js/bootstrap-wizard.min.js"></script>
|
<script src="${http_root}js/bootstrap-wizard.min.js"></script>
|
||||||
<script>
|
<script>
|
||||||
|
@ -467,87 +468,24 @@ $(document).ready(function() {
|
||||||
$("#pms-verify-status").html("");
|
$("#pms-verify-status").html("");
|
||||||
});
|
});
|
||||||
|
|
||||||
const x_plex_headers = {
|
function OAuthPreFunction() {
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-Plex-Product': '${plexpy.common.PRODUCT}',
|
|
||||||
'X-Plex-Version': '${plexpy.common.RELEASE}',
|
|
||||||
'X-Plex-Client-Identifier': '${plexpy.CONFIG.PMS_UUID}',
|
|
||||||
'X-Plex-Platform': '${plexpy.common.PLATFORM}',
|
|
||||||
'X-Plex-Platform-Version': '${plexpy.common.PLATFORM_RELEASE}',
|
|
||||||
'X-Plex-Device': '${plexpy.common.PLATFORM} ${plexpy.common.PLATFORM_RELEASE}',
|
|
||||||
'X-Plex-Device-Name': '${plexpy.common.PLATFORM_DEVICE_NAME}'
|
|
||||||
};
|
|
||||||
|
|
||||||
getPlexOAuthPin = function () {
|
|
||||||
var deferred = $.Deferred();
|
|
||||||
|
|
||||||
$.ajax({
|
|
||||||
url: 'https://plex.tv/api/v2/pins?strong=true',
|
|
||||||
type: 'POST',
|
|
||||||
headers: x_plex_headers,
|
|
||||||
success: function(data) {
|
|
||||||
deferred.resolve({pin: data.id, code: data.code});
|
|
||||||
},
|
|
||||||
error: function() {
|
|
||||||
deferred.reject();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return deferred;
|
|
||||||
};
|
|
||||||
|
|
||||||
var polling = null;
|
|
||||||
$('#sign-in-plex').click(function() {
|
|
||||||
$("#pms_token").val('');
|
$("#pms_token").val('');
|
||||||
$("#pms-token-status").html('<i class="fa fa-refresh fa-spin"></i> Waiting for authentication...').fadeIn('fast');
|
$("#pms-token-status").html('<i class="fa fa-refresh fa-spin"></i> Waiting for authentication...').fadeIn('fast');
|
||||||
|
}
|
||||||
|
function OAuthSuccessCallback(authToken) {
|
||||||
|
$("#pms_token").val(authToken);
|
||||||
|
$("#pms-token-status").html('<i class="fa fa-check"></i> Authentication successful.').fadeIn('fast');
|
||||||
|
authenticated = true;
|
||||||
|
getServerOptions(authToken);
|
||||||
|
}
|
||||||
|
function OAuthErrorCallback() {
|
||||||
|
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> Error communicating with Plex.tv.').fadeIn('fast');
|
||||||
|
}
|
||||||
|
|
||||||
clearTimeout(polling);
|
$('#sign-in-plex').click(function() {
|
||||||
|
x_plex_headers['X-Plex-Product'] = '${plexpy.common.PRODUCT}';
|
||||||
getPlexOAuthPin().then(function (data) {
|
x_plex_headers['X-Plex-Version'] = '${plexpy.common.RELEASE}';
|
||||||
const pin = data.pin;
|
PlexOAuth(OAuthSuccessCallback, OAuthErrorCallback, OAuthPreFunction);
|
||||||
const code = data.code;
|
|
||||||
var keep_polling = true;
|
|
||||||
|
|
||||||
var plex_oauth_window = PopupCenter(
|
|
||||||
'https://app.plex.tv/auth/#!?clientID=' + x_plex_headers['X-Plex-Client-Identifier'] + '&code=' + code,
|
|
||||||
'Plex-OAuth', 600, 700);
|
|
||||||
|
|
||||||
(function poll() {
|
|
||||||
polling = setTimeout(function () {
|
|
||||||
$.ajax({
|
|
||||||
url: 'https://plex.tv/api/v2/pins/' + pin,
|
|
||||||
type: 'GET',
|
|
||||||
headers: x_plex_headers,
|
|
||||||
success: function (data) {
|
|
||||||
if (data.authToken){
|
|
||||||
var authToken = data.authToken;
|
|
||||||
keep_polling = false;
|
|
||||||
if (plex_oauth_window) {
|
|
||||||
plex_oauth_window.close();
|
|
||||||
}
|
|
||||||
$("#pms_token").val(authToken);
|
|
||||||
$("#pms-token-status").html('<i class="fa fa-check"></i> Authentication successful.').fadeIn('fast');
|
|
||||||
authenticated = true;
|
|
||||||
getServerOptions(authToken)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: function () {
|
|
||||||
keep_polling = false;
|
|
||||||
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> Error communicating with Plex.tv.').fadeIn('fast');
|
|
||||||
},
|
|
||||||
complete: function () {
|
|
||||||
if (keep_polling){
|
|
||||||
poll();
|
|
||||||
} else {
|
|
||||||
clearTimeout(polling);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
timeout: 1000
|
|
||||||
});
|
|
||||||
}, 1000);
|
|
||||||
})();
|
|
||||||
}, function () {
|
|
||||||
$("#pms-token-status").html('<i class="fa fa-exclamation-circle"></i> Error communicating with Plex.tv.').fadeIn('fast');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue