2
0

7 Commits

Author SHA1 Message Date
4dc9c34bcc fix(plugin): use DriverName instead of Dialect for DB detection
All checks were successful
Build and Release / Tests (push) Successful in 1m11s
Build and Release / Lint (push) Successful in 1m41s
Build and Release / Create Release (push) Successful in 0s
Replace x.Dialect().URI().DBType with x.DriverName() for more reliable database driver detection. Add support for 'pgx' and 'sqlite' driver variants alongside existing 'postgres' and 'sqlite3'. Improve logging with driver information and error messages for better migration debugging.
2026-02-08 11:16:07 -05:00
b824b8e3be feat(vault): add database migration system for vault plugin
All checks were successful
Build and Release / Tests (push) Successful in 1m40s
Build and Release / Lint (push) Successful in 1m41s
Build and Release / Create Release (push) Successful in 0s
Implements explicit column migration logic to handle schema upgrades that xorm.Sync() doesn't reliably perform. Adds encryption_mode column migration for vault_secret table to support lockbox (E2E) encryption. Includes database-agnostic column existence checks and ALTER TABLE statement generation for PostgreSQL, MySQL, SQLite, and MSSQL.
2026-02-08 10:38:39 -05:00
e9b109c464 docs: add comprehensive API reference documentation
All checks were successful
Build and Release / Tests (push) Successful in 1m26s
Build and Release / Lint (push) Successful in 1m59s
Build and Release / Create Release (push) Has been skipped
Creates new API.md with complete API reference including:
- Architecture and encryption model documentation
- Installation and configuration guides
- Authentication methods and API endpoints
- Go package API documentation
- Error codes and code examples
- License tier information

Also updates README.md to reference the new API documentation.
2026-02-07 09:25:38 -05:00
69f71cef7e docs: add lockbox end-to-end encryption documentation
Documents the new Lockbox feature for client-side encryption including:
- Feature comparison table between standard and lockbox modes
- Encryption scheme details (Argon2id + AES-256-GCM)
- API usage examples for Go, TypeScript, and Python SDKs
- Raw API endpoint documentation for manual encryption
2026-02-07 09:19:32 -05:00
fcb720736b feat(vault): add lockbox encryption mode to secrets
All checks were successful
Build and Release / Tests (push) Successful in 1m11s
Build and Release / Lint (push) Successful in 1m33s
Build and Release / Create Release (push) Successful in 0s
Added encryption_mode field to secrets supporting "standard" (server-side) and "lockbox" (client-side E2E) modes. Updated API to validate lockbox format (lockbox:v1:salt:ciphertext). Enhanced UI to display lock icons and badges for lockbox secrets. Lockbox secrets show locked state in web UI, requiring CLI/SDK for decryption.
2026-02-07 02:14:26 -05:00
c38fe412f8 fix(vault): prevent dirty form warnings on key management forms
All checks were successful
Build and Release / Tests (push) Successful in 1m9s
Build and Release / Lint (push) Successful in 1m33s
Build and Release / Create Release (push) Has been skipped
2026-02-06 22:40:51 -05:00
3a920ce90c feat(vault): add confirmation modals for destructive operations
All checks were successful
Build and Release / Tests (push) Successful in 1m5s
Build and Release / Lint (push) Successful in 1m38s
Build and Release / Create Release (push) Successful in 0s
Replaced inline confirm() dialogs with proper modal dialogs for key migration, DEK rotation, token revocation, and version rollback operations. Improves UX and provides better context for destructive actions.
2026-02-06 22:22:08 -05:00
13 changed files with 2162 additions and 34 deletions

View File

@@ -1,7 +1,7 @@
{
"id": "note-1768964774362-3y9dna8sm",
"title": "working",
"content": "● Done. Updated all vault locale files with the new translation keys.\n\n Summary of what's now in gitcaddy-vault:\n 1. Templates: list.tmpl, view.tmpl, audit.tmpl, tokens.tmpl (copied)\n 2. Locale files: Added 20+ new translation keys to all locale files\n\n What stays in gitcaddy-server:\n - Router handlers (routers/web/repo/vault/vault.go) - these call the vault service\n - Service interface definitions (services/vault/vault.go)\n - Locale files also need the keys (for ctx.Locale.Tr calls in templates)\n\n```\n Now you need to:\n 1. Commit and tag vault as v1.0.32\n 2. Update server go.mod to v1.0.32\n 3. Release server v3.1.10\n 4. Deploy to git-test\n```\n\n● So:\n\n | Key | Value |\n |---------------------------|------------------------------------------------------------------|\n | Old (SECRET_KEY fallback) | bfe6e73282adba6545c2c9ba3542a332a80d62d1644b3b24c14b020a838c136f |\n | New (MASTER_KEY) | af1a7e9d7fe73258800b434ab5ffe7bf1ee3dff49740ef3cc3c5c014587acc08 |\n",
"content": "● Done. Updated all vault locale files with the new translation keys.\n\n Summary of what's now in gitcaddy-vault:\n 1. Templates: list.tmpl, view.tmpl, audit.tmpl, tokens.tmpl (copied)\n 2. Locale files: Added 20+ new translation keys to all locale files\n\n What stays in gitcaddy-server:\n - Router handlers (routers/web/repo/vault/vault.go) - these call the vault service\n - Service interface definitions (services/vault/vault.go)\n - Locale files also need the keys (for ctx.Locale.Tr calls in templates)\n\n```\n Now you need to:\n 1. Commit and tag vault as v1.0.32\n 2. Update server go.mod to v1.0.32\n 3. Release server v3.1.10\n 4. Deploy to git-test\n```\n",
"createdAt": 1768964774360,
"updatedAt": 1770432337981
"updatedAt": 1770512254003
}

1803
API.md Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,30 @@ GitCaddy Vault is a commercial module compiled directly into GitCaddy Server tha
| `certificate` | TLS/SSL certificates |
| `ssh-key` | SSH private keys |
### Lockbox (End-to-End Encryption)
Lockbox provides optional client-side encryption where the server **never sees your plaintext secrets**. Perfect for highly sensitive data where you don't want even the server administrator to have access.
| Feature | Standard Mode | Lockbox Mode |
|---------|--------------|--------------|
| Server sees plaintext | Yes | No |
| Passphrase required | No | Yes |
| Recovery if passphrase lost | Yes | No |
| Web UI viewing | Yes | CLI/SDK only |
**How Lockbox Works:**
1. Client encrypts secret with your passphrase using Argon2id + AES-256-GCM
2. Encrypted blob is sent to server in `lockbox:v1:...` format
3. Server wraps the blob with repository DEK (double encryption)
4. On retrieval, server unwraps DEK layer, returns lockbox blob
5. Client decrypts with your passphrase
**Encryption Scheme:**
- Key derivation: Argon2id (time=1, memory=64MB, parallelism=4)
- Cipher: AES-256-GCM with 12-byte nonce
- Salt: 16 bytes random per secret
- Format: `lockbox:v1:<base64(salt)>:<base64(nonce||ciphertext||tag)>`
### Security Architecture
```

View File

@@ -205,5 +205,6 @@
"vault.current_key_source": "Current key source",
"vault.one_click_migration": "One-Click Migration",
"vault.manual_migration": "Manual Migration",
"vault.manual_migration_description": "If your secrets were encrypted with a different key (not the fallback), enter it manually below."
"vault.manual_migration_description": "If your secrets were encrypted with a different key (not the fallback), enter it manually below.",
"vault.confirm_migrate_fallback": "This will migrate all vault secrets from the fallback key (Gitea SECRET_KEY) to your dedicated MASTER_KEY. This operation cannot be undone. Continue?"
}

View File

@@ -37,6 +37,9 @@ type VaultSecret struct {
CreatedBy int64 `xorm:"NOT NULL"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated NOT NULL"`
// Encryption mode: "standard" (server-side) or "lockbox" (client E2E encrypted)
EncryptionMode string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'standard'"`
// Soft delete
DeletedUnix timeutil.TimeStamp `xorm:"INDEX"` // 0 = active, >0 = soft deleted
DeletedBy int64

102
plugin.go
View File

@@ -5,6 +5,7 @@ package vault
import (
"context"
"fmt"
"git.marketally.com/gitcaddy/gitcaddy-vault/crypto"
"git.marketally.com/gitcaddy/gitcaddy-vault/license"
@@ -98,7 +99,106 @@ func (p *VaultPlugin) RegisterModels() []any {
// Migrate runs database migrations for this plugin
func (p *VaultPlugin) Migrate(ctx context.Context, x *xorm.Engine) error {
return x.Sync(p.RegisterModels()...)
// First, sync tables (works for fresh installations)
if err := x.Sync(p.RegisterModels()...); err != nil {
return err
}
// Then run explicit column migrations for upgrades
// xorm.Sync() doesn't reliably add columns to existing tables
return runColumnMigrations(ctx, x)
}
// runColumnMigrations adds any missing columns to existing tables.
// This handles the case where xorm.Sync() fails to add new columns.
func runColumnMigrations(ctx context.Context, x *xorm.Engine) error {
migrations := []struct {
table string
column string
columnType string
defaultVal string
}{
// v1.0.31: Add encryption_mode column for lockbox (E2E) secrets
{"vault_secret", "encryption_mode", "VARCHAR(20)", "'standard'"},
}
for _, m := range migrations {
exists, err := columnExists(x, m.table, m.column)
if err != nil {
return err
}
if !exists {
log.Info("Vault migration: adding column %s.%s", m.table, m.column)
sql := buildAddColumnSQL(x, m.table, m.column, m.columnType, m.defaultVal)
if _, err := x.Exec(sql); err != nil {
return fmt.Errorf("failed to add column %s.%s: %w", m.table, m.column, err)
}
}
}
return nil
}
// columnExists checks if a column exists in a table
func columnExists(x *xorm.Engine, table, column string) (bool, error) {
driverName := x.DriverName()
log.Info("Vault migration: checking column %s.%s (driver: %s)", table, column, driverName)
var sql string
switch driverName {
case "postgres", "pgx":
sql = fmt.Sprintf(`SELECT 1 FROM information_schema.columns WHERE table_name = '%s' AND column_name = '%s'`, table, column)
case "mysql":
sql = fmt.Sprintf(`SELECT 1 FROM information_schema.columns WHERE table_name = '%s' AND column_name = '%s'`, table, column)
case "sqlite3", "sqlite":
// SQLite uses PRAGMA
results, err := x.Query(fmt.Sprintf("PRAGMA table_info(%s)", table))
if err != nil {
return false, err
}
for _, row := range results {
if string(row["name"]) == column {
return true, nil
}
}
return false, nil
case "mssql":
sql = fmt.Sprintf(`SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('%s') AND name = '%s'`, table, column)
default:
// Fallback: try information_schema (works for postgres/mysql)
log.Warn("Vault migration: unknown driver '%s', using information_schema fallback", driverName)
sql = fmt.Sprintf(`SELECT 1 FROM information_schema.columns WHERE table_name = '%s' AND column_name = '%s'`, table, column)
}
results, err := x.Query(sql)
if err != nil {
log.Error("Vault migration: column check failed: %v", err)
return false, err
}
exists := len(results) > 0
log.Info("Vault migration: column %s.%s exists: %v", table, column, exists)
return exists, nil
}
// buildAddColumnSQL builds the appropriate ALTER TABLE statement for the database type
func buildAddColumnSQL(x *xorm.Engine, table, column, columnType, defaultVal string) string {
driverName := x.DriverName()
log.Info("Vault migration: building ALTER TABLE for driver: %s", driverName)
switch driverName {
case "postgres", "pgx":
return fmt.Sprintf(`ALTER TABLE "%s" ADD COLUMN IF NOT EXISTS "%s" %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
case "mysql":
// MySQL doesn't have IF NOT EXISTS for columns, but we already checked
return fmt.Sprintf("ALTER TABLE `%s` ADD COLUMN `%s` %s NOT NULL DEFAULT %s", table, column, columnType, defaultVal)
case "sqlite3", "sqlite":
return fmt.Sprintf(`ALTER TABLE "%s" ADD COLUMN "%s" %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
case "mssql":
return fmt.Sprintf(`ALTER TABLE [%s] ADD [%s] %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
default:
// Fallback to postgres-style (most compatible)
log.Warn("Vault migration: unknown driver '%s', using postgres-style ALTER TABLE", driverName)
return fmt.Sprintf(`ALTER TABLE "%s" ADD COLUMN IF NOT EXISTS "%s" %s NOT NULL DEFAULT %s`, table, column, columnType, defaultVal)
}
}
// RegisterRepoWebRoutes adds vault routes under /{owner}/{repo}/vault

View File

@@ -49,6 +49,7 @@ type SecretResponse struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Type string `json:"type"`
EncryptionMode string `json:"encryption_mode,omitempty"` // "standard" or "lockbox"
CurrentVersion int `json:"current_version"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
@@ -115,11 +116,12 @@ type TokenInfoResponse struct {
// CreateSecretRequest is the request body for creating a secret
type CreateSecretRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" binding:"required"`
Comment string `json:"comment"` // Used when updating existing secret
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Type string `json:"type"`
Value string `json:"value" binding:"required"`
Comment string `json:"comment"` // Used when updating existing secret
EncryptionMode string `json:"encryption_mode"` // "standard" (default) or "lockbox"
}
// UpdateSecretRequest is the request body for updating a secret
@@ -272,10 +274,15 @@ func requireRepoWrite(ctx *context.APIContext) bool {
}
func secretToResponse(s *models.VaultSecret) SecretResponse {
encMode := s.EncryptionMode
if encMode == "" {
encMode = "standard"
}
return SecretResponse{
Name: s.Name,
Description: s.Description,
Type: string(s.Type),
EncryptionMode: encMode,
CurrentVersion: s.CurrentVersion,
CreatedAt: int64(s.CreatedUnix),
UpdatedAt: int64(s.UpdatedUnix),
@@ -564,6 +571,28 @@ func apiPutSecret(lic *license.Manager) http.HandlerFunc {
return
}
// Validate encryption mode
encryptionMode := req.EncryptionMode
if encryptionMode == "" {
encryptionMode = "standard"
}
if encryptionMode != "standard" && encryptionMode != "lockbox" {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_encryption_mode",
"message": "encryption_mode must be 'standard' or 'lockbox'",
})
return
}
// Validate lockbox format: must start with "lockbox:v1:"
if encryptionMode == "lockbox" && !strings.HasPrefix(req.Value, "lockbox:v1:") {
ctx.JSON(http.StatusBadRequest, map[string]any{
"error": "invalid_lockbox_format",
"message": "Lockbox secrets must be pre-encrypted with format 'lockbox:v1:<salt>:<ciphertext>'",
})
return
}
// Check if secret exists
existing, err := services.GetSecret(ctx, ctx.Repo.Repository.ID, name)
if err == services.ErrSecretNotFound {
@@ -576,11 +605,12 @@ func apiPutSecret(lic *license.Manager) http.HandlerFunc {
limits = &info.Limits
}
secret, err := services.CreateSecret(ctx, ctx.Repo.Repository.ID, services.CreateSecretOptions{
Name: req.Name,
Description: req.Description,
Type: req.Type,
Value: req.Value,
CreatorID: ctx.Doer.ID,
Name: req.Name,
Description: req.Description,
Type: req.Type,
Value: req.Value,
EncryptionMode: encryptionMode,
CreatorID: ctx.Doer.ID,
}, limits)
if err != nil {
switch err {

View File

@@ -106,11 +106,12 @@ func GetSecretValue(ctx context.Context, repoID int64, name string, version int)
// CreateSecretOptions contains options for creating a secret
type CreateSecretOptions struct {
Name string
Description string
Type string
Value string
CreatorID int64
Name string
Description string
Type string
Value string
EncryptionMode string // "standard" (default) or "lockbox"
CreatorID int64
}
// CreateSecret creates a new secret
@@ -158,12 +159,19 @@ func CreateSecret(ctx context.Context, repoID int64, opts CreateSecretOptions, l
now := timeutil.TimeStampNow()
// Determine encryption mode
encryptionMode := opts.EncryptionMode
if encryptionMode == "" {
encryptionMode = "standard"
}
// Create the secret
secret := &models.VaultSecret{
RepoID: repoID,
Name: opts.Name,
Description: opts.Description,
Type: models.SecretType(opts.Type),
EncryptionMode: encryptionMode,
CurrentVersion: 1,
CreatedUnix: now,
UpdatedUnix: now,

View File

@@ -45,7 +45,7 @@
<div class="ui info message">
<p>{{ctx.Locale.Tr "vault.migrate_from_fallback_description"}}</p>
</div>
<form class="ui form" action="{{.RepoLink}}/vault/migrate-from-fallback" method="post">
<form class="ui form ignore-dirty" id="migrate-fallback-form" action="{{.RepoLink}}/vault/migrate-from-fallback" method="post">
{{.CsrfTokenHtml}}
<div class="field">
<select name="scope" class="ui dropdown">
@@ -55,7 +55,7 @@
{{end}}
</select>
</div>
<button class="ui primary button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_migrate"}}');">
<button class="ui primary button show-modal" data-modal="#migrate-fallback-modal" type="button">
{{svg "octicon-sync" 16}} {{ctx.Locale.Tr "vault.migrate_from_fallback_button"}}
</button>
</form>
@@ -86,7 +86,7 @@
<div class="ui divider"></div>
{{end}}
<form class="ui form" action="{{.RepoLink}}/vault/migrate-key" method="post">
<form class="ui form ignore-dirty" id="migrate-manual-form" action="{{.RepoLink}}/vault/migrate-key" method="post">
{{.CsrfTokenHtml}}
<div class="required field">
<label>{{ctx.Locale.Tr "vault.old_master_key"}}</label>
@@ -103,7 +103,7 @@
</select>
<p class="help">{{ctx.Locale.Tr "vault.migration_scope_help"}}</p>
</div>
<button class="ui {{if .HasDedicatedMasterKey}}{{else}}primary {{end}}button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_migrate"}}');">
<button class="ui {{if .HasDedicatedMasterKey}}{{else}}primary {{end}}button show-modal" data-modal="#migrate-manual-modal" type="button">
{{svg "octicon-sync" 16}} {{ctx.Locale.Tr "vault.start_migration"}}
</button>
<a class="ui button" href="{{.RepoLink}}/vault">
@@ -119,9 +119,9 @@
</h5>
<p>{{ctx.Locale.Tr "vault.dek_rotation_description"}}</p>
{{if .IsEnterprise}}
<form class="ui form" action="{{.RepoLink}}/vault/rotate-key" method="post">
<form class="ui form ignore-dirty" id="rotate-dek-form" action="{{.RepoLink}}/vault/rotate-key" method="post">
{{.CsrfTokenHtml}}
<button class="ui button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_rotate"}}');">
<button class="ui button show-modal" data-modal="#rotate-dek-modal" type="button">
{{svg "octicon-sync" 16}} {{ctx.Locale.Tr "vault.rotate_dek"}}
</button>
</form>
@@ -132,4 +132,42 @@
{{end}}
</div>
{{end}}
{{if .HasDedicatedMasterKey}}
<div class="ui small modal" id="migrate-fallback-modal">
<div class="header">{{svg "octicon-sync"}} {{ctx.Locale.Tr "vault.migrate_from_fallback"}}</div>
<div class="content">
<p>{{ctx.Locale.Tr "vault.confirm_migrate_fallback"}}</p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui primary button" onclick="document.getElementById('migrate-fallback-form').submit();">{{ctx.Locale.Tr "vault.start_migration"}}</button>
</div>
</div>
{{end}}
<div class="ui small modal" id="migrate-manual-modal">
<div class="header">{{svg "octicon-key"}} {{ctx.Locale.Tr "vault.manual_migration"}}</div>
<div class="content">
<p>{{ctx.Locale.Tr "vault.confirm_migrate"}}</p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui primary button" onclick="document.getElementById('migrate-manual-form').submit();">{{ctx.Locale.Tr "vault.start_migration"}}</button>
</div>
</div>
{{if and .IsRepoAdmin .IsEnterprise}}
<div class="ui small modal" id="rotate-dek-modal">
<div class="header">{{svg "octicon-sync"}} {{ctx.Locale.Tr "vault.dek_rotation_title"}}</div>
<div class="content">
<p>{{ctx.Locale.Tr "vault.confirm_rotate"}}</p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui primary button" onclick="document.getElementById('rotate-dek-form').submit();">{{ctx.Locale.Tr "vault.rotate_dek"}}</button>
</div>
</div>
{{end}}
{{template "repo/vault/layout_footer" .}}

View File

@@ -65,8 +65,11 @@
<tr{{if .IsDeleted}} class="negative"{{end}}>
<td>
<a href="{{$.RepoLink}}/vault/secrets/{{.Name}}">
{{svg "octicon-key" 16}} <strong>{{.Name}}</strong>
{{if eq .EncryptionMode "lockbox"}}{{svg "octicon-lock" 16}}{{else}}{{svg "octicon-key" 16}}{{end}} <strong>{{.Name}}</strong>
</a>
{{if eq .EncryptionMode "lockbox"}}
<span class="ui tiny blue label" data-tooltip="End-to-end encrypted">{{svg "octicon-shield-lock" 10}} Lock-Box</span>
{{end}}
{{if .Description}}
<br><small class="text grey">{{.Description}}</small>
{{end}}

View File

@@ -119,9 +119,9 @@
<td>{{.UsedCount}}</td>
<td class="right aligned">
{{if not .IsRevoked}}
<form class="ui inline" action="{{$.RepoLink}}/vault/tokens/{{.ID}}/revoke" method="post">
<form class="ui inline" id="revoke-form-{{.ID}}" action="{{$.RepoLink}}/vault/tokens/{{.ID}}/revoke" method="post">
{{$.CsrfTokenHtml}}
<button class="ui tiny red button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_revoke_token"}}');">
<button class="ui tiny red button show-modal" type="button" data-modal="#revoke-token-modal" data-token-id="{{.ID}}">
{{svg "octicon-x" 14}} {{ctx.Locale.Tr "vault.revoke"}}
</button>
</form>
@@ -142,6 +142,17 @@
{{end}}
</div>
<div class="ui small modal" id="revoke-token-modal">
<div class="header">{{svg "octicon-x"}} {{ctx.Locale.Tr "vault.revoke"}}</div>
<div class="content">
<p>{{ctx.Locale.Tr "vault.confirm_revoke_token"}}</p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui red button" id="revoke-confirm">{{ctx.Locale.Tr "vault.revoke"}}</button>
</div>
</div>
<script>
document.getElementById('new-token-btn')?.addEventListener('click', function() {
document.getElementById('new-token-form').style.display = 'block';
@@ -157,5 +168,20 @@ document.getElementById('copy-token')?.addEventListener('click', function() {
const input = document.getElementById('new-token-value');
navigator.clipboard.writeText(input.value);
});
// Revoke token modal
(function() {
let currentTokenId = null;
document.querySelectorAll('[data-modal="#revoke-token-modal"]').forEach(btn => {
btn.addEventListener('click', function() {
currentTokenId = this.getAttribute('data-token-id');
});
});
document.getElementById('revoke-confirm')?.addEventListener('click', function() {
if (currentTokenId) {
document.getElementById('revoke-form-' + currentTokenId).submit();
}
});
})();
</script>
{{template "repo/vault/layout_footer" .}}

View File

@@ -52,10 +52,10 @@
{{svg "octicon-diff" 14}} {{ctx.Locale.Tr "vault.compare"}}
</a>
{{if and $.CanWrite (ne .Version $.Secret.CurrentVersion)}}
<form class="ui inline tw-inline" action="{{$.RepoLink}}/vault/secrets/{{$.Secret.Name}}/rollback" method="post">
<form class="ui inline tw-inline" id="rollback-form-{{.Version}}" action="{{$.RepoLink}}/vault/secrets/{{$.Secret.Name}}/rollback" method="post">
{{$.CsrfTokenHtml}}
<input type="hidden" name="version" value="{{.Version}}">
<button class="ui button" type="submit" onclick="return confirm('{{ctx.Locale.Tr "vault.confirm_rollback" .Version}}');">
<button class="ui button show-modal" type="button" data-modal="#rollback-modal" data-version="{{.Version}}">
{{svg "octicon-history" 14}} {{ctx.Locale.Tr "vault.rollback"}}
</button>
</form>
@@ -75,4 +75,33 @@
</div>
{{end}}
</div>
{{if .CanWrite}}
<div class="ui small modal" id="rollback-modal">
<div class="header">{{svg "octicon-history"}} {{ctx.Locale.Tr "vault.rollback"}}</div>
<div class="content">
<p id="rollback-message"></p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui primary button" id="rollback-confirm">{{ctx.Locale.Tr "vault.rollback"}}</button>
</div>
</div>
<script>
(function() {
let currentVersion = null;
document.querySelectorAll('[data-modal="#rollback-modal"]').forEach(btn => {
btn.addEventListener('click', function() {
currentVersion = this.getAttribute('data-version');
document.getElementById('rollback-message').textContent = '{{ctx.Locale.Tr "vault.confirm_rollback" 0}}'.replace('0', currentVersion);
});
});
document.getElementById('rollback-confirm')?.addEventListener('click', function() {
if (currentVersion) {
document.getElementById('rollback-form-' + currentVersion).submit();
}
});
})();
</script>
{{end}}
{{template "repo/vault/layout_footer" .}}

View File

@@ -3,7 +3,12 @@
<div class="tw-flex tw-items-start tw-justify-between tw-gap-4">
<div>
<h4 class="ui header tw-mb-0">
{{if eq .Secret.EncryptionMode "lockbox"}}
{{svg "octicon-lock" 20}} {{.Secret.Name}}
<span class="ui blue label" data-tooltip="End-to-end encrypted. Use CLI/SDK to decrypt.">{{svg "octicon-shield-lock" 12}} Lock-Box</span>
{{else}}
{{svg "octicon-key" 20}} {{.Secret.Name}}
{{end}}
{{if .Secret.IsDeleted}}
<span class="ui red label">{{ctx.Locale.Tr "vault.deleted"}}</span>
{{end}}
@@ -50,7 +55,52 @@
</div>
</div>
{{if .CanWrite}}
{{if eq .Secret.EncryptionMode "lockbox"}}
<!-- Lockbox secret - show locked state -->
<div class="ui segment tw-text-center">
<div class="tw-py-8">
{{svg "octicon-lock" 64 "tw-text-blue-500"}}
<h3 class="ui header tw-mt-4">Lock-Box Protected</h3>
<p class="tw-text-secondary tw-max-w-md tw-mx-auto">
This secret is end-to-end encrypted. The server cannot decrypt it.
Use the CLI or SDK with your passphrase to access the value.
</p>
<div class="ui divider"></div>
<div class="ui secondary segment tw-text-left tw-max-w-lg tw-mx-auto">
<p class="tw-font-semibold tw-mb-2">CLI Usage:</p>
<code class="tw-block tw-p-2 tw-bg-gray-100 dark:tw-bg-gray-800 tw-rounded tw-font-mono tw-text-sm">
vault-resolve get --lockbox {{.Secret.Name}}
</code>
<p class="tw-font-semibold tw-mb-2 tw-mt-4">Go SDK:</p>
<code class="tw-block tw-p-2 tw-bg-gray-100 dark:tw-bg-gray-800 tw-rounded tw-font-mono tw-text-sm">
value, err := client.GetLockbox(ctx, "{{.Secret.Name}}", passphrase)
</code>
</div>
{{if .CanWrite}}
<div class="tw-mt-4">
{{if .Secret.IsDeleted}}
<button class="ui green button" type="button" onclick="document.getElementById('restore-form').submit();">
{{svg "octicon-history" 16}} {{ctx.Locale.Tr "vault.restore"}}
</button>
{{else}}
<button class="ui red button show-modal" type="button" data-modal="#delete-secret-modal">
{{svg "octicon-trash" 16}} {{ctx.Locale.Tr "vault.delete"}}
</button>
{{end}}
</div>
{{if .Secret.IsDeleted}}
<form id="restore-form" class="tw-hidden" action="{{.RepoLink}}/vault/secrets/{{.Secret.Name}}/restore" method="post">
{{.CsrfTokenHtml}}
</form>
{{else}}
<form id="delete-form" class="tw-hidden" action="{{.RepoLink}}/vault/secrets/{{.Secret.Name}}/delete" method="post">
{{.CsrfTokenHtml}}
</form>
{{end}}
{{end}}
</div>
</div>
{{else if .CanWrite}}
<!-- Editor view for users who can write -->
<div class="ui segment">
<h5 class="ui header">{{ctx.Locale.Tr "vault.edit_secret"}}</h5>
@@ -92,7 +142,7 @@
{{svg "octicon-history" 16}} {{ctx.Locale.Tr "vault.restore"}}
</button>
{{else}}
<button class="ui red button" type="button" onclick="if(confirm('{{ctx.Locale.Tr "vault.confirm_delete"}}')) document.getElementById('delete-form').submit();">
<button class="ui red button show-modal" type="button" data-modal="#delete-secret-modal">
{{svg "octicon-trash" 16}} {{ctx.Locale.Tr "vault.delete"}}
</button>
{{end}}
@@ -125,8 +175,8 @@
});
})();
</script>
{{else}}
<!-- Read-only view for users who cannot write -->
{{else if ne .Secret.EncryptionMode "lockbox"}}
<!-- Read-only view for users who cannot write (standard secrets only) -->
<div class="ui segment">
<div class="tw-flex tw-items-center tw-justify-between tw-mb-4">
<h5 class="ui header tw-mb-0">{{ctx.Locale.Tr "vault.secret_value"}}</h5>
@@ -238,4 +288,17 @@
</table>
</div>
{{end}}
{{if not .Secret.IsDeleted}}
<div class="ui small modal" id="delete-secret-modal">
<div class="header">{{svg "octicon-trash"}} {{ctx.Locale.Tr "vault.delete_secret"}}</div>
<div class="content">
<p>{{ctx.Locale.Tr "vault.confirm_delete"}}</p>
</div>
<div class="actions">
<button class="ui cancel button">{{ctx.Locale.Tr "cancel"}}</button>
<button class="ui red button" onclick="document.getElementById('delete-form').submit();">{{ctx.Locale.Tr "vault.delete"}}</button>
</div>
</div>
{{end}}
{{template "repo/vault/layout_footer" .}}