Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/projects/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func (c *KeyController) AddKey(w http.ResponseWriter, r *http.Request) {
helpers.WriteError(w, err)
return
}
key.Plain = newKey.Plain

helpers.WriteJSON(w, http.StatusCreated, key)
}
Expand Down
1 change: 1 addition & 0 deletions db/AccessKey.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type AccessKey struct {
String string `db:"-" json:"string"`
LoginPassword LoginPassword `db:"-" json:"login_password"`
SshKey SshKey `db:"-" json:"ssh"`
GenerateSSHKey bool `db:"-" json:"generate_ssh_key,omitempty"`
OverrideSecret bool `db:"-" json:"override_secret,omitempty"`

StorageID *int `db:"storage_id" json:"-" backup:"-"`
Expand Down
1 change: 1 addition & 0 deletions db/bolt/access_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func (d *BoltDb) UpdateAccessKey(key db.AccessKey) error {
return err2
}
oldKey.Name = key.Name
//oldKey.Plain = key.Plain
key = oldKey
}

Expand Down
7 changes: 5 additions & 2 deletions db/sql/access_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,10 @@ func (d *SqlDb) UpdateAccessKey(key db.AccessKey) error {
args = append(args, key.Name)

if key.OverrideSecret {
query += ", type=?, secret=?"
query += ", type=?, secret=?, plain=?"
args = append(args, key.Type)
args = append(args, key.Secret)
args = append(args, key.Plain)
}

query += " where id=?"
Expand All @@ -96,16 +97,18 @@ func (d *SqlDb) CreateAccessKey(key db.AccessKey) (newKey db.AccessKey, err erro
"type, "+
"project_id, "+
"secret, "+
"plain, "+
"environment_id, "+
"owner, "+
"storage_id, "+
"source_storage_id, "+
"source_storage_key) "+
"values (?, ?, ?, ?, ?, ?, ?, ?, ?)",
"values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
key.Name,
key.Type,
key.ProjectID,
key.Secret,
key.Plain,
key.EnvironmentID,
key.Owner,
key.StorageID,
Expand Down
51 changes: 51 additions & 0 deletions services/server/access_key_svc.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
package server

import (
"bufio"
"bytes"
"encoding/json"
"errors"

"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/util"
)

type AccessKeyService interface {
Expand Down Expand Up @@ -61,7 +65,48 @@ func (s *AccessKeyServiceImpl) GetAll(projectID int, options db.GetAccessKeyOpti
return s.accessKeyRepo.GetAccessKeys(projectID, options, params)
}

func maybeGenerateSSHPrivateKey(key *db.AccessKey) error {
if !key.GenerateSSHKey || key.Type != db.AccessKeySSH {
key.Plain = nil
return nil
}

var b bytes.Buffer
privateKeyFile := bufio.NewWriter(&b)

publicKey, err := util.GeneratePrivateKey(privateKeyFile)
if err != nil {
return err
}

err = privateKeyFile.Flush()
if err != nil {
return err
}

key.SshKey.PrivateKey = b.String()

type sshPublicKey struct {
PublicKey string `json:"public_key"`
}

plainBytes, err := json.Marshal(sshPublicKey{
PublicKey: publicKey,
})
if err != nil {
return err
}

plain := string(plainBytes)
key.Plain = &plain
return nil
}
Comment on lines +68 to +103
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SSH key generation functionality introduced by this PR lacks automated test coverage. Consider adding tests to verify that maybeGenerateSSHPrivateKey correctly generates keys when GenerateSSHKey is true, sets the Plain field with the public key, and properly handles the case when GenerateSSHKey is false. Tests should also verify that the generated private key is valid and properly encrypted.

Example test cases to add:

  • Test that GenerateSSHKey=true generates both private and public keys
  • Test that GenerateSSHKey=false preserves existing private key
  • Test that Plain field is set correctly with public key JSON
  • Test that generated keys are valid RSA keys

Copilot uses AI. Check for mistakes.

func (s *AccessKeyServiceImpl) Create(key db.AccessKey) (newKey db.AccessKey, err error) {
err = maybeGenerateSSHPrivateKey(&key)
if err != nil {
return
}

err = s.encryptionService.SerializeSecret(&key)
if err != nil && !errors.Is(err, ErrReadOnlyStorage) {
Expand All @@ -73,7 +118,13 @@ func (s *AccessKeyServiceImpl) Create(key db.AccessKey) (newKey db.AccessKey, er
}

func (s *AccessKeyServiceImpl) Update(key db.AccessKey) (err error) {

if key.OverrideSecret {
err = maybeGenerateSSHPrivateKey(&key)
if err != nil {
return
}

err = s.encryptionService.SerializeSecret(&key)
if errors.Is(err, ErrReadOnlyStorage) {
key.OverrideSecret = false
Expand Down
57 changes: 55 additions & 2 deletions web/src/components/KeyForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,47 @@
dense
/>

<v-checkbox
v-model="item.generate_ssh_key"
label="Generate SSH Key"
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkbox label "Generate SSH Key" is hardcoded and not internationalized, while other labels in the form use the i18n translation function. This creates inconsistency with the rest of the codebase where all user-facing strings are translated.

Consider changing label="Generate SSH Key" to :label="$t('generateSshKey')" and adding the corresponding translation keys.

Suggested change
label="Generate SSH Key"
:label="$t('generateSshKey')"

Copilot uses AI. Check for mistakes.
v-if="!isReadOnly && item.type === 'ssh'"
:disabled="formSaving || !canEditSecrets"
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The checkbox for generating SSH keys is only disabled when canEditSecrets is false, but it should also be disabled for existing keys where override_secret is required. When editing an existing key without override_secret checked, users can toggle generate_ssh_key, but this won't have any effect because the secret won't be updated. This creates a confusing user experience.

Consider adding || (!isNew && !item.override_secret) to the disabled condition to make it clear that SSH key generation only works when secrets can be edited.

Suggested change
:disabled="formSaving || !canEditSecrets"
:disabled="formSaving || !canEditSecrets || (!isNew && !item.override_secret)"

Copilot uses AI. Check for mistakes.
/>

<v-textarea
outlined
v-model="item.ssh.private_key"
:label="$t('privateKey')"
:disabled="formSaving || !canEditSecrets"
:rules="[v => !canEditSecrets || !!v || $t('private_key_required')]"
:disabled="formSaving || !canEditSecrets || item.generate_ssh_key"
:rules="[v => !canEditSecrets || item.generate_ssh_key || !!v || $t('private_key_required')]"
v-if="!isReadOnly && item.type === 'ssh'"
/>

<div
v-if="item.type === 'ssh' && !isNew && hasGeneratedPublicKey"
class="mb-4"
>
<!-- <div>Public Key</div>-->
Comment thread
fiftin marked this conversation as resolved.
<div style="position: relative">
<pre
style="
overflow: auto;
background: gray;
color: white;
border-radius: 10px;
margin-top: 5px;
"
class="pa-2"
>{{ publicKey }}</pre
>

<CopyClipboardButton
style="position: absolute; right: 0; top: 0; transform: scale(0.9);"
Comment on lines +133 to +147
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline styles are used extensively for the public key display section. Consider extracting these styles to a scoped <style> section or using CSS classes for better maintainability and consistency. This makes it easier to maintain and update styling across the application.

Suggested change
<div style="position: relative">
<pre
style="
overflow: auto;
background: gray;
color: white;
border-radius: 10px;
margin-top: 5px;
"
class="pa-2"
>{{ publicKey }}</pre
>
<CopyClipboardButton
style="position: absolute; right: 0; top: 0; transform: scale(0.9);"
<div class="public-key-container">
<pre
class="pa-2 public-key-display"
>{{ publicKey }}</pre
>
<CopyClipboardButton
class="public-key-copy-button"

Copilot uses AI. Check for mistakes.
:text="publicKey"
/>
</div>
</div>

<v-checkbox
v-model="item.override_secret"
:label="$t('override')"
Expand All @@ -136,8 +168,13 @@
</template>
<script>
import ItemFormBase from '@/components/ItemFormBase';
import CopyClipboardButton from '@/components/CopyClipboardButton.vue';

export default {
components: {
CopyClipboardButton,
},

mixins: [ItemFormBase],

props: {
Expand All @@ -164,6 +201,21 @@ export default {
},

computed: {
hasGeneratedPublicKey() {
return this.publicKey !== '';
},

publicKey: {
get() {
try {
const plain = JSON.parse(this.item?.plain || '{}');
return plain.public_key || '';
} catch (e) {
return '';
}
},
},

canEditSecrets() {
return this.isNew || this.item.override_secret;
},
Expand Down Expand Up @@ -195,6 +247,7 @@ export default {
return {
ssh: {},
login_password: {},
generate_ssh_key: false,
};
},

Expand Down
84 changes: 82 additions & 2 deletions web/src/views/project/Keys.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
:save-button-text="itemId === 'new' ? $t('create') : $t('save')"
:title="`${itemId === 'new' ? $t('nnew') : $t('edit')} Key`"
:max-width="450"
@save="loadItems()"
@save="loadItemsAndShowPublicKey($event)"
>
<template v-slot:form="{ onSave, onError, needSave, needReset }">
<KeyForm
Expand All @@ -20,6 +20,37 @@
</template>
</EditDialog>

<EditDialog
:max-width="700"
v-model="createdPublicKeyDialog"
:save-button-text="null"
title="Generated SSH Public Key"
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The dialog title "Generated SSH Public Key" is hardcoded and not internationalized. All other strings in the file use the i18n translation function (e.g., $t('create'), $t('save')). This string should be extracted to the translation files for consistency and to support multiple languages.

Consider changing title="Generated SSH Public Key" to title="$t('generatedSshPublicKey')" and adding the corresponding translation keys.

Suggested change
title="Generated SSH Public Key"
:title="$t('generatedSshPublicKey')"

Copilot uses AI. Check for mistakes.
hide-buttons
>
<template v-slot:form="{}">
<div class="mb-4">
<div style="position: relative">
<pre
style="
overflow: auto;
background: gray;
color: white;
border-radius: 10px;
margin-top: 5px;
"
class="pa-2"
Comment on lines +34 to +41
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline styles are duplicated between KeyForm.vue and Keys.vue for displaying the public key. The same styling is applied to the <pre> element in both files (gray background, white color, border-radius, etc.). Consider creating a reusable component or shared styles to avoid this duplication and ensure consistency.

Suggested change
style="
overflow: auto;
background: gray;
color: white;
border-radius: 10px;
margin-top: 5px;
"
class="pa-2"
class="pa-2 mt-1 rounded overflow-auto grey darken-3 white--text"

Copilot uses AI. Check for mistakes.
>{{ createdPublicKey }}</pre
>

<CopyClipboardButton
style="position: absolute; right: 10px; top: 10px"
:text="createdPublicKey"
/>
</div>
</div>
</template>
</EditDialog>

<ObjectRefsDialog
object-title="access key"
:object-refs="itemRefs"
Expand Down Expand Up @@ -89,9 +120,14 @@ import ItemListPageBase from '@/components/ItemListPageBase';
import KeyForm from '@/components/KeyForm.vue';
import PageMixin from '@/components/PageMixin';
import KeyStoreMenu from '@/components/KeyStoreMenu.vue';
import CopyClipboardButton from '@/components/CopyClipboardButton.vue';

export default {
components: { KeyStoreMenu, KeyForm },
components: {
CopyClipboardButton,
KeyStoreMenu,
KeyForm,
},

mixins: [ItemListPageBase, PageMixin],

Expand All @@ -105,7 +141,51 @@ export default {
},
},

data() {
return {
createdPublicKeyDialog: false,
createdPublicKey: '',
};
},

methods: {
async loadItemsAndShowPublicKey(e) {
await this.loadItems();

const isGeneratedOnCreate = e && e.action === 'new';
const isGeneratedOnUpdate = e && e.action === 'edit' && e.item && e.item.generate_ssh_key;
Comment on lines +155 to +156
Copy link

Copilot AI Feb 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The logic for determining if a public key should be shown on update (line 156) assumes that if generate_ssh_key is true, a key was generated. However, this flag comes from the request body, not the response. If the backend fails to generate the key (but doesn't return an error), or if the key generation is skipped for some reason, the dialog may still appear with an empty public key. Consider adding an additional check to ensure the public key was actually generated before showing the dialog.

Copilot uses AI. Check for mistakes.
if (!isGeneratedOnCreate && !isGeneratedOnUpdate) {
this.createdPublicKey = '';
return;
}

const itemId = e && e.item ? e.item.id : null;
const reloadedItem = itemId ? this.items.find((x) => x.id === itemId) : null;
const sourceItem = reloadedItem || (e || {}).item;
const publicKey = this.extractPublicKey(sourceItem);

if (!publicKey) {
this.createdPublicKey = '';
return;
}

this.createdPublicKey = publicKey;
this.createdPublicKeyDialog = true;
},

extractPublicKey(item) {
if (!item || !item.plain) {
return '';
}

try {
const plain = JSON.parse(item.plain);
return plain.public_key || '';
} catch (e) {
return '';
}
},

getHeaders() {
return [{
text: this.$i18n.t('name'),
Expand Down
Loading