From 8371f94d06cefbd65392af3b5c0f1fd1057429f7 Mon Sep 17 00:00:00 2001 From: Antoine GIRARD Date: Wed, 26 Apr 2017 15:10:43 +0200 Subject: [PATCH] Rework SSH key management UI to add GPG (#1293) * Rework SSH key management UI to add GPG * Add more detail to gpg key display * Update CHANGELOG.md * Implement deletion UI * Implement adding gpg UI * Various fixes - Fix duplicate entry in locale - Re-generate hash before verification since they are consumed * Add missing translation * Split template * Catch not found/verified email error --- CHANGELOG.md | 2 + models/error.go | 30 ++++++ models/gpg_key.go | 33 ++++--- modules/auth/user_form.go | 7 +- options/locale/locale_en-US.ini | 17 +++- public/js/index.js | 6 +- routers/repo/setting.go | 2 +- routers/routes/routes.go | 8 +- routers/user/setting.go | 133 ++++++++++++++++++-------- templates/user/settings/keys.tmpl | 12 +++ templates/user/settings/keys_gpg.tmpl | 67 +++++++++++++ templates/user/settings/keys_ssh.tmpl | 67 +++++++++++++ templates/user/settings/navbar.tmpl | 4 +- templates/user/settings/sshkeys.tmpl | 74 -------------- 14 files changed, 323 insertions(+), 139 deletions(-) create mode 100644 templates/user/settings/keys.tmpl create mode 100644 templates/user/settings/keys_gpg.tmpl create mode 100644 templates/user/settings/keys_ssh.tmpl delete mode 100644 templates/user/settings/sshkeys.tmpl diff --git a/CHANGELOG.md b/CHANGELOG.md index 13d530294..0ff4b3c01 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * BREAKING * Password reset URL changed from `/user/forget_password` to `/user/forgot_password` + * SSH keys management URL changed from `/user/settings/ssh` to `/user/settings/keys` + ## [1.1.0](https://github.com/go-gitea/gitea/releases/tag/v1.1.0) - 2017-03-09 diff --git a/models/error.go b/models/error.go index 68bc23890..404939c58 100644 --- a/models/error.go +++ b/models/error.go @@ -260,6 +260,36 @@ func (err ErrKeyNameAlreadyUsed) Error() string { return fmt.Sprintf("public key already exists [owner_id: %d, name: %s]", err.OwnerID, err.Name) } +// ErrGPGEmailNotFound represents a "ErrGPGEmailNotFound" kind of error. +type ErrGPGEmailNotFound struct { + Email string +} + +// IsErrGPGEmailNotFound checks if an error is a ErrGPGEmailNotFound. +func IsErrGPGEmailNotFound(err error) bool { + _, ok := err.(ErrGPGEmailNotFound) + return ok +} + +func (err ErrGPGEmailNotFound) Error() string { + return fmt.Sprintf("failed to found email or is not confirmed : %s", err.Email) +} + +// ErrGPGKeyParsing represents a "ErrGPGKeyParsing" kind of error. +type ErrGPGKeyParsing struct { + ParseError error +} + +// IsErrGPGKeyParsing checks if an error is a ErrGPGKeyParsing. +func IsErrGPGKeyParsing(err error) bool { + _, ok := err.(ErrGPGKeyParsing) + return ok +} + +func (err ErrGPGKeyParsing) Error() string { + return fmt.Sprintf("failed to parse gpg key %s", err.ParseError.Error()) +} + // ErrGPGKeyNotExist represents a "GPGKeyNotExist" kind of error. type ErrGPGKeyNotExist struct { ID int64 diff --git a/models/gpg_key.go b/models/gpg_key.go index 1c9d17d0e..862bd8859 100644 --- a/models/gpg_key.go +++ b/models/gpg_key.go @@ -89,7 +89,7 @@ func GetGPGKeyByID(keyID int64) (*GPGKey, error) { func checkArmoredGPGKeyString(content string) (*openpgp.Entity, error) { list, err := openpgp.ReadArmoredKeyRing(strings.NewReader(content)) if err != nil { - return nil, err + return nil, ErrGPGKeyParsing{err} } return list[0], nil } @@ -219,7 +219,7 @@ func parseGPGKey(ownerID int64, e *openpgp.Entity) (*GPGKey, error) { } } if emails[n] == nil { - return nil, fmt.Errorf("Failed to found email or is not confirmed : %s", ident.UserId.Email) + return nil, ErrGPGEmailNotFound{ident.UserId.Email} } n++ } @@ -400,17 +400,16 @@ func ParseCommitWithSignature(c *git.Commit) *CommitVerification { } } - //Generating hash of commit - hash, err := populateHash(sig.Hash, []byte(c.Signature.Payload)) - if err != nil { //Skipping ailed to generate hash - log.Error(3, "PopulateHash: %v", err) - return &CommitVerification{ - Verified: false, - Reason: "gpg.error.generate_hash", - } - } - for _, k := range keys { + //Generating hash of commit + hash, err := populateHash(sig.Hash, []byte(c.Signature.Payload)) + if err != nil { //Skipping ailed to generate hash + log.Error(3, "PopulateHash: %v", err) + return &CommitVerification{ + Verified: false, + Reason: "gpg.error.generate_hash", + } + } //We get PK if err := verifySign(sig, hash, k); err == nil { return &CommitVerification{ //Everything is ok @@ -422,6 +421,16 @@ func ParseCommitWithSignature(c *git.Commit) *CommitVerification { } //And test also SubsKey for _, sk := range k.SubsKey { + + //Generating hash of commit + hash, err := populateHash(sig.Hash, []byte(c.Signature.Payload)) + if err != nil { //Skipping ailed to generate hash + log.Error(3, "PopulateHash: %v", err) + return &CommitVerification{ + Verified: false, + Reason: "gpg.error.generate_hash", + } + } if err := verifySign(sig, hash, sk); err == nil { return &CommitVerification{ //Everything is ok Verified: true, diff --git a/modules/auth/user_form.go b/modules/auth/user_form.go index 2f767d4c8..9e19c78a4 100644 --- a/modules/auth/user_form.go +++ b/modules/auth/user_form.go @@ -163,14 +163,15 @@ func (f *AddOpenIDForm) Validate(ctx *macaron.Context, errs binding.Errors) bind return validate(errs, ctx.Data, f, ctx.Locale) } -// AddSSHKeyForm form for adding SSH key -type AddSSHKeyForm struct { +// AddKeyForm form for adding SSH/GPG key +type AddKeyForm struct { + Type string `binding:"OmitEmpty"` Title string `binding:"Required;MaxSize(50)"` Content string `binding:"Required"` } // Validate validates the fields -func (f *AddSSHKeyForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { +func (f *AddKeyForm) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors { return validate(errs, ctx.Data, f, ctx.Locale) } diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 04b5f44d2..160f8e7ab 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -258,6 +258,7 @@ user_not_exist = The given user does not exist. last_org_owner = Removing the last user from a owner team isn't allowed because there must always be at least one owner in any given organization. invalid_ssh_key = Sorry, we're not able to verify your SSH key: %s +invalid_gpg_key = Sorry, we're not able to verify your GPG key: %s unable_verify_ssh_key = Gitea cannot verify your SSH key, but we are assuming that it is valid, please double-check it. auth_failed = Authentication failed: %v @@ -285,7 +286,7 @@ form.name_pattern_not_allowed = Username pattern '%s' is not allowed. profile = Profile password = Password avatar = Avatar -ssh_keys = SSH Keys +ssh_gpg_keys = SSH / GPG Keys social = Social Accounts applications = Applications orgs = Organizations @@ -349,20 +350,33 @@ keep_email_private_popup = Your email address will be hidden from other users if openid_desc = Your OpenID addresses will let you delegate authentication to your provider of choice manage_ssh_keys = Manage SSH Keys +manage_gpg_keys = Manage GPG Keys add_key = Add Key ssh_desc = This is a list of SSH keys associated with your account. Because these keys allow anyone using them to gain access to your repositories, it is highly important that you make sure you recognize them. +gpg_desc = This is a list of GPG keys associated with your account. Because these keys allow verification of commit, it is highly important that you keep safe the corresponding private key. ssh_helper = Don't know how? Check out GitHub's guide to create your own SSH keys or solve common problems you might encounter using SSH. +gpg_helper = Don't know how? Check out GitHub's guide about GPG. add_new_key = Add SSH Key +add_new_gpg_key = Add GPG Key ssh_key_been_used = Public key content has already been used. ssh_key_name_used = Public key with same name already exists. +gpg_key_id_used = Public GPG key with same id already exists. +gpg_key_email_not_found = The email attached to the GPG key couldn't be found or is not yet confirmed: %s +subkeys = Subkeys +key_id = Key ID key_name = Key Name key_content = Content add_key_success = Your new SSH key '%s' has been added successfully! +add_gpg_key_success = Your new GPG key '%s' has been added successfully! delete_key = Delete ssh_key_deletion = SSH Key Deletion +gpg_key_deletion = GPG Key Deletion ssh_key_deletion_desc = Delete this SSH key will disable all access using this SSH key for your account. Do you want to continue? +gpg_key_deletion_desc = Delete this GPG key will disable all commit verification sign with this GPG key. Do you want to continue? ssh_key_deletion_success = SSH key has been deleted successfully! +gpg_key_deletion_success = GPG key has been deleted successfully! add_on = Added on +valid_until = Valid until last_used = Last used on no_activity = No recent activity key_state_desc = This key is used in last 7 days @@ -1364,7 +1378,6 @@ mark_as_unread = Mark as unread error.extract_sign = Failed to extract signature error.generate_hash = Failed to generate hash of commit error.no_committer_account = No account linked to committer email -error.no_gpg_keys_found = "Failed to retrieve publics keys of committer" error.no_gpg_keys_found = "No known key found for this signature in database" error.not_signed_commit = "Not a signed commit" error.failed_retrieval_gpg_keys = "Failed to retrieve any key attached to the commiter account" diff --git a/public/js/index.js b/public/js/index.js index e139d1674..9b48c24cd 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -1408,7 +1408,11 @@ $(document).ready(function () { // Helpers. $('.delete-button').click(function () { var $this = $(this); - $('.delete.modal').modal({ + var filter = ""; + if ($this.attr("id")) { + filter += "#"+$this.attr("id") + } + $('.delete.modal'+filter).modal({ closable: false, onApprove: function () { if ($this.data('type') == "form") { diff --git a/routers/repo/setting.go b/routers/repo/setting.go index ed7254fe1..b2cb73cf9 100644 --- a/routers/repo/setting.go +++ b/routers/repo/setting.go @@ -664,7 +664,7 @@ func DeployKeys(ctx *context.Context) { } // DeployKeysPost response for adding a deploy key of a repository -func DeployKeysPost(ctx *context.Context, form auth.AddSSHKeyForm) { +func DeployKeysPost(ctx *context.Context, form auth.AddKeyForm) { ctx.Data["Title"] = ctx.Tr("repo.settings.deploy_keys") ctx.Data["PageIsSettingsKeys"] = true diff --git a/routers/routes/routes.go b/routers/routes/routes.go index a28473c0e..0920ef03b 100644 --- a/routers/routes/routes.go +++ b/routers/routes/routes.go @@ -214,9 +214,9 @@ func RegisterRoutes(m *macaron.Macaron) { }) } - m.Combo("/ssh").Get(user.SettingsSSHKeys). - Post(bindIgnErr(auth.AddSSHKeyForm{}), user.SettingsSSHKeysPost) - m.Post("/ssh/delete", user.DeleteSSHKey) + m.Combo("/keys").Get(user.SettingsKeys). + Post(bindIgnErr(auth.AddKeyForm{}), user.SettingsKeysPost) + m.Post("/keys/delete", user.DeleteKey) m.Combo("/applications").Get(user.SettingsApplications). Post(bindIgnErr(auth.NewAccessTokenForm{}), user.SettingsApplicationsPost) m.Post("/applications/delete", user.SettingsDeleteApplication) @@ -438,7 +438,7 @@ func RegisterRoutes(m *macaron.Macaron) { m.Group("/keys", func() { m.Combo("").Get(repo.DeployKeys). - Post(bindIgnErr(auth.AddSSHKeyForm{}), repo.DeployKeysPost) + Post(bindIgnErr(auth.AddKeyForm{}), repo.DeployKeysPost) m.Post("/delete", repo.DeleteDeployKey) }) diff --git a/routers/user/setting.go b/routers/user/setting.go index 622f45f2c..4fff19ef6 100644 --- a/routers/user/setting.go +++ b/routers/user/setting.go @@ -32,7 +32,7 @@ const ( tplSettingsAvatar base.TplName = "user/settings/avatar" tplSettingsPassword base.TplName = "user/settings/password" tplSettingsEmails base.TplName = "user/settings/email" - tplSettingsSSHKeys base.TplName = "user/settings/sshkeys" + tplSettingsKeys base.TplName = "user/settings/keys" tplSettingsSocial base.TplName = "user/settings/social" tplSettingsApplications base.TplName = "user/settings/applications" tplSettingsTwofa base.TplName = "user/settings/twofa" @@ -320,10 +320,10 @@ func DeleteEmail(ctx *context.Context) { }) } -// SettingsSSHKeys render user's SSH public keys page -func SettingsSSHKeys(ctx *context.Context) { +// SettingsKeys render user's SSH/GPG public keys page +func SettingsKeys(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsSSHKeys"] = true + ctx.Data["PageIsSettingsKeys"] = true keys, err := models.ListPublicKeys(ctx.User.ID) if err != nil { @@ -332,13 +332,20 @@ func SettingsSSHKeys(ctx *context.Context) { } ctx.Data["Keys"] = keys - ctx.HTML(200, tplSettingsSSHKeys) + gpgkeys, err := models.ListGPGKeys(ctx.User.ID) + if err != nil { + ctx.Handle(500, "ListGPGKeys", err) + return + } + ctx.Data["GPGKeys"] = gpgkeys + + ctx.HTML(200, tplSettingsKeys) } -// SettingsSSHKeysPost response for change user's SSH keys -func SettingsSSHKeysPost(ctx *context.Context, form auth.AddSSHKeyForm) { +// SettingsKeysPost response for change user's SSH/GPG keys +func SettingsKeysPost(ctx *context.Context, form auth.AddKeyForm) { ctx.Data["Title"] = ctx.Tr("settings") - ctx.Data["PageIsSettingsSSHKeys"] = true + ctx.Data["PageIsSettingsKeys"] = true keys, err := models.ListPublicKeys(ctx.User.ID) if err != nil { @@ -347,51 +354,97 @@ func SettingsSSHKeysPost(ctx *context.Context, form auth.AddSSHKeyForm) { } ctx.Data["Keys"] = keys - if ctx.HasError() { - ctx.HTML(200, tplSettingsSSHKeys) + gpgkeys, err := models.ListGPGKeys(ctx.User.ID) + if err != nil { + ctx.Handle(500, "ListGPGKeys", err) return } + ctx.Data["GPGKeys"] = gpgkeys - content, err := models.CheckPublicKeyString(form.Content) - if err != nil { - if models.IsErrKeyUnableVerify(err) { - ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) - } else { - ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error())) - ctx.Redirect(setting.AppSubURL + "/user/settings/ssh") + if ctx.HasError() { + ctx.HTML(200, tplSettingsKeys) + return + } + switch form.Type { + case "gpg": + key, err := models.AddGPGKey(ctx.User.ID, form.Content) + if err != nil { + ctx.Data["HasGPGError"] = true + switch { + case models.IsErrGPGKeyParsing(err): + ctx.Flash.Error(ctx.Tr("form.invalid_gpg_key", err.Error())) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case models.IsErrGPGKeyIDAlreadyUsed(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.gpg_key_id_used"), tplSettingsKeys, &form) + case models.IsErrGPGEmailNotFound(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.gpg_key_email_not_found", err.(models.ErrGPGEmailNotFound).Email), tplSettingsKeys, &form) + default: + ctx.Handle(500, "AddPublicKey", err) + } return } - } + ctx.Flash.Success(ctx.Tr("settings.add_gpg_key_success", key.KeyID)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + case "ssh": + content, err := models.CheckPublicKeyString(form.Content) + if err != nil { + if models.IsErrKeyUnableVerify(err) { + ctx.Flash.Info(ctx.Tr("form.unable_verify_ssh_key")) + } else { + ctx.Flash.Error(ctx.Tr("form.invalid_ssh_key", err.Error())) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + return + } + } - if _, err = models.AddPublicKey(ctx.User.ID, form.Title, content); err != nil { - ctx.Data["HasError"] = true - switch { - case models.IsErrKeyAlreadyExist(err): - ctx.Data["Err_Content"] = true - ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsSSHKeys, &form) - case models.IsErrKeyNameAlreadyUsed(err): - ctx.Data["Err_Title"] = true - ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsSSHKeys, &form) - default: - ctx.Handle(500, "AddPublicKey", err) + if _, err = models.AddPublicKey(ctx.User.ID, form.Title, content); err != nil { + ctx.Data["HasSSHError"] = true + switch { + case models.IsErrKeyAlreadyExist(err): + ctx.Data["Err_Content"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsKeys, &form) + case models.IsErrKeyNameAlreadyUsed(err): + ctx.Data["Err_Title"] = true + ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsKeys, &form) + default: + ctx.Handle(500, "AddPublicKey", err) + } + return } - return + ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title)) + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + + default: + ctx.Flash.Warning("Function not implemented") + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") } - ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title)) - ctx.Redirect(setting.AppSubURL + "/user/settings/ssh") } -// DeleteSSHKey response for delete user's SSH key -func DeleteSSHKey(ctx *context.Context) { - if err := models.DeletePublicKey(ctx.User, ctx.QueryInt64("id")); err != nil { - ctx.Flash.Error("DeletePublicKey: " + err.Error()) - } else { - ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success")) - } +// DeleteKey response for delete user's SSH/GPG key +func DeleteKey(ctx *context.Context) { + switch ctx.Query("type") { + case "gpg": + if err := models.DeleteGPGKey(ctx.User, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeleteGPGKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.gpg_key_deletion_success")) + } + case "ssh": + if err := models.DeletePublicKey(ctx.User, ctx.QueryInt64("id")); err != nil { + ctx.Flash.Error("DeletePublicKey: " + err.Error()) + } else { + ctx.Flash.Success(ctx.Tr("settings.ssh_key_deletion_success")) + } + default: + ctx.Flash.Warning("Function not implemented") + ctx.Redirect(setting.AppSubURL + "/user/settings/keys") + } ctx.JSON(200, map[string]interface{}{ - "redirect": setting.AppSubURL + "/user/settings/ssh", + "redirect": setting.AppSubURL + "/user/settings/keys", }) } diff --git a/templates/user/settings/keys.tmpl b/templates/user/settings/keys.tmpl new file mode 100644 index 000000000..86d2204eb --- /dev/null +++ b/templates/user/settings/keys.tmpl @@ -0,0 +1,12 @@ +{{template "base/head" .}} +
+ {{template "user/settings/navbar" .}} +
+ {{template "base/alert" .}} + {{template "user/settings/keys_ssh" .}} +
+ {{template "user/settings/keys_gpg" .}} +
+
+ +{{template "base/footer" .}} diff --git a/templates/user/settings/keys_gpg.tmpl b/templates/user/settings/keys_gpg.tmpl new file mode 100644 index 000000000..b0c3e1bda --- /dev/null +++ b/templates/user/settings/keys_gpg.tmpl @@ -0,0 +1,67 @@ +

+ {{.i18n.Tr "settings.manage_gpg_keys"}} +
+
{{.i18n.Tr "settings.add_key"}}
+
+

+
+
+
+ {{.i18n.Tr "settings.gpg_desc"}} +
+ {{range .GPGKeys}} +
+
+ +
+ +
+ {{range .Emails}}{{.Email}} {{end}} +
+ {{$.i18n.Tr "settings.key_id"}}: {{.KeyID}} + {{$.i18n.Tr "settings.subkeys"}}: {{range .SubsKey}} {{.KeyID}} {{end}} +
+
+ {{$.i18n.Tr "settings.add_on"}} {{DateFmtShort .Added}} + - + {{$.i18n.Tr "settings.valid_until"}} {{DateFmtShort .Expired}} +
+
+
+ {{end}} +
+
+
+

{{.i18n.Tr "settings.gpg_helper" "https://help.github.com/articles/about-gpg/" | Str2html}}

+
+

+ {{.i18n.Tr "settings.add_new_gpg_key"}} +

+
+
+ {{.CsrfTokenHtml}} + +
+ + +
+ + +
+
+
+ + diff --git a/templates/user/settings/keys_ssh.tmpl b/templates/user/settings/keys_ssh.tmpl new file mode 100644 index 000000000..1bdf0b15b --- /dev/null +++ b/templates/user/settings/keys_ssh.tmpl @@ -0,0 +1,67 @@ +

+ {{.i18n.Tr "settings.manage_ssh_keys"}} +
+
{{.i18n.Tr "settings.add_key"}}
+
+

+
+
+
+ {{.i18n.Tr "settings.ssh_desc"}} +
+ {{range .Keys}} +
+
+ +
+ +
+ {{.Name}} +
+ {{.Fingerprint}} +
+
+ {{$.i18n.Tr "settings.add_on"}} {{DateFmtShort .Created}} {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} {{DateFmtShort .Updated}}{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}} +
+
+
+ {{end}} +
+
+
+

{{.i18n.Tr "settings.ssh_helper" "https://help.github.com/articles/generating-ssh-keys" "https://help.github.com/ssh-issues/" | Str2html}}

+
+

+ {{.i18n.Tr "settings.add_new_key"}} +

+
+
+ {{.CsrfTokenHtml}} +
+ + +
+
+ + +
+ + +
+
+
+ + diff --git a/templates/user/settings/navbar.tmpl b/templates/user/settings/navbar.tmpl index d798d4021..4aac9effb 100644 --- a/templates/user/settings/navbar.tmpl +++ b/templates/user/settings/navbar.tmpl @@ -16,8 +16,8 @@ OpenID {{end}} - - {{.i18n.Tr "settings.ssh_keys"}} + + {{.i18n.Tr "settings.ssh_gpg_keys"}} {{.i18n.Tr "settings.applications"}} diff --git a/templates/user/settings/sshkeys.tmpl b/templates/user/settings/sshkeys.tmpl deleted file mode 100644 index 7942afde4..000000000 --- a/templates/user/settings/sshkeys.tmpl +++ /dev/null @@ -1,74 +0,0 @@ -{{template "base/head" .}} -
- {{template "user/settings/navbar" .}} -
- {{template "base/alert" .}} -

- {{.i18n.Tr "settings.manage_ssh_keys"}} -
-
{{.i18n.Tr "settings.add_key"}}
-
-

-
-
-
- {{.i18n.Tr "settings.ssh_desc"}} -
- {{range .Keys}} -
-
- -
- -
- {{.Name}} -
- {{.Fingerprint}} -
-
- {{$.i18n.Tr "settings.add_on"}} {{DateFmtShort .Created}} {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} {{DateFmtShort .Updated}}{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}} -
-
-
- {{end}} -
-
-
-

{{.i18n.Tr "settings.ssh_helper" "https://help.github.com/articles/generating-ssh-keys" "https://help.github.com/ssh-issues/" | Str2html}}

-
-

- {{.i18n.Tr "settings.add_new_key"}} -

-
-
- {{.CsrfTokenHtml}} -
- - -
-
- - -
- -
-
-
-
-
- - -{{template "base/footer" .}}