Auth: Localize login/session/OIDC error responses via messageId #5699 #5682

This commit is contained in:
Michael Mayer 2026-06-27 11:34:54 +00:00
parent 0293736a08
commit a2e8592dd5
9 changed files with 116 additions and 87 deletions

View file

@ -33,8 +33,10 @@ func TestMain(m *testing.M) {
c := config.TestConfig()
get.SetConfig(c)
// Increase login rate limit for testing.
// Increase the login and authentication rate limits for testing so the many
// failed-auth cases across the suite don't exhaust the shared per-IP buckets.
limiter.Login = limiter.NewLimit(1, 10000)
limiter.Auth = limiter.NewLimit(1, 10000)
// Run unit tests.
code := m.Run()

View file

@ -55,7 +55,7 @@ func OIDCLogin(router *gin.RouterGroup) {
// Abort if failure rate limit is exceeded.
if r.Reject() || limiter.Auth.Reject(clientIp) {
c.HTML(http.StatusTooManyRequests, "auth.gohtml", CreateSessionError(http.StatusTooManyRequests, i18n.Error(i18n.ErrTooManyRequests)))
c.HTML(http.StatusTooManyRequests, "auth.gohtml", CreateSessionError(http.StatusTooManyRequests, i18n.ErrTooManyRequests))
return
}
@ -64,7 +64,7 @@ func OIDCLogin(router *gin.RouterGroup) {
if provider == nil {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrInvalidProviderConfiguration.Error()})
c.HTML(http.StatusInternalServerError, "auth.gohtml", CreateSessionError(http.StatusInternalServerError, i18n.Error(i18n.ErrConnectionFailed)))
c.HTML(http.StatusInternalServerError, "auth.gohtml", CreateSessionError(http.StatusInternalServerError, i18n.ErrConnectionFailed))
return
}

View file

@ -100,7 +100,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
// Abort if failure rate limit is exceeded.
if r.Reject() || limiter.Auth.Reject(clientIp) {
c.HTML(http.StatusTooManyRequests, "auth.gohtml", CreateSessionError(http.StatusTooManyRequests, i18n.Error(i18n.ErrTooManyRequests)))
c.HTML(http.StatusTooManyRequests, "auth.gohtml", CreateSessionError(http.StatusTooManyRequests, i18n.ErrTooManyRequests))
return
}
@ -108,7 +108,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
// access_denied). Surface it in the instance's branded UI, not a silent bounce.
if oauthErr := c.Query("error"); oauthErr != "" {
event.AuditWarn([]string{clientIp, "create session", "oidc", "provider returned error", clean.Log(oauthErr), clean.Log(c.Query("error_description"))})
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(oidcRedirectErrorMessage(oauthErr))))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, oidcRedirectErrorMessage(oauthErr)))
return
}
@ -124,7 +124,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
if provider == nil {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrInvalidProviderConfiguration.Error()})
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
}
@ -137,7 +137,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
// raw, dead-end error with no way forward.
event.AuditErr([]string{clientIp, "create session", "oidc", claimErr.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, claimErr.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrUnexpected)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrUnexpected))
return
}
@ -171,13 +171,13 @@ func OIDCRedirect(router *gin.RouterGroup) {
message := "IdP omitted some or all groups; cannot validate required groups"
event.AuditErr([]string{clientIp, "create session", "oidc", message})
event.LoginError(clientIp, "oidc", userName, userAgent, message)
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrForbidden))
return
case !oidc.HasAnyGroup(groups, requiredGroups):
message := "missing required group membership"
event.AuditErr([]string{clientIp, "create session", "oidc", message})
event.LoginError(clientIp, "oidc", userName, userAgent, message)
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrForbidden))
return
}
}
@ -205,13 +205,13 @@ func OIDCRedirect(router *gin.RouterGroup) {
} else if _, emailDomain, _ := strings.Cut(userEmail, "@"); emailDomain == "" || !userInfo.EmailVerified {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrVerifiedEmailRequired.Error()})
event.LoginError(clientIp, "oidc", userEmail, userAgent, authn.ErrVerifiedEmailRequired.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrVerifiedEmailRequired))
return
} else if !strings.HasSuffix("."+emailDomain, "."+domain) {
message := fmt.Sprintf("domain must match '%s'", domain)
event.AuditErr([]string{clientIp, "create session", "oidc", userEmail, message})
event.LoginError(clientIp, "oidc", userEmail, userAgent, message)
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrForbidden)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrForbidden))
return
}
@ -219,19 +219,19 @@ func OIDCRedirect(router *gin.RouterGroup) {
if oidcUser := entity.OidcUser(userInfo, provider.Issuer(), oidc.Username(userInfo, conf.OIDCUsername())); authn.ProviderOIDC.NotEqual(oidcUser.AuthProvider) {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrAuthProviderIsNotOIDC.Error()})
event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrAuthProviderIsNotOIDC.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
} else if oidcUser.UserName == "" {
event.AuditErr([]string{clientIp, "create session", "oidc", authn.ErrUsernameRequiredToRegister.Error()})
event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrUsernameRequiredToRegister.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
} else if user = entity.FindUser(oidcUser); user != nil {
// Ensure user has a username.
if user.Username() == "" {
event.AuditErr([]string{clientIp, "create session", "oidc", oidcUser.UserName, authn.ErrUsernameRequired.Error()})
event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrUsernameRequired.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
}
@ -243,7 +243,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
case !user.CanLogIn():
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountDisabled.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountDisabled.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
case authn.ProviderOIDC.NotEqual(user.AuthProvider):
// Claimable collision: a non-OIDC account matched the login. Log the
@ -253,12 +253,12 @@ func OIDCRedirect(router *gin.RouterGroup) {
hint := oidcReconcileHint(userName, user.AuthProvider, oidcUser.AuthID)
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAuthProviderIsNotOIDC.Error(), hint})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAuthProviderIsNotOIDC.Error()+": "+hint)
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
case user.AuthID == "" || oidcUser.AuthID == "" || user.AuthID != oidcUser.AuthID:
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrInvalidAuthID.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrInvalidAuthID.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
}
@ -340,7 +340,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
if err = user.Save(); err != nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountUpdateFailed.Error(), err.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountUpdateFailed.Error()+" ("+err.Error()+")")
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
}
@ -354,7 +354,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
userName = oidcUser.Username()
event.AuditWarn([]string{clientIp, "create session", "oidc", "create user", userName, authn.ErrUsersQuotaExceeded.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrUsersQuotaExceeded.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrQuotaExceeded)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrQuotaExceeded))
return
} else if conf.OIDCRegister() {
// Create new user record.
@ -430,12 +430,12 @@ func OIDCRedirect(router *gin.RouterGroup) {
if err = user.Create(); err != nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountCreateFailed.Error(), err.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountCreateFailed.Error()+" ("+err.Error()+")")
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
} else if err = user.UpdateAuthID(userInfo.Subject, provider.Issuer()); err != nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountUpdateFailed.Error(), err.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountUpdateFailed.Error()+" ("+err.Error()+")")
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
}
@ -448,7 +448,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
} else {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrRegistrationDisabled.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrRegistrationDisabled.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrRegistrationDisabled))
return
}
@ -456,7 +456,7 @@ func OIDCRedirect(router *gin.RouterGroup) {
if !user.CanLogIn() {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, authn.ErrAccountDisabled.Error()})
event.LoginError(clientIp, "oidc", userName, userAgent, authn.ErrAccountDisabled.Error())
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
}
@ -495,11 +495,11 @@ func OIDCRedirect(router *gin.RouterGroup) {
// Save session after successful authentication.
if sess, err = get.Session().Save(sess); err != nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, status.Error(err)})
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrInvalidCredentials)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrInvalidCredentials))
return
} else if sess == nil {
event.AuditErr([]string{clientIp, "create session", "oidc", userName, status.Failed})
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.Error(i18n.ErrUnexpected)))
c.HTML(http.StatusUnauthorized, "auth.gohtml", CreateSessionError(http.StatusUnauthorized, i18n.ErrUnexpected))
return
}

View file

@ -129,6 +129,10 @@ func TestOIDCRedirect_ProviderError(t *testing.T) {
// auth.gohtml renders the failed-status branch and stores the branded message.
assert.Contains(t, body, `setItem("session.error"`)
assert.Contains(t, body, i18n.Error(i18n.ErrForbidden).Error())
// The page also carries the message key (messageId) so the Web UI can render
// it in the current UI locale; notify.vue applies Tp to the source string.
assert.Contains(t, body, `setItem("session.messageId"`)
assert.Contains(t, body, i18n.Source(i18n.ErrForbidden))
}
// extractTemplateList returns the template source between the provided markers.

View file

@ -110,13 +110,15 @@ func CreateSession(router *gin.RouterGroup) {
if err = sess.LogIn(frm, c); err != nil {
switch {
case sess.GetMethod().IsNot(authn.Method2FA):
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
Abort(c, sess.HttpStatus(), i18n.ErrInvalidCredentials)
case errors.Is(err, authn.ErrPasscodeRequired):
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error(), "code": 32, "message": i18n.Msg(i18n.ErrPasscodeRequired)})
// Code 32 asks the client to enter a 2FA passcode (a continuation request,
// not a failure), so the text goes in "message" (code < 400) with messageId.
c.AbortWithStatusJSON(http.StatusUnauthorized, i18n.NewResponse(32, i18n.ErrPasscodeRequired))
// Return the reserved request rate limit tokens if password is correct, even if the verification code is missing.
r.Success()
default:
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error(), "code": http.StatusUnauthorized, "message": i18n.Msg(i18n.ErrInvalidPasscode)})
Abort(c, http.StatusUnauthorized, i18n.ErrInvalidPasscode)
}
return
}
@ -131,10 +133,10 @@ func CreateSession(router *gin.RouterGroup) {
switch saved, saveErr := get.Session().Save(sess); {
case saveErr != nil:
event.AuditErr([]string{clientIp, status.Error(saveErr)})
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
Abort(c, sess.HttpStatus(), i18n.ErrInvalidCredentials)
return
case saved == nil:
c.AbortWithStatusJSON(sess.HttpStatus(), gin.H{"error": i18n.Msg(i18n.ErrUnexpected)})
Abort(c, sess.HttpStatus(), i18n.ErrUnexpected)
return
case isNew:
event.AuditInfo([]string{clientIp, "session %s", "created"}, saved.RefID)

View file

@ -6,6 +6,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/i18n"
)
// CreateSessionResponse returns the authentication response data for POST requests
@ -14,13 +15,18 @@ func CreateSessionResponse(authToken string, sess *entity.Session, conf *config.
return GetSessionResponse(authToken, sess, conf)
}
// CreateSessionError returns an authentication error response.
func CreateSessionError(code int, err error) gin.H {
// CreateSessionError returns an authentication error response. "error" is the
// instance-locale fallback; "messageId" (the English source) and "messageParams"
// let the Web UI render the message in the current UI locale.
func CreateSessionError(code int, id i18n.Message, params ...any) gin.H {
resp := i18n.NewResponse(code, id, params...)
return gin.H{
"status": StatusFailed,
"code": code,
"error": err.Error(),
"config": get.Config().ClientPublic(),
"status": StatusFailed,
"code": resp.Code,
"error": resp.Error,
"messageId": resp.MessageID,
"messageParams": resp.MessageParams,
"config": get.Config().ClientPublic(),
}
}

View file

@ -191,6 +191,7 @@ func TestCreateSession(t *testing.T) {
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrInvalidCredentials), val.String())
assert.Equal(t, i18n.Source(i18n.ErrInvalidCredentials), gjson.Get(r.Body.String(), "messageId").String())
assert.Equal(t, http.StatusUnauthorized, r.Code)
})
t.Run("AliceSuccess", func(t *testing.T) {
@ -231,6 +232,7 @@ func TestCreateSession(t *testing.T) {
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"username": "bob", "password": "helloworld"}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrInvalidCredentials), val.String())
assert.Equal(t, i18n.Source(i18n.ErrInvalidCredentials), gjson.Get(r.Body.String(), "messageId").String())
assert.Equal(t, http.StatusUnauthorized, r.Code)
})
t.Run("TwoFaPasscodeRequired", func(t *testing.T) {
@ -246,6 +248,11 @@ func TestCreateSession(t *testing.T) {
assert.Equal(t, "", userEmail.String())
assert.Equal(t, "", userName.String())
assert.Equal(t, http.StatusUnauthorized, r.Code)
// Code 32 is a continuation request, not an error: the text is in "message", not "error".
assert.Equal(t, int64(32), gjson.Get(r.Body.String(), "code").Int())
assert.Equal(t, i18n.Msg(i18n.ErrPasscodeRequired), gjson.Get(r.Body.String(), "message").String())
assert.Empty(t, gjson.Get(r.Body.String(), "error").String())
assert.Equal(t, i18n.Source(i18n.ErrPasscodeRequired), gjson.Get(r.Body.String(), "messageId").String())
})
t.Run("TwoFaInvalidPasscode", func(t *testing.T) {
app, router, conf := NewApiTest()

View file

@ -94,6 +94,10 @@ func TestSource(t *testing.T) {
t.Run("WithoutPlaceholder", func(t *testing.T) {
assert.Equal(t, "Permission denied", Source(ErrForbidden))
})
t.Run("AuthErrorMessages", func(t *testing.T) {
assert.Equal(t, "Registration disabled", Source(ErrRegistrationDisabled))
assert.Equal(t, "Verified email required", Source(ErrVerifiedEmailRequired))
})
t.Run("UntranslatedAfterSetLocale", func(t *testing.T) {
SetLocale("de")
assert.Equal(t, "%s already exists", Source(ErrAlreadyExists))

View file

@ -55,6 +55,8 @@ const (
ErrTooManyRequests
ErrInsufficientStorage
ErrQuotaExceeded
ErrRegistrationDisabled
ErrVerifiedEmailRequired
MsgChangesSaved
MsgAlbumCreated
@ -107,57 +109,59 @@ const (
// Messages holds default English message strings.
var Messages = MessageMap{
// Error messages:
ErrUnexpected: gettext("Something went wrong, try again"),
ErrBadRequest: gettext("Unable to do that"),
ErrSaveFailed: gettext("Changes could not be saved"),
ErrDeleteFailed: gettext("Could not be deleted"),
ErrAlreadyExists: gettext("%s already exists"),
ErrNotFound: gettext("Not found"),
ErrFileNotFound: gettext("File not found"),
ErrFileTooLarge: gettext("File too large"),
ErrUnsupported: gettext("Unsupported"),
ErrUnsupportedType: gettext("Unsupported type"),
ErrUnsupportedFormat: gettext("Unsupported format"),
ErrOriginalsEmpty: gettext("Originals folder is empty"),
ErrSelectionNotFound: gettext("Selection not found"),
ErrEntityNotFound: gettext("Entity not found"),
ErrAccountNotFound: gettext("Account not found"),
ErrUserNotFound: gettext("User not found"),
ErrLabelNotFound: gettext("Label not found"),
ErrCameraNotFound: gettext("Camera not found"),
ErrLensNotFound: gettext("Lens not found"),
ErrAlbumNotFound: gettext("Album not found"),
ErrSubjectNotFound: gettext("Subject not found"),
ErrPersonNotFound: gettext("Person not found"),
ErrFaceNotFound: gettext("Face not found"),
ErrPublic: gettext("Not available in public mode"),
ErrReadOnly: gettext("Not available in read-only mode"),
ErrUnauthorized: gettext("Please log in to your account"),
ErrForbidden: gettext("Permission denied"),
ErrPaymentRequired: gettext("Payment required"),
ErrOffensiveUpload: gettext("Upload might be offensive"),
ErrUploadFailed: gettext("Upload failed"),
ErrNoItemsSelected: gettext("No items selected"),
ErrCreateFile: gettext("Failed creating file, please check permissions"),
ErrCreateFolder: gettext("Failed creating folder, please check permissions"),
ErrConnectionFailed: gettext("Could not connect, please try again"),
ErrPasscodeRequired: gettext("Enter verification code"),
ErrInvalidPasscode: gettext("Invalid verification code, please try again"),
ErrInvalidPassword: gettext("Invalid password, please try again"),
ErrFeatureDisabled: gettext("Feature disabled"),
ErrNoLabelsSelected: gettext("No labels selected"),
ErrNoAlbumsSelected: gettext("No albums selected"),
ErrNoFilesForDownload: gettext("No files available for download"),
ErrZipFailed: gettext("Failed to create zip file"),
ErrInvalidCredentials: gettext("Invalid credentials"),
ErrInvalidLink: gettext("Invalid link"),
ErrInvalidName: gettext("Invalid name"),
ErrBusy: gettext("Busy, please try again later"),
ErrWakeupInterval: gettext("The wakeup interval is %s, but must be 1h or less"),
ErrAccountConnect: gettext("Your account could not be connected"),
ErrTooManyRequests: gettext("Too many requests"),
ErrInsufficientStorage: gettext("Insufficient storage"),
ErrQuotaExceeded: gettext("Quota exceeded"),
ErrUnexpected: gettext("Something went wrong, try again"),
ErrBadRequest: gettext("Unable to do that"),
ErrSaveFailed: gettext("Changes could not be saved"),
ErrDeleteFailed: gettext("Could not be deleted"),
ErrAlreadyExists: gettext("%s already exists"),
ErrNotFound: gettext("Not found"),
ErrFileNotFound: gettext("File not found"),
ErrFileTooLarge: gettext("File too large"),
ErrUnsupported: gettext("Unsupported"),
ErrUnsupportedType: gettext("Unsupported type"),
ErrUnsupportedFormat: gettext("Unsupported format"),
ErrOriginalsEmpty: gettext("Originals folder is empty"),
ErrSelectionNotFound: gettext("Selection not found"),
ErrEntityNotFound: gettext("Entity not found"),
ErrAccountNotFound: gettext("Account not found"),
ErrUserNotFound: gettext("User not found"),
ErrLabelNotFound: gettext("Label not found"),
ErrCameraNotFound: gettext("Camera not found"),
ErrLensNotFound: gettext("Lens not found"),
ErrAlbumNotFound: gettext("Album not found"),
ErrSubjectNotFound: gettext("Subject not found"),
ErrPersonNotFound: gettext("Person not found"),
ErrFaceNotFound: gettext("Face not found"),
ErrPublic: gettext("Not available in public mode"),
ErrReadOnly: gettext("Not available in read-only mode"),
ErrUnauthorized: gettext("Please log in to your account"),
ErrForbidden: gettext("Permission denied"),
ErrPaymentRequired: gettext("Payment required"),
ErrOffensiveUpload: gettext("Upload might be offensive"),
ErrUploadFailed: gettext("Upload failed"),
ErrNoItemsSelected: gettext("No items selected"),
ErrCreateFile: gettext("Failed creating file, please check permissions"),
ErrCreateFolder: gettext("Failed creating folder, please check permissions"),
ErrConnectionFailed: gettext("Could not connect, please try again"),
ErrPasscodeRequired: gettext("Enter verification code"),
ErrInvalidPasscode: gettext("Invalid verification code, please try again"),
ErrInvalidPassword: gettext("Invalid password, please try again"),
ErrFeatureDisabled: gettext("Feature disabled"),
ErrNoLabelsSelected: gettext("No labels selected"),
ErrNoAlbumsSelected: gettext("No albums selected"),
ErrNoFilesForDownload: gettext("No files available for download"),
ErrZipFailed: gettext("Failed to create zip file"),
ErrInvalidCredentials: gettext("Invalid credentials"),
ErrInvalidLink: gettext("Invalid link"),
ErrInvalidName: gettext("Invalid name"),
ErrBusy: gettext("Busy, please try again later"),
ErrWakeupInterval: gettext("The wakeup interval is %s, but must be 1h or less"),
ErrAccountConnect: gettext("Your account could not be connected"),
ErrTooManyRequests: gettext("Too many requests"),
ErrInsufficientStorage: gettext("Insufficient storage"),
ErrQuotaExceeded: gettext("Quota exceeded"),
ErrRegistrationDisabled: gettext("Registration disabled"),
ErrVerifiedEmailRequired: gettext("Verified email required"),
// Info and confirmation messages:
MsgChangesSaved: gettext("Changes successfully saved"),