feat: encryption password prompt + --encrypt flag
- PasswordPromptTab: setup/unlock modes, password + confirm fields - --encrypt flag: shows password prompt on TUI startup - Model.ShowEncryptPrompt() triggers prompt on first render - passwordSetMsg stores password in Model.storagePassword - Tab interface: added Close() method to PasswordPromptTab
This commit is contained in:
@@ -13,6 +13,8 @@ import (
|
|||||||
"git.tukangketik.id/swanadiva/hostkeeper/pkg/tui"
|
"git.tukangketik.id/swanadiva/hostkeeper/pkg/tui"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var encryptFlag bool
|
||||||
|
|
||||||
// tuiCmd represents the tui command
|
// tuiCmd represents the tui command
|
||||||
var tuiCmd = &cobra.Command{
|
var tuiCmd = &cobra.Command{
|
||||||
Use: "tui",
|
Use: "tui",
|
||||||
@@ -23,6 +25,7 @@ var tuiCmd = &cobra.Command{
|
|||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.AddCommand(tuiCmd)
|
rootCmd.AddCommand(tuiCmd)
|
||||||
|
tuiCmd.Flags().BoolVar(&encryptFlag, "encrypt", false, "Enable AES-256-GCM encryption for sensitive data")
|
||||||
}
|
}
|
||||||
|
|
||||||
func runTUI(cmd *cobra.Command, args []string) error {
|
func runTUI(cmd *cobra.Command, args []string) error {
|
||||||
@@ -59,6 +62,11 @@ func runTUI(cmd *cobra.Command, args []string) error {
|
|||||||
model.SetKnownHosts(kh)
|
model.SetKnownHosts(kh)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If --encrypt flag, show password prompt first
|
||||||
|
if encryptFlag {
|
||||||
|
model.ShowEncryptPrompt()
|
||||||
|
}
|
||||||
|
|
||||||
p := tea.NewProgram(model)
|
p := tea.NewProgram(model)
|
||||||
model.SetProgram(p)
|
model.SetProgram(p)
|
||||||
if _, err := p.Run(); err != nil {
|
if _, err := p.Run(); err != nil {
|
||||||
|
|||||||
@@ -178,3 +178,6 @@ func saveHostCmd(host *models.Host, dataDir string) tea.Cmd {
|
|||||||
return saveHostResultMsg{host: host}
|
return saveHostResultMsg{host: host}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openEncryptPromptMsg shows the encryption setup prompt
|
||||||
|
type openEncryptPromptMsg struct{}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
)
|
||||||
|
|
||||||
|
// passwordPromptMode determines what the password prompt is for
|
||||||
|
type passwordPromptMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
// passwordModeSetup — first time enabling encryption, ask for new password
|
||||||
|
passwordModeSetup passwordPromptMode = iota
|
||||||
|
// passwordModeUnlock — encryption already enabled, ask for existing password
|
||||||
|
passwordModeUnlock
|
||||||
|
)
|
||||||
|
|
||||||
|
// PasswordPromptTab shows a password prompt for encryption setup or unlock
|
||||||
|
type PasswordPromptTab struct {
|
||||||
|
mode passwordPromptMode
|
||||||
|
inputs []textinput.Model
|
||||||
|
focus int
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
err error
|
||||||
|
dataDir string
|
||||||
|
onComplete func(password string) // called with password on success
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPasswordPromptTab creates a new password prompt tab
|
||||||
|
func NewPasswordPromptTab(mode passwordPromptMode, dataDir string, onComplete func(string)) *PasswordPromptTab {
|
||||||
|
p := &PasswordPromptTab{
|
||||||
|
mode: mode,
|
||||||
|
dataDir: dataDir,
|
||||||
|
onComplete: onComplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password field
|
||||||
|
passwordInput := textinput.New()
|
||||||
|
passwordInput.Placeholder = "Enter master password"
|
||||||
|
passwordInput.EchoMode = textinput.EchoPassword
|
||||||
|
passwordInput.EchoCharacter = '•'
|
||||||
|
passwordInput.Focus()
|
||||||
|
|
||||||
|
// Confirm password field (only for setup mode)
|
||||||
|
confirmInput := textinput.New()
|
||||||
|
confirmInput.Placeholder = "Confirm password"
|
||||||
|
confirmInput.EchoMode = textinput.EchoPassword
|
||||||
|
confirmInput.EchoCharacter = '•'
|
||||||
|
|
||||||
|
if mode == passwordModeSetup {
|
||||||
|
p.inputs = []textinput.Model{passwordInput, confirmInput}
|
||||||
|
} else {
|
||||||
|
p.inputs = []textinput.Model{passwordInput}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PasswordPromptTab) Name() string {
|
||||||
|
if p.mode == passwordModeSetup {
|
||||||
|
return "Setup Encryption"
|
||||||
|
}
|
||||||
|
return "Unlock Storage"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PasswordPromptTab) Init() tea.Cmd {
|
||||||
|
return textinput.Blink
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PasswordPromptTab) Update(msg tea.Msg) (Tab, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
p.width = msg.Width
|
||||||
|
p.height = msg.Height
|
||||||
|
return p, nil
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
switch msg.String() {
|
||||||
|
case "tab", "down":
|
||||||
|
p.focus++
|
||||||
|
if p.focus >= len(p.inputs) {
|
||||||
|
p.focus = 0
|
||||||
|
}
|
||||||
|
for i := range p.inputs {
|
||||||
|
if i == p.focus {
|
||||||
|
p.inputs[i].Focus()
|
||||||
|
} else {
|
||||||
|
p.inputs[i].Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
|
||||||
|
case "shift+tab", "up":
|
||||||
|
p.focus--
|
||||||
|
if p.focus < 0 {
|
||||||
|
p.focus = len(p.inputs) - 1
|
||||||
|
}
|
||||||
|
for i := range p.inputs {
|
||||||
|
if i == p.focus {
|
||||||
|
p.inputs[i].Focus()
|
||||||
|
} else {
|
||||||
|
p.inputs[i].Blur()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
|
||||||
|
case "enter":
|
||||||
|
password := p.inputs[0].Value()
|
||||||
|
if password == "" {
|
||||||
|
p.err = fmt.Errorf("password cannot be empty")
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.mode == passwordModeSetup && len(p.inputs) > 1 {
|
||||||
|
confirm := p.inputs[1].Value()
|
||||||
|
if password != confirm {
|
||||||
|
p.err = fmt.Errorf("passwords do not match")
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success — call onComplete
|
||||||
|
if p.onComplete != nil {
|
||||||
|
p.onComplete(password)
|
||||||
|
}
|
||||||
|
return p, func() tea.Msg { return passwordSetMsg{} }
|
||||||
|
|
||||||
|
case "esc":
|
||||||
|
// Cancel — go back or quit
|
||||||
|
return p, func() tea.Msg { return closeFormMsg{} }
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Update current input
|
||||||
|
var cmd tea.Cmd
|
||||||
|
p.inputs[p.focus], cmd = p.inputs[p.focus].Update(msg)
|
||||||
|
return p, cmd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PasswordPromptTab) Close() {
|
||||||
|
// Nothing to clean up
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PasswordPromptTab) View() string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
title := "Setup Master Password"
|
||||||
|
if p.mode == passwordModeUnlock {
|
||||||
|
title = "Enter Master Password"
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
|
||||||
|
AppTitleStyle.Render(title)))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
|
||||||
|
if p.mode == passwordModeSetup {
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
|
||||||
|
SubtitleStyle.Render("Encrypt all sensitive data (passwords, keys) with AES-256")))
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
|
||||||
|
SubtitleStyle.Render("You will need this password to access your data")))
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input fields
|
||||||
|
contentW := p.width - 8
|
||||||
|
if contentW > 60 {
|
||||||
|
contentW = 60
|
||||||
|
}
|
||||||
|
|
||||||
|
box := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("62")).
|
||||||
|
Padding(1, 2).
|
||||||
|
Width(contentW)
|
||||||
|
|
||||||
|
var fields strings.Builder
|
||||||
|
for i, input := range p.inputs {
|
||||||
|
label := "Password:"
|
||||||
|
if i == 1 {
|
||||||
|
label = "Confirm:"
|
||||||
|
}
|
||||||
|
fields.WriteString(SubtitleStyle.Render(" " + label))
|
||||||
|
fields.WriteString("\n")
|
||||||
|
fields.WriteString(input.View())
|
||||||
|
fields.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, box.Render(fields.String())))
|
||||||
|
|
||||||
|
if p.err != nil {
|
||||||
|
b.WriteString("\n")
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center,
|
||||||
|
ErrorStyle.Render(p.err.Error())))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
footerText := "Enter:confirm Esc:cancel Tab:next field"
|
||||||
|
b.WriteString("\n\n")
|
||||||
|
for _, line := range strings.Split(wrapFooter(footerText, p.width), "\n") {
|
||||||
|
b.WriteString(lipgloss.PlaceHorizontal(p.width, lipgloss.Center, SubtitleStyle.Render(line)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordSetMsg is sent when password is successfully set
|
||||||
|
type passwordSetMsg struct {
|
||||||
|
password string
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ type Model struct {
|
|||||||
// Security
|
// Security
|
||||||
storagePassword string
|
storagePassword string
|
||||||
knownHosts *knownhosts.KnownHosts
|
knownHosts *knownhosts.KnownHosts
|
||||||
|
showEncryptPrompt bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new TUI model
|
// New creates a new TUI model
|
||||||
@@ -50,6 +51,15 @@ func New() *Model {
|
|||||||
|
|
||||||
// Init initializes the TUI
|
// Init initializes the TUI
|
||||||
func (m *Model) Init() tea.Cmd {
|
func (m *Model) Init() tea.Cmd {
|
||||||
|
if m.showEncryptPrompt {
|
||||||
|
// Show password prompt tab
|
||||||
|
tab := NewPasswordPromptTab(passwordModeSetup, m.dataDir, func(password string) {
|
||||||
|
m.storagePassword = password
|
||||||
|
})
|
||||||
|
cmd := m.tabs.Add(tab)
|
||||||
|
m.showEncryptPrompt = false
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
return m.tabs.Init()
|
return m.tabs.Init()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +78,11 @@ func (m *Model) SetKnownHosts(kh *knownhosts.KnownHosts) {
|
|||||||
m.knownHosts = kh
|
m.knownHosts = kh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShowEncryptPrompt sets a flag to show the encryption setup prompt on first render
|
||||||
|
func (m *Model) ShowEncryptPrompt() {
|
||||||
|
m.showEncryptPrompt = true
|
||||||
|
}
|
||||||
|
|
||||||
// Update handles messages and updates the model
|
// Update handles messages and updates the model
|
||||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
@@ -101,6 +116,20 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
cmd := m.tabs.Add(tab)
|
cmd := m.tabs.Add(tab)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
|
case passwordSetMsg:
|
||||||
|
// Password was set — store it and proceed to host list
|
||||||
|
m.storagePassword = msg.password
|
||||||
|
// Close the password prompt tab, host list should already be there
|
||||||
|
return m, nil
|
||||||
|
|
||||||
|
case openEncryptPromptMsg:
|
||||||
|
// Show password setup prompt
|
||||||
|
tab := NewPasswordPromptTab(passwordModeSetup, m.dataDir, func(password string) {
|
||||||
|
m.storagePassword = password
|
||||||
|
})
|
||||||
|
cmd := m.tabs.Add(tab)
|
||||||
|
return m, cmd
|
||||||
|
|
||||||
case openSFTPMsg:
|
case openSFTPMsg:
|
||||||
tab := NewSFTPBrowserTab(msg.host, m.dataDir)
|
tab := NewSFTPBrowserTab(msg.host, m.dataDir)
|
||||||
if m.program != nil {
|
if m.program != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user