diff --git a/components/config/SigninScene.bs b/components/config/SigninScene.bs
index 3a248528a..a104a71fb 100644
--- a/components/config/SigninScene.bs
+++ b/components/config/SigninScene.bs
@@ -191,6 +191,11 @@ sub onAlertChange()
errorMessage = m.top.findNode("errorMessage")
errorMessage.text = m.top.alert
+
+ ' Some messages run to several lines, so the box grows to hold them instead of covering the first.
+ textHeight = errorMessage.boundingRect().height
+ m.errorContainer.height = textHeight + 26
+ if m.errorContainer.height < 60 then m.errorContainer.height = 60
end sub
sub onUserChange()
@@ -282,6 +287,12 @@ sub onQuickConnectAuthenticated()
if authenticated >= 0 then return
+ if authenticated = -2
+ m.top.alert = signInRefusedMessage()
+ hideQuickConnect()
+ return
+ end if
+
' A poll that can never succeed looks the same as one still waiting to be approved, so
' the wait is bounded rather than left running until the viewer walks away
m.quickConnectAttempts = m.quickConnectAttempts + 1
diff --git a/components/config/SigninScene.xml b/components/config/SigninScene.xml
index 46ae31486..c1419c51f 100644
--- a/components/config/SigninScene.xml
+++ b/components/config/SigninScene.xml
@@ -25,7 +25,7 @@
uri="pkg:/images/surface.9.png">
-
+
External Lists
Heading of a group of disabled rows in the Home Rows editor
+
+ This account can't sign in right now. It may be outside its allowed hours, locked or disabled. Try again later or ask an administrator.
+ This account can't sign in right now. It may be outside its allowed hours, locked or disabled. Try again later or ask an administrator.
+ Shown when the server refuses a sign in because the account can't be used right now, for example outside its access schedule
+
diff --git a/source/api/userauth.bs b/source/api/userauth.bs
index 0494140a9..ca178fa66 100644
--- a/source/api/userauth.bs
+++ b/source/api/userauth.bs
@@ -23,9 +23,17 @@ function validPasswordLogin(response as object) as boolean
return embyConnect.StringValue(json.User, ["Id"]) <> "" and embyConnect.StringValue(json.User, ["Name"]) <> ""
end function
+' Jellyfin refuses a sign in with a 403 when the account can't be used right now, for instance
+' outside the hours of its access schedule, and gives no reason beyond that. A wrong password is
+' a 401, but the account is checked first, so such an account gets this for any password.
+function signInRefusedMessage() as string
+ return tr("This account can't sign in right now. It may be outside its allowed hours, locked or disabled. Try again later or ask an administrator.")
+end function
+
function passwordLoginError(status as integer) as string
if status = 401 or status = 403
if isEmbyServer() then return tr("Emby server rejected the login. Use the local server account, or choose Emby Connect for your Emby account.")
+ if status = 403 then return signInRefusedMessage()
return tr("Username or password is incorrect, or access is denied.")
end if
if status = 0 then return tr("The server did not respond. Check the connection and try again.")
@@ -248,23 +256,24 @@ function checkQuickConnect(secret)
return false
end function
-function AuthenticateViaQuickConnect(secret)
- params = {
- secret: secret
- }
- req = APIRequest("Users/AuthenticateWithQuickConnect")
- jsonResponse = postJson(req, FormatJson(params))
- if jsonResponse <> invalid and jsonResponse.AccessToken <> invalid and jsonResponse.User <> invalid
- userdata = CreateObject("roSGNode", "UserData")
- userdata.json = jsonResponse
- session.user.Update("id", jsonResponse.User.Id)
- session.user.Update("authToken", jsonResponse.AccessToken)
- userdata.callFunc("saveToRegistry")
-
- return true
- end if
+' Exchanges an approved Quick Connect secret for a session. refused is true when the server turned
+' the account away, which it does with a 403 and nothing in the body.
+function AuthenticateViaQuickConnect(secret) as object
+ serverUrl = get_url()
+ header = buildAuthHeaderForServer({ serverUrl: serverUrl, userId: "", authToken: "" })
+ headers = { "Content-Type": "application/json", "Authorization": header }
+ if isEmbyServer(serverUrl) then headers["X-Emby-Authorization"] = header
+ url = buildURLForServer(serverUrl, "Users/AuthenticateWithQuickConnect", {})
+ response = embyConnect.Request(url, "POST", FormatJson({ secret: secret }), headers)
+ if not validPasswordLogin(response) then return { ok: false, refused: response.status = 403 }
- return false
+ jsonResponse = response.data
+ userdata = CreateObject("roSGNode", "UserData")
+ userdata.json = jsonResponse
+ session.user.Update("id", jsonResponse.User.Id)
+ session.user.Update("authToken", jsonResponse.AccessToken)
+ userdata.callFunc("saveToRegistry")
+ return { ok: true, refused: false }
end function
sub forgetServerIdentity(serverUrl as dynamic, removedUserId as string)
diff --git a/test/server-compatibility.bs b/test/server-compatibility.bs
index 43d812f49..0d825dfa5 100644
--- a/test/server-compatibility.bs
+++ b/test/server-compatibility.bs
@@ -133,6 +133,7 @@ sub Main()
testReviewRegressions()
testAccentFolding()
testQuickConnectUrl()
+ testSignInRefused()
testLogRedaction()
testYoutubeTrailer()
testPlaybackFallback()
diff --git a/test/server-compatibility.mjs b/test/server-compatibility.mjs
index 11c1e0c5d..236af5f91 100644
--- a/test/server-compatibility.mjs
+++ b/test/server-compatibility.mjs
@@ -91,7 +91,7 @@ const selections = {
'components/captionTask.bs': ['trackAddress'],
'components/ItemGrid/LoadVideoContentTask.bs': ['playbackResourceURL', 'resolvePlaybackURL', 'playbackPort', 'normalizedPlaybackPort', 'playbackUsesServerAuth', 'isHTTPStream', 'getTranscodeReasons', 'addVideoContentURL', 'addSourceCodecs', 'shouldBurnInSubtitle', 'transcodeCopiesVideo', 'audioPlaysDirect', 'containersOverlap'],
'components/ItemGrid/LoadItemsTask2.bs': ['getTargetImageURL', 'getTargetServerUrl', 'isUsingRemoteServer'],
- 'source/api/userauth.bs': ['passwordLoginRequest', 'validPasswordLogin', 'passwordLoginError'],
+ 'source/api/userauth.bs': ['passwordLoginRequest', 'validPasswordLogin', 'passwordLoginError', 'signInRefusedMessage', 'AuthenticateViaQuickConnect'],
'source/utils/config.bs': ['current_user_id', 'get_user_setting'],
'source/utils/misc.bs': ['isLocalhost', 'isSupportedMediaServer', 'urlCandidates', 'isValid', 'isAllValid', 'isStringEqual', 'isChainValid', 'chainLookupReturn', 'chainLookup', 'isValidAndNotEmpty', 'serverVersionMeetsMinimumRequirements', 'playlistRenumbersAfterDelete', 'toString', 'getHomeBackdropBlurAmount', 'inArray', 'guidKey', 'isString', 'toBoolean', 'leftPad'],
'source/ShowScenes.bs': ['ServerVersionCheck', 'startDetailExtras'],
@@ -131,6 +131,7 @@ source += '\n' + await readFile('test/server-compatibility.bs', 'utf8');
source += '\n' + await readFile('test/review-regressions.bs', 'utf8');
source += '\n' + await readFile('test/accent-folding.bs', 'utf8');
source += '\n' + await readFile('test/quick-connect-url.bs', 'utf8');
+source += '\n' + await readFile('test/sign-in-refused.bs', 'utf8');
source += '\n' + await readFile('test/log-redaction.bs', 'utf8');
source += '\n' + await readFile('test/youtube-trailer.bs', 'utf8');
source += '\n' + await readFile('test/playback-fallback.bs', 'utf8');
diff --git a/test/sign-in-refused.bs b/test/sign-in-refused.bs
new file mode 100644
index 000000000..f2af55e64
--- /dev/null
+++ b/test/sign-in-refused.bs
@@ -0,0 +1,21 @@
+sub testSignInRefused()
+ m.global.session.server = { url: "https://jellyfin.test", serverType: "jellyfin" }
+ refused = signInRefusedMessage()
+ assertEqual(passwordLoginError(403), refused, "A 403 from Jellyfin says the account can't sign in right now")
+ assertEqual(passwordLoginError(401) <> refused, true, "A wrong password keeps its own message")
+ assertEqual(passwordLoginError(500).Instr("500") >= 0, true, "Other failures keep their messages")
+ assertEqual(refused.Instr("allowed hours") >= 0, true, "The message names the schedule as one possible reason")
+
+ m.requests = []
+ m.responses = [{ status: 403, data: invalid }]
+ result = AuthenticateViaQuickConnect("secret")
+ assertEqual(result.refused, true, "A refused Quick Connect exchange is reported as refused")
+ assertEqual(result.ok, false, "A refused Quick Connect exchange does not sign in")
+ assertEqual(m.requests[0].url, "https://jellyfin.test/Users/AuthenticateWithQuickConnect", "The exchange goes to the Quick Connect route")
+ assertEqual(ParseJson(m.requests[0].body).secret, "secret", "The exchange sends the approved secret")
+
+ m.responses = [{ status: 500, data: invalid }]
+ result = AuthenticateViaQuickConnect("secret")
+ assertEqual(result.refused, false, "A server error is not a refusal")
+ assertEqual(result.ok, false, "A server error does not sign in")
+end sub