mirror of
https://github.com/anotherhadi/jwt-tui.git
synced 2026-06-26 01:02:33 +02:00
init
Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com>
This commit is contained in:
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user