Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
Hadi
2026-05-26 19:59:34 +02:00
commit f3dce1f4ab
29 changed files with 2218 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
package config
import (
_ "embed"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
//go:embed default_config.yaml
var defaultConfig []byte
type Config struct {
Keybindings Keybindings `mapstructure:"keybindings"`
}
var Global *Config
func Load(path string) error {
var defaults map[string]any
if err := yaml.Unmarshal(defaultConfig, &defaults); err != nil {
return fmt.Errorf("default config: %w", err)
}
for k, v := range flatten("", defaults) {
viper.SetDefault(k, v)
}
viper.SetConfigType("yaml")
viper.SetConfigFile(path)
if err := viper.ReadInConfig(); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
}
Global = &Config{}
return viper.Unmarshal(Global)
}
func WriteDefaultConfig(path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create config dir: %w", err)
}
if err := os.WriteFile(path, defaultConfig, 0o600); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}
func flatten(prefix string, m map[string]any) map[string]any {
out := make(map[string]any)
for k, v := range m {
key := k
if prefix != "" {
key = prefix + "." + k
}
if nested, ok := v.(map[string]any); ok {
for nk, nv := range flatten(key, nested) {
out[nk] = nv
}
} else {
out[key] = v
}
}
return out
}
+11
View File
@@ -0,0 +1,11 @@
keybindings:
quit: "ctrl+c,q"
cycle_focus: "tab"
edit: "e,enter"
edit_external: "E"
docs: "d"
help_toggle: "?"
clear: "x"
reset: "r"
copy: "y,ctrl+shift+c"
paste: "p,ctrl+shift+v"
+14
View File
@@ -0,0 +1,14 @@
package config
type Keybindings struct {
Quit string `mapstructure:"quit"`
CycleFocus string `mapstructure:"cycle_focus"`
Edit string `mapstructure:"edit"`
EditExternal string `mapstructure:"edit_external"`
Docs string `mapstructure:"docs"`
HelpToggle string `mapstructure:"help_toggle"`
Clear string `mapstructure:"clear"`
Reset string `mapstructure:"reset"`
Copy string `mapstructure:"copy"`
Paste string `mapstructure:"paste"`
}
+90
View File
@@ -0,0 +1,90 @@
package highlight
import (
"strings"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"image/color"
)
func paint(c color.Color, s string) string {
return lipgloss.NewStyle().Foreground(c).Render(s)
}
// JSON applies syntax coloring to a pretty-printed JSON string using ilovetui colors.
func JSON(s string) string {
var out strings.Builder
i, n := 0, len(s)
for i < n {
ch := s[i]
switch {
case ch == '"':
j := i + 1
for j < n {
if s[j] == '\\' {
j += 2
continue
}
if s[j] == '"' {
j++
break
}
j++
}
str := s[i:j]
k := j
for k < n && (s[k] == ' ' || s[k] == '\t') {
k++
}
if k < n && s[k] == ':' {
out.WriteString(paint(ilovetui.S.Primary, str))
} else {
out.WriteString(paint(ilovetui.S.Success, str))
}
i = j
case (ch >= '0' && ch <= '9') || (ch == '-' && i+1 < n && s[i+1] >= '0' && s[i+1] <= '9'):
j := i
if s[j] == '-' {
j++
}
for j < n && ((s[j] >= '0' && s[j] <= '9') || s[j] == '.' || s[j] == 'e' || s[j] == 'E' || s[j] == '+' || s[j] == '-') {
j++
}
out.WriteString(paint(ilovetui.S.Warning, s[i:j]))
i = j
case i+4 <= n && s[i:i+4] == "true":
out.WriteString(paint(ilovetui.S.Error, "true"))
i += 4
case i+5 <= n && s[i:i+5] == "false":
out.WriteString(paint(ilovetui.S.Error, "false"))
i += 5
case i+4 <= n && s[i:i+4] == "null":
out.WriteString(paint(ilovetui.S.Muted, "null"))
i += 4
case ch == '{' || ch == '}' || ch == '[' || ch == ']' || ch == ':' || ch == ',':
out.WriteString(paint(ilovetui.S.Subtle, string(ch)))
i++
default:
out.WriteByte(ch)
i++
}
}
return out.String()
}
// JWT colors the three dot-separated parts of a JWT token in distinct colors.
func JWT(s string) string {
dot := paint(ilovetui.S.Subtle, ".")
parts := strings.SplitN(s, ".", 3)
switch len(parts) {
case 1:
return paint(ilovetui.S.Primary, parts[0])
case 2:
return paint(ilovetui.S.Primary, parts[0]) + dot + paint(ilovetui.S.Success, parts[1])
default:
return paint(ilovetui.S.Primary, parts[0]) + dot +
paint(ilovetui.S.Success, parts[1]) + dot +
paint(ilovetui.S.Warning, parts[2])
}
}
+151
View File
@@ -0,0 +1,151 @@
package jwt
import (
"crypto/hmac"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"fmt"
"hash"
"strings"
)
// Decode splits a JWT and returns pretty-printed header and payload JSON.
func Decode(token string) (header, payload string, err error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", "", fmt.Errorf("expected 3 parts, got %d", len(parts))
}
hdrBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", "", fmt.Errorf("header: %w", err)
}
plBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return "", "", fmt.Errorf("payload: %w", err)
}
var hdrObj any
if err := json.Unmarshal(hdrBytes, &hdrObj); err != nil {
return "", "", fmt.Errorf("header JSON: %w", err)
}
var plObj any
if err := json.Unmarshal(plBytes, &plObj); err != nil {
return "", "", fmt.Errorf("payload JSON: %w", err)
}
hdrPretty, _ := json.MarshalIndent(hdrObj, "", " ")
plPretty, _ := json.MarshalIndent(plObj, "", " ")
return string(hdrPretty), string(plPretty), nil
}
// Encode builds and signs a JWT from raw JSON header and payload strings.
func Encode(header, payload, secret string) (string, error) {
var hdrObj map[string]any
if err := json.Unmarshal([]byte(header), &hdrObj); err != nil {
return "", fmt.Errorf("header JSON: %w", err)
}
var plObj any
if err := json.Unmarshal([]byte(payload), &plObj); err != nil {
return "", fmt.Errorf("payload JSON: %w", err)
}
hdrCompact, _ := json.Marshal(hdrObj)
plCompact, _ := json.Marshal(plObj)
hdrB64 := base64.RawURLEncoding.EncodeToString(hdrCompact)
plB64 := base64.RawURLEncoding.EncodeToString(plCompact)
signingInput := hdrB64 + "." + plB64
alg, _ := hdrObj["alg"].(string)
h, err := hashForAlg(alg)
if err != nil {
return signingInput + ".", fmt.Errorf("%w", err)
}
if h == nil {
return signingInput + ".", nil
}
mac := hmac.New(h, []byte(secret))
mac.Write([]byte(signingInput))
sig := mac.Sum(nil)
return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil
}
// Verify checks whether the JWT signature is valid for the given secret.
// Returns (false, nil) for an invalid signature, (true, nil) for valid.
func Verify(token, secret string) (bool, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return false, fmt.Errorf("expected 3 parts, got %d", len(parts))
}
hdrBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return false, fmt.Errorf("header encoding: %w", err)
}
var hdrObj map[string]any
if err := json.Unmarshal(hdrBytes, &hdrObj); err != nil {
return false, fmt.Errorf("header JSON: %w", err)
}
alg, _ := hdrObj["alg"].(string)
h, err := hashForAlg(alg)
if err != nil {
return false, err
}
if h == nil {
return parts[2] == "", nil
}
signingInput := parts[0] + "." + parts[1]
mac := hmac.New(h, []byte(secret))
mac.Write([]byte(signingInput))
expected := mac.Sum(nil)
actual, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return false, fmt.Errorf("signature encoding: %w", err)
}
return hmac.Equal(actual, expected), nil
}
// Algorithm returns the "alg" claim from the JWT header, or "" if unreadable.
func Algorithm(token string) string {
parts := strings.SplitN(token, ".", 3)
if len(parts) < 1 {
return ""
}
hdrBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return ""
}
var hdrObj map[string]any
if err := json.Unmarshal(hdrBytes, &hdrObj); err != nil {
return ""
}
alg, _ := hdrObj["alg"].(string)
return alg
}
func hashForAlg(alg string) (func() hash.Hash, error) {
switch strings.ToUpper(alg) {
case "HS256":
return sha256.New, nil
case "HS384":
return sha512.New384, nil
case "HS512":
return sha512.New, nil
case "NONE", "":
return nil, nil
default:
return nil, fmt.Errorf("unsupported algorithm: %s", alg)
}
}
+75
View File
@@ -0,0 +1,75 @@
package keys
import (
"strings"
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/jwt-tui/internal/config"
)
type KeyMap struct {
Quit key.Binding
CycleFocus key.Binding
Edit key.Binding
EditExternal key.Binding
Docs key.Binding
HelpToggle key.Binding
Clear key.Binding
Reset key.Binding
Copy key.Binding
Paste key.Binding
}
var Keys *KeyMap
func Init(cfg *config.Config) {
kb := cfg.Keybindings
Keys = &KeyMap{
Quit: binding(kb.Quit, "quit"),
CycleFocus: binding(kb.CycleFocus, "cycle focus"),
Edit: binding(kb.Edit, "edit"),
EditExternal: binding(kb.EditExternal, "edit in $EDITOR"),
Docs: binding(kb.Docs, "docs"),
HelpToggle: binding(kb.HelpToggle, "help"),
Clear: binding(kb.Clear, "clear"),
Reset: binding(kb.Reset, "reset"),
Copy: binding(kb.Copy, "copy"),
Paste: binding(kb.Paste, "paste"),
}
}
func parseKeys(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if k := strings.TrimSpace(p); k != "" {
out = append(out, k)
}
}
return out
}
func ChunkByWidth(bindings []key.Binding, termWidth int) [][]key.Binding {
cols := termWidth / 26
if cols < 2 {
cols = 2
} else if cols > 7 {
cols = 7
}
perCol := (len(bindings) + cols - 1) / cols
var out [][]key.Binding
for i := 0; i < len(bindings); i += perCol {
end := i + perCol
if end > len(bindings) {
end = len(bindings)
}
out = append(out, bindings[i:end])
}
return out
}
func binding(s, help string) key.Binding {
keys := parseKeys(s)
display := strings.Join(keys, "/")
return key.NewBinding(key.WithKeys(keys...), key.WithHelp(display, help))
}
+39
View File
@@ -0,0 +1,39 @@
package style
import (
"strings"
"charm.land/lipgloss/v2"
)
func PanelContentH(totalH int) int {
h := totalH - 2
if h < 0 {
return 0
}
return h
}
// RenderWithTitle renders a bordered box with a title embedded in the top border.
// The title may contain ANSI color codes. width and height are the total outer dimensions.
func RenderWithTitle(border lipgloss.Style, title, content string, width, height int) string {
boxH := height - 1
if contentH := boxH - 1; contentH > 0 {
lines := strings.Split(content, "\n")
if len(lines) > contentH {
content = strings.Join(lines[:contentH], "\n")
}
}
box := border.BorderTop(false).Width(width).Height(boxH).Render(content)
boxWidth := lipgloss.Width(strings.SplitN(box, "\n", 2)[0])
titleW := lipgloss.Width(title) // strips ANSI for measurement
fillW := boxWidth - titleW - 4 // 4 = "╭ " + " " + "╮"
if fillW < 0 {
fillW = 0
}
bc := lipgloss.NewStyle().Foreground(border.GetBorderTopForeground())
topLine := bc.Render("╭ ") + title + bc.Render(" "+strings.Repeat("─", fillW)+"╮")
return lipgloss.JoinVertical(lipgloss.Left, topLine, box)
}
+123
View File
@@ -0,0 +1,123 @@
# JWT Reference
## Structure
A JSON Web Token is three Base64URL-encoded parts joined by dots:
```
header.payload.signature
```
- **Header**: algorithm and token type
- **Payload**: claims (statements about an entity)
- **Signature**: HMAC or RSA/ECDSA over header + payload
The header and payload are readable by anyone. JWTs are _signed_, not _encrypted_.
Use JWE if you need confidentiality.
## Header
```json
{
"alg": "HS256",
"typ": "JWT"
}
```
Common header parameters:
| Param | Description |
| ----- | ------------------------------------------ |
| `alg` | Signing algorithm (`HS256`, `RS256`, etc.) |
| `typ` | Token type: always `JWT` |
| `kid` | Key ID: hint for which key to use |
| `cty` | Content type: used for nested JWTs |
## Payload (Claims)
**Registered claims** (all optional, but recommended):
| Claim | Type | Description |
| ----- | ------ | --------------------------------------- |
| `iss` | string | Issuer: who created the token |
| `sub` | string | Subject: principal the token is about |
| `aud` | string | Audience: intended recipient(s) |
| `exp` | number | Expiration time (Unix timestamp) |
| `nbf` | number | Not before: token valid after this time |
| `iat` | number | Issued at (Unix timestamp) |
| `jti` | string | JWT ID: unique identifier |
**Private claims** are any additional fields agreed upon by the parties.
## Algorithms
| Algorithm | Type | Key type |
| --------- | -------------- | ------------------------- |
| `HS256` | HMAC + SHA-256 | Shared secret |
| `HS384` | HMAC + SHA-384 | Shared secret |
| `HS512` | HMAC + SHA-512 | Shared secret |
| `RS256` | RSA + SHA-256 | RSA key pair |
| `RS384` | RSA + SHA-384 | RSA key pair |
| `RS512` | RSA + SHA-512 | RSA key pair |
| `ES256` | ECDSA + P-256 | EC key pair |
| `ES384` | ECDSA + P-384 | EC key pair |
| `ES512` | ECDSA + P-521 | EC key pair |
| `none` | No signature | ⚠ Never use in production |
> This tool supports **HS256**, **HS384**, and **HS512**.
## Signature Computation
For HMAC algorithms:
```
signature = HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret
)
```
The final token:
```
base64url(header) + "." + base64url(payload) + "." + base64url(signature)
```
## Security
- **Never use `alg: none`**: disables signature verification entirely.
- Use **long, random secrets**: at least 256 bits (32 bytes) for HS256.
- Always validate **`exp`** (expiration) and **`nbf`** (not before).
- Validate **`iss`** and **`aud`** to prevent token reuse across services.
- The payload is **base64-encoded, not encrypted**: never store passwords or PII.
- Prefer **asymmetric algorithms** (RS256, ES256) for public-facing APIs.
- Store secrets in environment variables or a secrets manager, never in code.
## Brute-forcing a JWT Secret
If a token is signed with a weak HMAC secret, it can be recovered offline.
Both **hashcat** and **john** accept the raw JWT string as input:
```bash
# hashcat mode 16500 targets JWT (HS256/384/512)
hashcat -a 0 -m 16500 <token> wordlist.txt
# john the ripper
john --format=HMAC-SHA256 --wordlist=wordlist.txt jwt.txt
```
This only works against **HS\*** algorithms where the secret is a simple password or passphrase.
## Configuration
jwt-tui looks for a config file at `~/.config/jwt-tui/config.yaml`.
If the file does not exist the built-in defaults are used automatically.
To get a starting point you can edit, run:
```
jwt-tui --add-default-config
```
This writes the default config to `~/.config/jwt-tui/config.yaml` (or to the path given with `--config`).
You can then open that file in any text editor and change the values you want.
+463
View File
@@ -0,0 +1,463 @@
package ui
import (
_ "embed"
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textarea"
"charm.land/bubbles/v2/viewport"
tea "charm.land/bubbletea/v2"
"charm.land/glamour/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/jwt-tui/internal/highlight"
"github.com/anotherhadi/jwt-tui/internal/jwt"
"github.com/anotherhadi/jwt-tui/internal/keys"
"github.com/anotherhadi/jwt-tui/internal/style"
)
//go:embed docs.md
var jwtDocsMD string
// Panel indices in clockwise order starting top-left:
//
// top-left (0=JWT) → top-right (1=Header)
// ↓
// bot-left (3=Secret) ← bot-right (2=Payload)
const (
panelJWT = 0
panelHeader = 1
panelPayload = 2
panelSecret = 3
)
const exampleJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
var panelPlaceholders = [4]string{
exampleJWT,
"{\n \"alg\": \"HS256\",\n \"typ\": \"JWT\"\n}",
"{\n \"sub\": \"1234567890\",\n \"name\": \"John Doe\",\n \"iat\": 1516239022\n}",
"your-256-bit-secret",
}
var panelTAPlaceholders = [4]string{
exampleJWT,
"{\n \"alg\": \"HS256\",\n \"typ\": \"JWT\"\n}",
"{\n \"sub\": \"1234567890\",\n \"name\": \"John Doe\",\n \"iat\": 1516239022\n}",
"your-256-bit-secret",
}
type panelState struct {
vp viewport.Model
ta textarea.Model
editing bool
}
type keyMap struct {
CycleFocus key.Binding
Edit key.Binding
EditExternal key.Binding
Clear key.Binding
Reset key.Binding
Copy key.Binding
Paste key.Binding
Docs key.Binding
HelpToggle key.Binding
Quit key.Binding
width int
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.CycleFocus, k.Edit, k.EditExternal, k.HelpToggle, k.Quit}
}
func (k keyMap) FullHelp() [][]key.Binding {
all := []key.Binding{k.CycleFocus, k.Edit, k.EditExternal, k.Copy, k.Paste, k.Clear, k.Reset, k.Docs, k.Quit}
return keys.ChunkByWidth(all, k.width)
}
type docsKeyMap struct {
Close key.Binding
}
func (k docsKeyMap) ShortHelp() []key.Binding { return []key.Binding{k.Close} }
func (k docsKeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{{k.Close}} }
type Model struct {
panels [4]panelState
initial [4]string // per-panel initial values (for reset)
focus int
showDocs bool
docsVP viewport.Model
pendingEditorPanel int
pendingPastePanel int
sigValid *bool
sigStatus string
errMsg string
help help.Model
keymap keyMap
docsKeys docsKeyMap
width, height int
}
func New(initialToken, initialSecret string) Model {
token := strings.TrimSpace(initialToken)
secret := strings.TrimSpace(initialSecret)
var initVals [4]string
initVals[panelJWT] = token
initVals[panelSecret] = secret
if token != "" {
header, payload, _ := jwt.Decode(token)
initVals[panelHeader] = header
initVals[panelPayload] = payload
}
m := Model{
initial: initVals,
focus: panelJWT,
help: ilovetui.NewHelp(),
keymap: keyMap{
CycleFocus: keys.Keys.CycleFocus,
Edit: keys.Keys.Edit,
EditExternal: keys.Keys.EditExternal,
Clear: keys.Keys.Clear,
Reset: keys.Keys.Reset,
Copy: keys.Keys.Copy,
Paste: keys.Keys.Paste,
Docs: keys.Keys.Docs,
HelpToggle: keys.Keys.HelpToggle,
Quit: keys.Keys.Quit,
},
docsKeys: docsKeyMap{
Close: keys.Keys.Docs,
},
}
for i := range m.panels {
ta := ilovetui.NewTextarea(false)
ta.Placeholder = panelTAPlaceholders[i]
vp := ilovetui.NewViewport()
vp.SoftWrap = true
m.panels[i].ta = ta
m.panels[i].vp = vp
}
m.docsVP = ilovetui.NewViewport()
m.docsVP.SoftWrap = true
for i, val := range initVals {
m.panels[i].ta.SetValue(val)
if val != "" {
m.setViewportContent(i, val)
}
}
if token != "" {
m.revalidate()
}
return m
}
func (m Model) Init() tea.Cmd {
return nil
}
func (m *Model) helpHeight() int {
if !m.help.ShowAll {
return 1
}
max := 0
for _, col := range m.keymap.FullHelp() {
if len(col) > max {
max = len(col)
}
}
return max
}
func (m *Model) setViewportContent(panel int, raw string) {
var content string
switch panel {
case panelHeader, panelPayload:
content = highlight.JSON(raw)
case panelJWT:
content = highlight.JWT(raw)
default:
content = lipgloss.NewStyle().Foreground(ilovetui.S.Text).Render(raw)
}
m.panels[panel].vp.SetContent(content)
}
func (m *Model) recalcSizes() {
if m.width == 0 || m.height == 0 {
return
}
leftW := m.width / 2
rightW := m.width - leftW
helpH := m.helpHeight()
availH := m.height - helpH - 1 // -1 for the error line
topH := availH / 2
bottomH := availH - topH
setPanel := func(idx, w, h int) {
cw := max(1, w-2)
ch := max(1, h-2)
m.panels[idx].vp.SetWidth(cw)
m.panels[idx].vp.SetHeight(ch)
m.panels[idx].ta.SetWidth(cw)
m.panels[idx].ta.SetHeight(ch)
}
setPanel(panelJWT, leftW, topH)
setPanel(panelHeader, rightW, topH)
setPanel(panelPayload, rightW, bottomH)
setPanel(panelSecret, leftW, bottomH)
m.help.SetWidth(m.width)
docsAvailH := m.height - 1
m.docsVP.SetHeight(max(1, docsAvailH-2))
m.docsVP.SetWidth(max(1, m.width-4))
}
func (m *Model) revalidate() {
jwtVal := m.panels[panelJWT].ta.Value()
secVal := m.panels[panelSecret].ta.Value()
if jwtVal == "" {
m.sigValid = nil
m.sigStatus = ""
return
}
valid, err := jwt.Verify(jwtVal, secVal)
if err != nil {
m.sigValid = nil
m.sigStatus = ""
m.errMsg = err.Error()
return
}
m.errMsg = ""
m.sigValid = &valid
if valid {
m.sigStatus = "✓ Signature Verified"
} else {
m.sigStatus = "✗ Invalid Signature"
}
}
func (m *Model) decodeJWT() {
token := m.panels[panelJWT].ta.Value()
if token == "" {
m.sigValid = nil
m.sigStatus = ""
m.errMsg = ""
m.panels[panelHeader].ta.SetValue("")
m.panels[panelHeader].vp.SetContent("")
m.panels[panelPayload].ta.SetValue("")
m.panels[panelPayload].vp.SetContent("")
return
}
header, payload, err := jwt.Decode(token)
if err != nil {
m.sigValid = nil
m.sigStatus = ""
m.errMsg = err.Error()
m.panels[panelHeader].ta.SetValue("")
m.panels[panelHeader].vp.SetContent("")
m.panels[panelPayload].ta.SetValue("")
m.panels[panelPayload].vp.SetContent("")
return
}
m.errMsg = ""
m.panels[panelHeader].ta.SetValue(header)
m.panels[panelPayload].ta.SetValue(payload)
m.setViewportContent(panelHeader, header)
m.setViewportContent(panelPayload, payload)
m.revalidate()
}
func (m *Model) rebuildJWT() {
header := m.panels[panelHeader].ta.Value()
payload := m.panels[panelPayload].ta.Value()
if header == "" && payload == "" {
m.panels[panelJWT].ta.SetValue("")
m.panels[panelJWT].vp.SetContent("")
m.sigValid = nil
m.sigStatus = ""
m.errMsg = ""
return
}
token, err := jwt.Encode(header, payload, m.panels[panelSecret].ta.Value())
if err != nil {
m.sigValid = nil
m.sigStatus = ""
m.errMsg = err.Error()
return
}
m.errMsg = ""
m.panels[panelJWT].ta.SetValue(token)
m.setViewportContent(panelJWT, token)
m.revalidate()
}
func (m *Model) exitEditMode() {
p := &m.panels[m.focus]
if !p.editing {
return
}
p.editing = false
p.ta.Placeholder = panelTAPlaceholders[m.focus]
p.ta.Blur()
raw := p.ta.Value()
if raw != "" {
m.setViewportContent(m.focus, raw)
}
switch m.focus {
case panelHeader, panelPayload:
m.rebuildJWT()
case panelJWT:
m.decodeJWT()
case panelSecret:
m.revalidate()
}
}
func (m *Model) enterEditMode() tea.Cmd {
p := &m.panels[m.focus]
p.editing = true
p.ta.Placeholder = ""
return p.ta.Focus()
}
// clearPanel empties the focused panel and enters edit mode.
func (m *Model) clearPanel() tea.Cmd {
m.exitEditMode()
m.panels[m.focus].ta.SetValue("")
m.panels[m.focus].vp.SetContent("")
// Clearing JWT also wipes derived header/payload
if m.focus == panelJWT {
m.panels[panelHeader].ta.SetValue("")
m.panels[panelHeader].vp.SetContent("")
m.panels[panelPayload].ta.SetValue("")
m.panels[panelPayload].vp.SetContent("")
m.sigValid = nil
m.sigStatus = ""
} else {
switch m.focus {
case panelHeader, panelPayload:
m.rebuildJWT()
case panelSecret:
m.revalidate()
}
}
return m.enterEditMode()
}
// resetPanel restores the focused panel to its initial value.
func (m *Model) resetPanel() {
m.exitEditMode()
val := m.initial[m.focus]
m.panels[m.focus].ta.SetValue(val)
if val != "" {
m.setViewportContent(m.focus, val)
} else {
m.panels[m.focus].vp.SetContent("")
}
// Resetting JWT also restores derived header/payload from initial
if m.focus == panelJWT {
m.panels[panelHeader].ta.SetValue(m.initial[panelHeader])
if m.initial[panelHeader] != "" {
m.setViewportContent(panelHeader, m.initial[panelHeader])
} else {
m.panels[panelHeader].vp.SetContent("")
}
m.panels[panelPayload].ta.SetValue(m.initial[panelPayload])
if m.initial[panelPayload] != "" {
m.setViewportContent(panelPayload, m.initial[panelPayload])
} else {
m.panels[panelPayload].vp.SetContent("")
}
m.revalidate()
} else {
switch m.focus {
case panelHeader, panelPayload:
m.rebuildJWT()
case panelSecret:
m.revalidate()
}
}
}
func (m *Model) renderDocs() {
width := max(40, m.docsVP.Width())
renderer, err := glamour.NewTermRenderer(
glamour.WithStyles(ilovetui.GlamourStyleConfig()),
glamour.WithWordWrap(width),
)
if err != nil {
m.docsVP.SetContent(jwtDocsMD)
return
}
rendered, err := renderer.Render(jwtDocsMD)
if err != nil {
m.docsVP.SetContent(jwtDocsMD)
return
}
m.docsVP.SetContent(rendered)
m.docsVP.SetYOffset(0)
}
func (m Model) borderFor(panel int) lipgloss.Style {
if panel == m.focus && !m.showDocs {
return ilovetui.S.PanelFocused
}
return ilovetui.S.Panel
}
func (m Model) panelTitle(panel int, name string) string {
bc := lipgloss.NewStyle().Foreground(m.borderFor(panel).GetBorderTopForeground())
title := bc.Render(name)
if panel == m.focus && m.panels[panel].editing {
title += ilovetui.S.Faint.Render(" [edit]")
}
return title
}
func (m Model) secretTitle() string {
name := m.panelTitle(panelSecret, "Secret")
var sigStr string
if m.sigValid == nil {
sigStr = ilovetui.S.Faint.Render("·")
} else if *m.sigValid {
sigStr = lipgloss.NewStyle().Foreground(ilovetui.S.Success).Render("✓")
} else {
sigStr = lipgloss.NewStyle().Foreground(ilovetui.S.Error).Render("✗")
}
return name + ilovetui.S.Faint.Render(" · ") + sigStr
}
func (m *Model) renderPanelContent(panel int) string {
p := &m.panels[panel]
if p.editing {
return p.ta.View()
}
if p.ta.Value() == "" {
return ilovetui.S.Faint.Render(panelPlaceholders[panel])
}
return ilovetui.ViewportView(&p.vp)
}
func (m *Model) renderPanel(panel int, title string, w, h int) string {
return style.RenderWithTitle(m.borderFor(panel), title, m.renderPanelContent(panel), w, h)
}
+149
View File
@@ -0,0 +1,149 @@
package ui
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/jwt-tui/internal/keys"
"github.com/anotherhadi/jwt-tui/internal/util"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.keymap.width = msg.Width
m.recalcSizes()
if m.showDocs {
m.renderDocs()
}
return m, nil
case tea.ClipboardMsg:
content := msg.String()
if content != "" {
panel := m.pendingPastePanel
m.panels[panel].ta.SetValue(content)
m.setViewportContent(panel, content)
switch panel {
case panelHeader, panelPayload:
m.rebuildJWT()
case panelJWT:
m.decodeJWT()
case panelSecret:
m.revalidate()
}
}
return m, nil
case util.EditorFinishedMsg:
if msg.Err == nil && msg.Content != "" {
panel := m.pendingEditorPanel
m.panels[panel].ta.SetValue(msg.Content)
m.setViewportContent(panel, msg.Content)
switch panel {
case panelHeader, panelPayload:
m.rebuildJWT()
case panelJWT:
m.decodeJWT()
case panelSecret:
m.revalidate()
}
}
return m, nil
case tea.KeyPressMsg:
// Docs overlay: only scrolling, d or esc to close, ctrl+c to quit
if m.showDocs {
switch {
case key.Matches(msg, keys.Keys.Quit):
return m, tea.Quit
case key.Matches(msg, keys.Keys.Docs), msg.String() == "esc":
m.showDocs = false
default:
var cmd tea.Cmd
m.docsVP, cmd = m.docsVP.Update(msg)
return m, cmd
}
return m, nil
}
// In edit mode: esc and ctrl+c exit edit mode, everything else goes to the textarea
if m.panels[m.focus].editing {
if msg.String() == "esc" || msg.String() == "ctrl+c" {
m.exitEditMode()
return m, nil
}
p := &m.panels[m.focus]
prev := p.ta.Value()
var cmd tea.Cmd
p.ta, cmd = p.ta.Update(msg)
if p.ta.Value() != prev {
switch m.focus {
case panelHeader, panelPayload:
m.rebuildJWT()
case panelJWT:
m.decodeJWT()
case panelSecret:
m.revalidate()
}
}
return m, cmd
}
// View mode shortcuts
switch {
case key.Matches(msg, keys.Keys.Quit):
return m, tea.Quit
case key.Matches(msg, keys.Keys.HelpToggle):
m.help.ShowAll = !m.help.ShowAll
m.recalcSizes()
case key.Matches(msg, keys.Keys.Docs):
m.help.ShowAll = false
m.showDocs = true
m.renderDocs()
case key.Matches(msg, keys.Keys.CycleFocus):
m.focus = (m.focus + 1) % 4
case key.Matches(msg, keys.Keys.Edit):
return m, m.enterEditMode()
case key.Matches(msg, keys.Keys.EditExternal):
m.pendingEditorPanel = m.focus
return m, util.OpenExternalEditor(m.panels[m.focus].ta.Value())
case key.Matches(msg, keys.Keys.Copy):
return m, tea.SetClipboard(m.panels[m.focus].ta.Value())
case key.Matches(msg, keys.Keys.Paste):
m.pendingPastePanel = m.focus
return m, tea.ReadClipboard
case key.Matches(msg, keys.Keys.Clear):
return m, m.clearPanel()
case key.Matches(msg, keys.Keys.Reset):
m.resetPanel()
default:
var cmd tea.Cmd
m.panels[m.focus].vp, cmd = m.panels[m.focus].vp.Update(msg)
return m, cmd
}
return m, nil
default:
if m.panels[m.focus].editing {
p := &m.panels[m.focus]
var cmd tea.Cmd
p.ta, cmd = p.ta.Update(msg)
return m, cmd
}
var cmd tea.Cmd
m.panels[m.focus].vp, cmd = m.panels[m.focus].vp.Update(msg)
return m, cmd
}
}
+89
View File
@@ -0,0 +1,89 @@
package ui
import (
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
)
func (m Model) View() tea.View {
var content string
if m.width == 0 {
content = ""
} else if m.showDocs {
content = m.renderDocsView()
} else {
content = m.renderMainView()
}
v := tea.NewView(content)
v.AltScreen = true
return v
}
func (m Model) renderMainView() string {
leftW := m.width / 2
rightW := m.width - leftW
helpH := m.helpHeight()
availH := m.height - helpH - 1
topH := availH / 2
bottomH := availH - topH
// Layout (clockwise from top-left):
// top-left=JWT top-right=Header
// bot-left=Secret bot-right=Payload
jwtPanel := m.renderPanel(panelJWT, m.panelTitle(panelJWT, "Encoded"), leftW, topH)
headerPanel := m.renderPanel(panelHeader, m.panelTitle(panelHeader, "Header"), rightW, topH)
payloadPanel := m.renderPanel(panelPayload, m.panelTitle(panelPayload, "Payload"), rightW, bottomH)
secretPanel := m.renderPanel(panelSecret, m.secretTitle(), leftW, bottomH)
left := lipgloss.JoinVertical(lipgloss.Left, jwtPanel, secretPanel)
right := lipgloss.JoinVertical(lipgloss.Left, headerPanel, payloadPanel)
main := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
return lipgloss.JoinVertical(lipgloss.Left, main, m.renderErrorLine(), m.renderHelpBar())
}
func (m Model) renderDocsView() string {
docsBorder := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ilovetui.S.Subtle).
Padding(0, 1)
window := docsBorder.Render(ilovetui.ViewportView(&m.docsVP))
helpStr := m.help.View(m.docsKeys)
return lipgloss.JoinVertical(lipgloss.Left, window, helpStr)
}
func (m Model) renderErrorLine() string {
if m.errMsg == "" {
return ""
}
return lipgloss.NewStyle().Foreground(ilovetui.S.Error).Render(" " + m.errMsg)
}
func (m Model) renderHelpBar() string {
helpStr := m.help.View(m.keymap)
var sigStr string
if m.sigValid == nil {
sigStr = ilovetui.S.Faint.Render("-")
} else if *m.sigValid {
sigStr = lipgloss.NewStyle().Foreground(ilovetui.S.Success).Bold(true).Render(m.sigStatus)
} else {
sigStr = lipgloss.NewStyle().Foreground(ilovetui.S.Error).Bold(true).Render(m.sigStatus)
}
// Align sig status to the right of the last line of helpStr
helpLines := strings.Split(helpStr, "\n")
lastLine := helpLines[len(helpLines)-1]
lastLineW := lipgloss.Width(lastLine)
sigW := lipgloss.Width(sigStr)
pad := m.width - lastLineW - sigW
if pad < 1 {
pad = 1
}
helpLines[len(helpLines)-1] = lastLine + strings.Repeat(" ", pad) + sigStr
return strings.Join(helpLines, "\n")
}
+47
View File
@@ -0,0 +1,47 @@
package util
import (
"os"
"os/exec"
tea "charm.land/bubbletea/v2"
)
type EditorFinishedMsg struct {
Content string
Err error
}
func OpenExternalEditor(content string) tea.Cmd {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = os.Getenv("VISUAL")
}
if editor == "" {
editor = "vi"
}
f, err := os.CreateTemp("", "jwt-tui-*.json")
if err != nil {
return func() tea.Msg { return EditorFinishedMsg{Err: err} }
}
tmpPath := f.Name()
if _, werr := f.WriteString(content); werr != nil {
f.Close()
os.Remove(tmpPath)
return func() tea.Msg { return EditorFinishedMsg{Err: werr} }
}
f.Close()
return tea.ExecProcess(exec.Command(editor, tmpPath), func(err error) tea.Msg {
defer os.Remove(tmpPath)
if err != nil {
return EditorFinishedMsg{Err: err}
}
data, readErr := os.ReadFile(tmpPath)
if readErr != nil {
return EditorFinishedMsg{Err: readErr}
}
return EditorFinishedMsg{Content: string(data)}
})
}