OIDC: Allow to use nickname as username #782

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2024-07-05 10:47:09 +02:00
parent ad581aff4b
commit fbb0284efa
12 changed files with 222 additions and 160 deletions

View file

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-03-22 14:48+0000\n"
"POT-Creation-Date: 2024-07-05 07:50+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -17,383 +17,387 @@ msgstr ""
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"
#: messages.go:100
#: messages.go:101
msgid "Something went wrong, try again"
msgstr ""
#: messages.go:101
#: messages.go:102
msgid "Unable to do that"
msgstr ""
#: messages.go:102
msgid "Changes could not be saved"
msgstr ""
#: messages.go:103
msgid "Could not be deleted"
msgid "Too many requests"
msgstr ""
#: messages.go:104
msgid "Changes could not be saved"
msgstr ""
#: messages.go:105
msgid "Could not be deleted"
msgstr ""
#: messages.go:106
#, c-format
msgid "%s already exists"
msgstr ""
#: messages.go:105
#: messages.go:107
msgid "Not found"
msgstr ""
#: messages.go:106
#: messages.go:108
msgid "File not found"
msgstr ""
#: messages.go:107
#: messages.go:109
msgid "File too large"
msgstr ""
#: messages.go:108
#: messages.go:110
msgid "Unsupported"
msgstr ""
#: messages.go:109
#: messages.go:111
msgid "Unsupported type"
msgstr ""
#: messages.go:110
#: messages.go:112
msgid "Unsupported format"
msgstr ""
#: messages.go:111
#: messages.go:113
msgid "Originals folder is empty"
msgstr ""
#: messages.go:112
#: messages.go:114
msgid "Selection not found"
msgstr ""
#: messages.go:113
#: messages.go:115
msgid "Entity not found"
msgstr ""
#: messages.go:114
#: messages.go:116
msgid "Account not found"
msgstr ""
#: messages.go:115
#: messages.go:117
msgid "User not found"
msgstr ""
#: messages.go:116
#: messages.go:118
msgid "Label not found"
msgstr ""
#: messages.go:117
#: messages.go:119
msgid "Album not found"
msgstr ""
#: messages.go:118
#: messages.go:120
msgid "Subject not found"
msgstr ""
#: messages.go:119
#: messages.go:121
msgid "Person not found"
msgstr ""
#: messages.go:120
#: messages.go:122
msgid "Face not found"
msgstr ""
#: messages.go:121
#: messages.go:123
msgid "Not available in public mode"
msgstr ""
#: messages.go:122
#: messages.go:124
msgid "Not available in read-only mode"
msgstr ""
#: messages.go:123
#: messages.go:125
msgid "Please log in to your account"
msgstr ""
#: messages.go:124
#: messages.go:126
msgid "Permission denied"
msgstr ""
#: messages.go:125
#: messages.go:127
msgid "Upload might be offensive"
msgstr ""
#: messages.go:126
#: messages.go:128
msgid "Upload failed"
msgstr ""
#: messages.go:127
#: messages.go:129
msgid "No items selected"
msgstr ""
#: messages.go:128
#: messages.go:130
msgid "Failed creating file, please check permissions"
msgstr ""
#: messages.go:129
#: messages.go:131
msgid "Failed creating folder, please check permissions"
msgstr ""
#: messages.go:130
#: messages.go:132
msgid "Could not connect, please try again"
msgstr ""
#: messages.go:131
#: messages.go:133
msgid "Enter verification code"
msgstr ""
#: messages.go:132
#: messages.go:134
msgid "Invalid verification code, please try again"
msgstr ""
#: messages.go:133
#: messages.go:135
msgid "Invalid password, please try again"
msgstr ""
#: messages.go:134
#: messages.go:136
msgid "Feature disabled"
msgstr ""
#: messages.go:135
#: messages.go:137
msgid "No labels selected"
msgstr ""
#: messages.go:136
#: messages.go:138
msgid "No albums selected"
msgstr ""
#: messages.go:137
#: messages.go:139
msgid "No files available for download"
msgstr ""
#: messages.go:138
#: messages.go:140
msgid "Failed to create zip file"
msgstr ""
#: messages.go:139
#: messages.go:141
msgid "Invalid credentials"
msgstr ""
#: messages.go:140
#: messages.go:142
msgid "Invalid link"
msgstr ""
#: messages.go:141
#: messages.go:143
msgid "Invalid name"
msgstr ""
#: messages.go:142
#: messages.go:144
msgid "Busy, please try again later"
msgstr ""
#: messages.go:143
#: messages.go:145
#, c-format
msgid "The wakeup interval is %s, but must be 1h or less"
msgstr ""
#: messages.go:144
#: messages.go:146
msgid "Your account could not be connected"
msgstr ""
#: messages.go:147
#: messages.go:149
msgid "Changes successfully saved"
msgstr ""
#: messages.go:148
#: messages.go:150
msgid "Album created"
msgstr ""
#: messages.go:149
#: messages.go:151
msgid "Album saved"
msgstr ""
#: messages.go:150
#: messages.go:152
#, c-format
msgid "Album %s deleted"
msgstr ""
#: messages.go:151
#: messages.go:153
msgid "Album contents cloned"
msgstr ""
#: messages.go:152
#: messages.go:154
msgid "File removed from stack"
msgstr ""
#: messages.go:153
msgid "File deleted"
msgstr ""
#: messages.go:154
#, c-format
msgid "Selection added to %s"
msgstr ""
#: messages.go:155
#, c-format
msgid "One entry added to %s"
msgid "File deleted"
msgstr ""
#: messages.go:156
#, c-format
msgid "%d entries added to %s"
msgid "Selection added to %s"
msgstr ""
#: messages.go:157
#, c-format
msgid "One entry removed from %s"
msgid "One entry added to %s"
msgstr ""
#: messages.go:158
#, c-format
msgid "%d entries removed from %s"
msgid "%d entries added to %s"
msgstr ""
#: messages.go:159
msgid "Account created"
#, c-format
msgid "One entry removed from %s"
msgstr ""
#: messages.go:160
msgid "Account saved"
#, c-format
msgid "%d entries removed from %s"
msgstr ""
#: messages.go:161
msgid "Account deleted"
msgid "Account created"
msgstr ""
#: messages.go:162
msgid "Settings saved"
msgid "Account saved"
msgstr ""
#: messages.go:163
msgid "Password changed"
msgid "Account deleted"
msgstr ""
#: messages.go:164
#, c-format
msgid "Import completed in %d s"
msgid "Settings saved"
msgstr ""
#: messages.go:165
msgid "Import canceled"
msgid "Password changed"
msgstr ""
#: messages.go:166
#, c-format
msgid "Indexing completed in %d s"
msgid "Import completed in %d s"
msgstr ""
#: messages.go:167
msgid "Indexing originals..."
msgid "Import canceled"
msgstr ""
#: messages.go:168
#, c-format
msgid "Indexing files in %s"
msgid "Indexing completed in %d s"
msgstr ""
#: messages.go:169
msgid "Indexing canceled"
msgid "Indexing originals..."
msgstr ""
#: messages.go:170
#, c-format
msgid "Removed %d files and %d photos"
msgid "Indexing files in %s"
msgstr ""
#: messages.go:171
#, c-format
msgid "Moving files from %s"
msgid "Indexing canceled"
msgstr ""
#: messages.go:172
#, c-format
msgid "Copying files from %s"
msgid "Removed %d files and %d photos"
msgstr ""
#: messages.go:173
msgid "Labels deleted"
#, c-format
msgid "Moving files from %s"
msgstr ""
#: messages.go:174
msgid "Label saved"
#, c-format
msgid "Copying files from %s"
msgstr ""
#: messages.go:175
msgid "Subject saved"
msgid "Labels deleted"
msgstr ""
#: messages.go:176
msgid "Subject deleted"
msgid "Label saved"
msgstr ""
#: messages.go:177
msgid "Person saved"
msgid "Subject saved"
msgstr ""
#: messages.go:178
msgid "Person deleted"
msgid "Subject deleted"
msgstr ""
#: messages.go:179
msgid "File uploaded"
msgid "Person saved"
msgstr ""
#: messages.go:180
msgid "Person deleted"
msgstr ""
#: messages.go:181
msgid "File uploaded"
msgstr ""
#: messages.go:182
#, c-format
msgid "%d files uploaded in %d s"
msgstr ""
#: messages.go:181
#: messages.go:183
msgid "Processing upload..."
msgstr ""
#: messages.go:182
#: messages.go:184
msgid "Upload has been processed"
msgstr ""
#: messages.go:183
#: messages.go:185
msgid "Selection approved"
msgstr ""
#: messages.go:184
#: messages.go:186
msgid "Selection archived"
msgstr ""
#: messages.go:185
#: messages.go:187
msgid "Selection restored"
msgstr ""
#: messages.go:186
#: messages.go:188
msgid "Selection marked as private"
msgstr ""
#: messages.go:187
msgid "Albums deleted"
msgstr ""
#: messages.go:188
#, c-format
msgid "Zip created in %d s"
msgstr ""
#: messages.go:189
msgid "Permanently deleted"
msgid "Albums deleted"
msgstr ""
#: messages.go:190
#, c-format
msgid "%s has been restored"
msgid "Zip created in %d s"
msgstr ""
#: messages.go:191
msgid "Successfully verified"
msgid "Permanently deleted"
msgstr ""
#: messages.go:192
#, c-format
msgid "%s has been restored"
msgstr ""
#: messages.go:193
msgid "Successfully verified"
msgstr ""
#: messages.go:194
msgid "Successfully activated"
msgstr ""

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/><path d="M1 1h22v22H1z" fill="none"/></svg>

After

Width:  |  Height:  |  Size: 742 B

View file

@ -37,11 +37,11 @@ func OIDCLogin(router *gin.RouterGroup) {
// Abort in public mode and if OIDC is disabled.
if get.Config().Public() {
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrDisabledInPublicMode.Error()})
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
return
} else if !conf.OIDCEnabled() {
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrAuthenticationDisabled.Error()})
Abort(c, http.StatusMethodNotAllowed, i18n.ErrUnsupported)
c.Redirect(http.StatusTemporaryRedirect, conf.LoginUri())
return
}
@ -51,7 +51,7 @@ func OIDCLogin(router *gin.RouterGroup) {
// Abort if failure rate limit is exceeded.
if r.Reject() || limiter.Auth.Reject(clientIp) {
limiter.AbortJSON(c)
c.HTML(http.StatusTooManyRequests, "auth.gohtml", CreateSessionError(http.StatusTooManyRequests, i18n.Error(i18n.ErrTooManyRequests)))
return
}
@ -59,8 +59,8 @@ func OIDCLogin(router *gin.RouterGroup) {
provider := get.OIDC()
if provider == nil {
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrInvalidProvider.Error()})
Abort(c, http.StatusInternalServerError, i18n.ErrConnectionFailed)
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrInvalidProviderConfiguration.Error()})
c.HTML(http.StatusInternalServerError, "auth.gohtml", CreateSessionError(http.StatusInternalServerError, i18n.Error(i18n.ErrConnectionFailed)))
return
}

View file

@ -62,7 +62,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.ErrForbidden)))
c.HTML(http.StatusTooManyRequests, "auth.gohtml", CreateSessionError(http.StatusTooManyRequests, i18n.Error(i18n.ErrTooManyRequests)))
return
}
@ -112,9 +112,14 @@ func OIDCRedirect(router *gin.RouterGroup) {
}
// Find existing user record and update it, if necessary.
if oidcUser := entity.OidcUser(userInfo, conf.OIDCUsername()); oidcUser.UserName == "" || authn.ProviderOIDC.NotEqual(oidcUser.AuthProvider) {
event.AuditErr([]string{clientIp, "oidc", action, authn.ErrInvalidUsername.Error()})
event.LoginError(clientIp, "oidc", oidcUser.UserName, userAgent, authn.ErrInvalidUsername.Error())
if oidcUser := entity.OidcUser(userInfo, conf.OIDCUsername()); authn.ProviderOIDC.NotEqual(oidcUser.AuthProvider) {
event.AuditErr([]string{clientIp, "oidc", action, 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)))
return
} else if oidcUser.UserName == "" {
event.AuditErr([]string{clientIp, "oidc", action, 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)))
return
} else if user = entity.FindUser(oidcUser); user != nil {

View file

@ -95,13 +95,16 @@ func (c *Config) OIDCRegister() bool {
return c.options.OIDCRegister
}
// OIDCUsername returns the claim to use as username when signing up via OIDC.
// OIDCUsername returns the preferred username claim for new users signing up via OIDC.
func (c *Config) OIDCUsername() string {
if c.options.OIDCUsername == authn.ClaimEmail {
switch c.options.OIDCUsername {
case authn.ClaimEmail:
return authn.ClaimEmail
case authn.ClaimNickname:
return authn.ClaimNickname
default:
return authn.ClaimPreferredUsername
}
return authn.ClaimUsername
}
// OIDCDomain returns the email domain name for restricted single sign-on via OIDC.

View file

@ -113,15 +113,19 @@ func TestConfig_OIDCRedirect(t *testing.T) {
func TestConfig_OIDCUsername(t *testing.T) {
c := NewConfig(CliTestContext())
assert.Equal(t, authn.ClaimUsername, c.OIDCUsername())
assert.Equal(t, authn.ClaimPreferredUsername, c.OIDCUsername())
c.options.OIDCUsername = "email"
assert.Equal(t, authn.ClaimEmail, c.OIDCUsername())
c.options.OIDCUsername = "nickname"
assert.Equal(t, authn.ClaimNickname, c.OIDCUsername())
c.options.OIDCUsername = ""
assert.Equal(t, authn.ClaimUsername, c.OIDCUsername())
assert.Equal(t, authn.ClaimPreferredUsername, c.OIDCUsername())
}
func TestConfig_OIDCDomain(t *testing.T) {

View file

@ -46,7 +46,7 @@ var Flags = CliFlags{
}}, {
Flag: cli.StringFlag{
Name: "oidc-uri",
Usage: "identity provider `URI` for single sign-on via OpenID Connect, e.g. \"https://accounts.google.com/\"",
Usage: "identity provider issuer `URI` for single sign-on via OpenID Connect, e.g. \"https://accounts.google.com\"",
Value: "",
EnvVar: EnvVar("OIDC_URI"),
}}, {
@ -93,13 +93,13 @@ var Flags = CliFlags{
}}, {
Flag: cli.StringFlag{
Name: "oidc-username",
Usage: "username `CLAIM` for OpenID Connect users (preferred_username, email)",
Value: authn.ClaimUsername,
Usage: "preferred username `CLAIM` for new OpenID Connect users (preferred_username, email, nickname)",
Value: authn.ClaimPreferredUsername,
EnvVar: EnvVar("OIDC_USERNAME"),
}}, {
Flag: cli.BoolFlag{
Name: "oidc-webdav",
Usage: "enable WebDAV for new OpenID Connect users if their role allows it",
Usage: "allow new OpenID Connect users to use WebDAV when they have a role that allows it",
EnvVar: EnvVar("OIDC_WEBDAV"),
}}, {
Flag: cli.BoolFlag{

View file

@ -110,9 +110,35 @@ func OidcUser(userInfo oidc.UserInfo, usernameClaim string) User {
switch usernameClaim {
case authn.ClaimEmail:
userName = clean.Username(userInfo.GetEmail())
if name := clean.Email(userInfo.GetEmail()); userInfo.IsEmailVerified() && len(name) > 4 {
userName = name
} else if name = clean.Handle(userInfo.GetPreferredUsername()); len(name) > 0 {
userName = name
} else if name = clean.Handle(userInfo.GetName()); len(name) > 0 {
userName = name
} else if name = clean.Handle(userInfo.GetNickname()); len(name) > 0 {
userName = name
}
case authn.ClaimNickname:
if name := clean.Handle(userInfo.GetNickname()); len(name) > 0 {
userName = name
} else if name = clean.Handle(userInfo.GetPreferredUsername()); len(name) > 0 {
userName = name
} else if name = clean.Handle(userInfo.GetName()); len(name) > 0 {
userName = name
} else if name = clean.Email(userInfo.GetEmail()); userInfo.IsEmailVerified() && len(name) > 4 {
userName = name
}
default:
userName = clean.Username(userInfo.GetPreferredUsername())
if name := clean.Handle(userInfo.GetPreferredUsername()); len(name) > 0 {
userName = name
} else if name = clean.Email(userInfo.GetEmail()); userInfo.IsEmailVerified() && len(name) > 4 {
userName = name
} else if name = clean.Handle(userInfo.GetName()); len(name) > 0 {
userName = name
} else if name = clean.Handle(userInfo.GetNickname()); len(name) > 0 {
userName = name
}
}
userEmail = clean.Email(userInfo.GetEmail())

View file

@ -22,6 +22,36 @@ func TestNewUser(t *testing.T) {
}
func TestOidcUser(t *testing.T) {
t.Run("ClaimPreferredUsername", func(t *testing.T) {
info := oidc.NewUserInfo()
info.SetName("Jane")
info.SetFamilyName("Doe")
info.SetEmail("jane@doe.com", true)
info.SetSubject("abcd123")
info.SetPreferredUsername("Jane Doe")
m := OidcUser(info, authn.ClaimPreferredUsername)
assert.Equal(t, "oidc", m.AuthProvider)
assert.Equal(t, "abcd123", m.AuthID)
assert.Equal(t, "jane@doe.com", m.UserEmail)
assert.Equal(t, "jane.doe", m.UserName)
assert.Equal(t, "Jane", m.DisplayName)
})
t.Run("ClaimNickname", func(t *testing.T) {
info := oidc.NewUserInfo()
info.SetName("Jane")
info.SetFamilyName("Doe")
info.SetNickname("Jens Mander")
info.SetEmail("jane@doe.com", true)
info.SetSubject("abcd123")
m := OidcUser(info, authn.ClaimNickname)
assert.Equal(t, "oidc", m.AuthProvider)
assert.Equal(t, "abcd123", m.AuthID)
assert.Equal(t, "jane@doe.com", m.UserEmail)
assert.Equal(t, "jens.mander", m.UserName)
assert.Equal(t, "Jane", m.DisplayName)
})
t.Run("ClaimEmail", func(t *testing.T) {
info := oidc.NewUserInfo()
info.SetName("Jane")
@ -36,27 +66,12 @@ func TestOidcUser(t *testing.T) {
assert.Equal(t, "jane@doe.com", m.UserName)
assert.Equal(t, "Jane", m.DisplayName)
})
t.Run("ClaimUsername", func(t *testing.T) {
t.Run("EmptyAuthId", func(t *testing.T) {
info := oidc.NewUserInfo()
info.SetName("Jane")
info.SetFamilyName("Doe")
info.SetEmail("jane@doe.com", true)
info.SetSubject("abcd123")
info.SetPreferredUsername("Jane Doe")
m := OidcUser(info, authn.ClaimUsername)
assert.Equal(t, "oidc", m.AuthProvider)
assert.Equal(t, "abcd123", m.AuthID)
assert.Equal(t, "jane@doe.com", m.UserEmail)
assert.Equal(t, "jane doe", m.UserName)
assert.Equal(t, "Jane", m.DisplayName)
})
t.Run("AuthIdEmpty", func(t *testing.T) {
info := oidc.NewUserInfo()
info.SetName("Jane")
info.SetFamilyName("Doe")
info.SetEmail("jane@doe.com", true)
m := OidcUser(info, authn.ClaimUsername)
m := OidcUser(info, authn.ClaimPreferredUsername)
assert.Empty(t, m)
})

View file

@ -31,16 +31,17 @@ var (
// OIDC and OAuth2-related error messages:
var (
ErrInvalidProvider = errors.New("invalid provider")
ErrInvalidGrantType = errors.New("invalid grant type")
ErrInvalidClientID = errors.New("invalid client id")
ErrInvalidAuthID = errors.New("invalid auth id")
ErrAuthCodeRequired = errors.New("auth code required")
ErrClientIDRequired = errors.New("client id required")
ErrInvalidClientSecret = errors.New("invalid client secret")
ErrClientSecretRequired = errors.New("client secret required")
ErrVerifiedEmailRequired = errors.New("verified email required")
ErrRegistrationDisabled = errors.New("registration disabled")
ErrInvalidProviderConfiguration = errors.New("invalid provider configuration")
ErrInvalidGrantType = errors.New("invalid grant type")
ErrInvalidClientID = errors.New("invalid client id")
ErrInvalidAuthID = errors.New("invalid auth id")
ErrAuthProviderIsNotOIDC = errors.New("auth provider is not oidc")
ErrAuthCodeRequired = errors.New("auth code required")
ErrClientIDRequired = errors.New("client id required")
ErrInvalidClientSecret = errors.New("invalid client secret")
ErrClientSecretRequired = errors.New("client secret required")
ErrVerifiedEmailRequired = errors.New("verified email required")
ErrRegistrationDisabled = errors.New("registration disabled")
)
// User-related error messages:

View file

@ -1,7 +1,8 @@
package authn
const (
ClaimEmail = "email"
ClaimUsername = "preferred_username"
OidcScopes = "openid email profile"
ClaimPreferredUsername = "preferred_username"
ClaimEmail = "email"
ClaimNickname = "nickname"
OidcScopes = "openid email profile"
)

View file

@ -3,6 +3,7 @@ package i18n
const (
ErrUnexpected Message = iota + 1
ErrBadRequest
ErrTooManyRequests
ErrSaveFailed
ErrDeleteFailed
ErrAlreadyExists
@ -99,6 +100,7 @@ var Messages = MessageMap{
// Error messages:
ErrUnexpected: gettext("Something went wrong, try again"),
ErrBadRequest: gettext("Unable to do that"),
ErrTooManyRequests: gettext("Too many requests"),
ErrSaveFailed: gettext("Changes could not be saved"),
ErrDeleteFailed: gettext("Could not be deleted"),
ErrAlreadyExists: gettext("%s already exists"),