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
This commit is contained in:
Antoine GIRARD 2017-04-26 15:10:43 +02:00 committed by Lunny Xiao
parent b7da5a6cb7
commit 8371f94d06
14 changed files with 324 additions and 140 deletions

View file

@ -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

View file

@ -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

View file

@ -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,6 +400,7 @@ func ParseCommitWithSignature(c *git.Commit) *CommitVerification {
}
}
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
@ -409,8 +410,6 @@ func ParseCommitWithSignature(c *git.Commit) *CommitVerification {
Reason: "gpg.error.generate_hash",
}
}
for _, k := range keys {
//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,

View file

@ -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)
}

View file

@ -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 = <strong>Don't know how?</strong> Check out GitHub's guide to <a href="%s">create your own SSH keys</a> or solve <a href="%s">common problems</a> you might encounter using SSH.
gpg_helper = <strong>Don't know how?</strong> Check out GitHub's guide <a href="%s">about GPG</a>.
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"

View file

@ -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") {

View file

@ -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

View file

@ -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)
})

View file

@ -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
gpgkeys, err := models.ListGPGKeys(ctx.User.ID)
if err != nil {
ctx.Handle(500, "ListGPGKeys", err)
return
}
ctx.Data["GPGKeys"] = gpgkeys
if ctx.HasError() {
ctx.HTML(200, tplSettingsSSHKeys)
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/ssh")
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
return
}
}
if _, err = models.AddPublicKey(ctx.User.ID, form.Title, content); err != nil {
ctx.Data["HasError"] = true
ctx.Data["HasSSHError"] = true
switch {
case models.IsErrKeyAlreadyExist(err):
ctx.Data["Err_Content"] = true
ctx.RenderWithErr(ctx.Tr("settings.ssh_key_been_used"), tplSettingsSSHKeys, &form)
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"), tplSettingsSSHKeys, &form)
ctx.RenderWithErr(ctx.Tr("settings.ssh_key_name_used"), tplSettingsKeys, &form)
default:
ctx.Handle(500, "AddPublicKey", err)
}
return
}
ctx.Flash.Success(ctx.Tr("settings.add_key_success", form.Title))
ctx.Redirect(setting.AppSubURL + "/user/settings/ssh")
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
default:
ctx.Flash.Warning("Function not implemented")
ctx.Redirect(setting.AppSubURL + "/user/settings/keys")
}
// DeleteSSHKey response for delete user's SSH key
func DeleteSSHKey(ctx *context.Context) {
}
// 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",
})
}

View file

@ -0,0 +1,12 @@
{{template "base/head" .}}
<div class="user settings sshkeys">
{{template "user/settings/navbar" .}}
<div class="ui container">
{{template "base/alert" .}}
{{template "user/settings/keys_ssh" .}}
<br>
{{template "user/settings/keys_gpg" .}}
</div>
</div>
{{template "base/footer" .}}

View file

@ -0,0 +1,67 @@
<h4 class="ui top attached header">
{{.i18n.Tr "settings.manage_gpg_keys"}}
<div class="ui right">
<div class="ui blue tiny show-panel button" data-panel="#add-gpg-key-panel">{{.i18n.Tr "settings.add_key"}}</div>
</div>
</h4>
<div class="ui attached segment">
<div class="ui key list">
<div class="item">
{{.i18n.Tr "settings.gpg_desc"}}
</div>
{{range .GPGKeys}}
<div class="item">
<div class="right floated content">
<button class="ui red tiny button delete-button" id="delete-gpg" data-url="{{$.Link}}/delete?type=gpg" data-id="{{.ID}}">
{{$.i18n.Tr "settings.delete_key"}}
</button>
</div>
<i class="mega-octicon octicon-key {{if .Expired.After $.PageStartTime}}green{{end}}"></i>
<div class="content">
{{range .Emails}}<strong>{{.Email}} </strong>{{end}}
<div class="print meta">
<b>{{$.i18n.Tr "settings.key_id"}}:</b> {{.KeyID}}
<b>{{$.i18n.Tr "settings.subkeys"}}:</b> {{range .SubsKey}} {{.KeyID}} {{end}}
</div>
<div class="activity meta">
<i>{{$.i18n.Tr "settings.add_on"}} <span>{{DateFmtShort .Added}}</span></i>
-
<i>{{$.i18n.Tr "settings.valid_until"}} <span>{{DateFmtShort .Expired}}</span></i>
</div>
</div>
</div>
{{end}}
</div>
</div>
<br>
<p>{{.i18n.Tr "settings.gpg_helper" "https://help.github.com/articles/about-gpg/" | Str2html}}</p>
<div {{if not .HasGPGError}}class="hide"{{end}} id="add-gpg-key-panel">
<h4 class="ui top attached header">
{{.i18n.Tr "settings.add_new_gpg_key"}}
</h4>
<div class="ui attached segment">
<form class="ui form" action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
<input type="hidden" name="title" value="none">
<div class="field {{if .Err_Content}}error{{end}}">
<label for="content">{{.i18n.Tr "settings.key_content"}}</label>
<textarea id="gpg-key-content" name="content" required>{{.content}}</textarea>
</div>
<input name="type" type="hidden" value="gpg">
<button class="ui green button">
{{.i18n.Tr "settings.add_key"}}
</button>
</form>
</div>
</div>
<div class="ui small basic delete modal" id="delete-gpg">
<div class="ui icon header">
<i class="trash icon"></i>
{{.i18n.Tr "settings.gpg_key_deletion"}}
</div>
<div class="content">
<p>{{.i18n.Tr "settings.gpg_key_deletion_desc"}}</p>
</div>
{{template "base/delete_modal_actions" .}}
</div>

View file

@ -0,0 +1,67 @@
<h4 class="ui top attached header">
{{.i18n.Tr "settings.manage_ssh_keys"}}
<div class="ui right">
<div class="ui blue tiny show-panel button" data-panel="#add-ssh-key-panel">{{.i18n.Tr "settings.add_key"}}</div>
</div>
</h4>
<div class="ui attached segment">
<div class="ui key list">
<div class="item">
{{.i18n.Tr "settings.ssh_desc"}}
</div>
{{range .Keys}}
<div class="item">
<div class="right floated content">
<button class="ui red tiny button delete-button" id="delete-ssh" data-url="{{$.Link}}/delete?type=ssh" data-id="{{.ID}}">
{{$.i18n.Tr "settings.delete_key"}}
</button>
</div>
<i class="mega-octicon octicon-key {{if .HasRecentActivity}}green{{end}}" {{if .HasRecentActivity}}data-content="{{$.i18n.Tr "settings.key_state_desc"}}" data-variation="inverted tiny"{{end}}></i>
<div class="content">
<strong>{{.Name}}</strong>
<div class="print meta">
{{.Fingerprint}}
</div>
<div class="activity meta">
<i>{{$.i18n.Tr "settings.add_on"}} <span>{{DateFmtShort .Created}}</span> — <i class="octicon octicon-info"></i> {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{DateFmtShort .Updated}}</span>{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}}</i>
</div>
</div>
</div>
{{end}}
</div>
</div>
<br>
<p>{{.i18n.Tr "settings.ssh_helper" "https://help.github.com/articles/generating-ssh-keys" "https://help.github.com/ssh-issues/" | Str2html}}</p>
<div {{if not .HasSSHError}}class="hide"{{end}} id="add-ssh-key-panel">
<h4 class="ui top attached header">
{{.i18n.Tr "settings.add_new_key"}}
</h4>
<div class="ui attached segment">
<form class="ui form" action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
<div class="field {{if .Err_Title}}error{{end}}">
<label for="title">{{.i18n.Tr "settings.key_name"}}</label>
<input id="ssh-key-title" name="title" value="{{.title}}" autofocus required>
</div>
<div class="field {{if .Err_Content}}error{{end}}">
<label for="content">{{.i18n.Tr "settings.key_content"}}</label>
<textarea id="ssh-key-content" name="content" required>{{.content}}</textarea>
</div>
<input name="type" type="hidden" value="ssh">
<button class="ui green button">
{{.i18n.Tr "settings.add_key"}}
</button>
</form>
</div>
</div>
<div class="ui small basic delete modal" id="delete-ssh">
<div class="ui icon header">
<i class="trash icon"></i>
{{.i18n.Tr "settings.ssh_key_deletion"}}
</div>
<div class="content">
<p>{{.i18n.Tr "settings.ssh_key_deletion_desc"}}</p>
</div>
{{template "base/delete_modal_actions" .}}
</div>

View file

@ -16,8 +16,8 @@
OpenID
</a>
{{end}}
<a class="{{if .PageIsSettingsSSHKeys}}active{{end}} item" href="{{AppSubUrl}}/user/settings/ssh">
{{.i18n.Tr "settings.ssh_keys"}}
<a class="{{if .PageIsSettingsKeys}}active{{end}} item" href="{{AppSubUrl}}/user/settings/keys">
{{.i18n.Tr "settings.ssh_gpg_keys"}}
</a>
<a class="{{if .PageIsSettingsApplications}}active{{end}} item" href="{{AppSubUrl}}/user/settings/applications">
{{.i18n.Tr "settings.applications"}}

View file

@ -1,74 +0,0 @@
{{template "base/head" .}}
<div class="user settings sshkeys">
{{template "user/settings/navbar" .}}
<div class="ui container">
{{template "base/alert" .}}
<h4 class="ui top attached header">
{{.i18n.Tr "settings.manage_ssh_keys"}}
<div class="ui right">
<div class="ui blue tiny show-panel button" data-panel="#add-ssh-key-panel">{{.i18n.Tr "settings.add_key"}}</div>
</div>
</h4>
<div class="ui attached segment">
<div class="ui key list">
<div class="item">
{{.i18n.Tr "settings.ssh_desc"}}
</div>
{{range .Keys}}
<div class="item">
<div class="right floated content">
<button class="ui red tiny button delete-button" data-url="{{$.Link}}/delete" data-id="{{.ID}}">
{{$.i18n.Tr "settings.delete_key"}}
</button>
</div>
<i class="mega-octicon octicon-key {{if .HasRecentActivity}}green{{end}}" {{if .HasRecentActivity}}data-content="{{$.i18n.Tr "settings.key_state_desc"}}" data-variation="inverted tiny"{{end}}></i>
<div class="content">
<strong>{{.Name}}</strong>
<div class="print meta">
{{.Fingerprint}}
</div>
<div class="activity meta">
<i>{{$.i18n.Tr "settings.add_on"}} <span>{{DateFmtShort .Created}}</span> — <i class="octicon octicon-info"></i> {{if .HasUsed}}{{$.i18n.Tr "settings.last_used"}} <span {{if .HasRecentActivity}}class="green"{{end}}>{{DateFmtShort .Updated}}</span>{{else}}{{$.i18n.Tr "settings.no_activity"}}{{end}}</i>
</div>
</div>
</div>
{{end}}
</div>
</div>
<br>
<p>{{.i18n.Tr "settings.ssh_helper" "https://help.github.com/articles/generating-ssh-keys" "https://help.github.com/ssh-issues/" | Str2html}}</p>
<div {{if not .HasError}}class="hide"{{end}} id="add-ssh-key-panel">
<h4 class="ui top attached header">
{{.i18n.Tr "settings.add_new_key"}}
</h4>
<div class="ui attached segment">
<form class="ui form" action="{{.Link}}" method="post">
{{.CsrfTokenHtml}}
<div class="field {{if .Err_Title}}error{{end}}">
<label for="title">{{.i18n.Tr "settings.key_name"}}</label>
<input id="ssh-key-title" name="title" value="{{.title}}" autofocus required>
</div>
<div class="field {{if .Err_Content}}error{{end}}">
<label for="content">{{.i18n.Tr "settings.key_content"}}</label>
<textarea id="ssh-key-content" name="content" required>{{.content}}</textarea>
</div>
<button class="ui green button">
{{.i18n.Tr "settings.add_key"}}
</button>
</form>
</div>
</div>
</div>
</div>
<div class="ui small basic delete modal">
<div class="ui icon header">
<i class="trash icon"></i>
{{.i18n.Tr "settings.ssh_key_deletion"}}
</div>
<div class="content">
<p>{{.i18n.Tr "settings.ssh_key_deletion_desc"}}</p>
</div>
{{template "base/delete_modal_actions" .}}
</div>
{{template "base/footer" .}}