Host the gRPC API and add token admin views
This commit is contained in:
@@ -25,6 +25,8 @@ import (
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
"gioui.org/widget/material"
|
||||
"git.julianfamily.org/keepassgo/api"
|
||||
"git.julianfamily.org/keepassgo/apiaudit"
|
||||
"git.julianfamily.org/keepassgo/apiapproval"
|
||||
"git.julianfamily.org/keepassgo/apitokens"
|
||||
"git.julianfamily.org/keepassgo/appstate"
|
||||
@@ -128,6 +130,12 @@ type ui struct {
|
||||
remotePassword widget.Editor
|
||||
masterPassword widget.Editor
|
||||
keyFilePath widget.Editor
|
||||
apiTokenName widget.Editor
|
||||
apiTokenClientName widget.Editor
|
||||
apiTokenExpiresAt widget.Editor
|
||||
apiPolicyOperation widget.Editor
|
||||
apiPolicyPath widget.Editor
|
||||
apiPolicyEntryID widget.Editor
|
||||
entryID widget.Editor
|
||||
entryTitle widget.Editor
|
||||
entryUsername widget.Editor
|
||||
@@ -195,6 +203,8 @@ type ui struct {
|
||||
showEntries widget.Clickable
|
||||
showTemplates widget.Clickable
|
||||
showRecycle widget.Clickable
|
||||
showAPITokens widget.Clickable
|
||||
showAPIAudit widget.Clickable
|
||||
showLocalLifecycle widget.Clickable
|
||||
showRemoteLifecycle widget.Clickable
|
||||
showSyncLocal widget.Clickable
|
||||
@@ -206,7 +216,13 @@ type ui struct {
|
||||
cancelApproval widget.Clickable
|
||||
approvalPermanent widget.Bool
|
||||
rememberRemoteAuth widget.Bool
|
||||
apiPolicyAllow widget.Bool
|
||||
apiPolicyGroupScopeW widget.Bool
|
||||
apiTokenDisabled widget.Bool
|
||||
entryClicks []widget.Clickable
|
||||
apiTokenClicks []widget.Clickable
|
||||
apiPolicyRemoves []widget.Clickable
|
||||
apiAuditClicks []widget.Clickable
|
||||
historyClicks []widget.Clickable
|
||||
attachmentClicks []widget.Clickable
|
||||
breadcrumbs []widget.Clickable
|
||||
@@ -221,6 +237,14 @@ type ui struct {
|
||||
selectedHistoryIndex int
|
||||
showPassword bool
|
||||
togglePassword widget.Clickable
|
||||
copyAPITokenSecret widget.Clickable
|
||||
issueAPIToken widget.Clickable
|
||||
saveAPIToken widget.Clickable
|
||||
rotateAPIToken widget.Clickable
|
||||
disableAPIToken widget.Clickable
|
||||
revokeAPIToken widget.Clickable
|
||||
deleteAPIToken widget.Clickable
|
||||
addAPIPolicyRule widget.Clickable
|
||||
phoneSplit widget.Float
|
||||
splitDrag gesture.Drag
|
||||
splitBase float32
|
||||
@@ -256,8 +280,14 @@ type ui struct {
|
||||
recentRemotes []recentRemoteRecord
|
||||
recentVaultGroups map[string][]string
|
||||
deleteGroupPath []string
|
||||
apiPolicyGroupScope bool
|
||||
apiTokenSecret string
|
||||
selectedAuditIndex int
|
||||
statusExpiresAt time.Time
|
||||
now func() time.Time
|
||||
apiHost *api.Host
|
||||
auditLog *apiaudit.Log
|
||||
grpcAddress string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -274,10 +304,6 @@ const (
|
||||
errSaveAsPathRequired = "save-as path is required"
|
||||
)
|
||||
|
||||
func newUI(mode string, paths statePaths) *ui {
|
||||
return newUIWithSession(mode, &session.Manager{}, paths)
|
||||
}
|
||||
|
||||
func newUIWithModel(mode string, model vault.Model) *ui {
|
||||
return newUIWithState(mode, &uiSession{model: model}, defaultStatePaths(""))
|
||||
}
|
||||
@@ -317,6 +343,12 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
||||
syncRemotePassword: widget.Editor{SingleLine: true, Submit: false, Mask: '•'},
|
||||
masterPassword: widget.Editor{SingleLine: true, Submit: false},
|
||||
keyFilePath: widget.Editor{SingleLine: true, Submit: false},
|
||||
apiTokenName: widget.Editor{SingleLine: true, Submit: false},
|
||||
apiTokenClientName: widget.Editor{SingleLine: true, Submit: false},
|
||||
apiTokenExpiresAt: widget.Editor{SingleLine: true, Submit: false},
|
||||
apiPolicyOperation: widget.Editor{SingleLine: true, Submit: false},
|
||||
apiPolicyPath: widget.Editor{SingleLine: true, Submit: false},
|
||||
apiPolicyEntryID: widget.Editor{SingleLine: true, Submit: false},
|
||||
entryID: widget.Editor{SingleLine: true, Submit: false},
|
||||
entryTitle: widget.Editor{SingleLine: true, Submit: false},
|
||||
entryUsername: widget.Editor{SingleLine: true, Submit: false},
|
||||
@@ -343,6 +375,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
||||
},
|
||||
state: appstate.State{},
|
||||
selectedHistoryIndex: -1,
|
||||
selectedAuditIndex: -1,
|
||||
lifecycleMode: "local",
|
||||
defaultSaveAsPath: paths.DefaultSaveAsPath,
|
||||
recentVaultsPath: paths.RecentVaultsPath,
|
||||
@@ -352,7 +385,10 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
||||
now: time.Now,
|
||||
syncSourceMode: syncSourceLocal,
|
||||
syncDirection: syncDirectionPull,
|
||||
apiPolicyGroupScope: true,
|
||||
}
|
||||
u.apiPolicyAllow.Value = true
|
||||
u.apiPolicyGroupScopeW.Value = true
|
||||
u.state.Session = sess
|
||||
u.phoneSplit.Value = 0.46
|
||||
u.eyeIcon, _ = widget.NewIcon(icons.ActionVisibility)
|
||||
@@ -478,6 +514,20 @@ func (u *ui) showRecycleBinSection() {
|
||||
u.filter()
|
||||
}
|
||||
|
||||
func (u *ui) showAPITokensSection() {
|
||||
u.resetPasswordPeek()
|
||||
u.state.ShowSection(appstate.SectionAPITokens)
|
||||
u.loadSelectedAPITokenIntoEditor()
|
||||
u.filter()
|
||||
}
|
||||
|
||||
func (u *ui) showAPIAuditSection() {
|
||||
u.resetPasswordPeek()
|
||||
u.state.ShowSection(appstate.SectionAPIAudit)
|
||||
u.selectedAuditIndex = -1
|
||||
u.filter()
|
||||
}
|
||||
|
||||
func (u *ui) resetPasswordPeek() {
|
||||
u.showPassword = false
|
||||
}
|
||||
@@ -1350,6 +1400,10 @@ func (u *ui) listEmptyMessage() string {
|
||||
}
|
||||
}
|
||||
switch u.state.Section {
|
||||
case appstate.SectionAPITokens:
|
||||
return "No API tokens match the current filter."
|
||||
case appstate.SectionAPIAudit:
|
||||
return "No API audit events match the current filter."
|
||||
case appstate.SectionTemplates:
|
||||
return "Templates are not available in this build."
|
||||
case appstate.SectionRecycleBin:
|
||||
@@ -1369,6 +1423,10 @@ func (u *ui) detailPlaceholderMessage() string {
|
||||
return "Complete the form to create a new item or update the current selection."
|
||||
}
|
||||
switch u.state.Section {
|
||||
case appstate.SectionAPITokens:
|
||||
return "Select an API token or issue a new one."
|
||||
case appstate.SectionAPIAudit:
|
||||
return "Select an audit event to inspect it."
|
||||
case appstate.SectionTemplates:
|
||||
return "Select a template or start a reusable entry."
|
||||
case appstate.SectionRecycleBin:
|
||||
@@ -1430,6 +1488,8 @@ func (u *ui) noteCurrentVaultPath() {
|
||||
}
|
||||
|
||||
func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
u.syncHostedAPI()
|
||||
u.filter()
|
||||
u.processShortcuts(gtx)
|
||||
for u.createVault.Clicked(gtx) {
|
||||
u.runAction("create vault", u.createVaultAction)
|
||||
@@ -1480,6 +1540,14 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
u.showRecycleBinSection()
|
||||
}
|
||||
for u.showAPITokens.Clicked(gtx) {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
u.showAPITokensSection()
|
||||
}
|
||||
for u.showAPIAudit.Clicked(gtx) {
|
||||
u.clearDeleteGroupConfirmation()
|
||||
u.showAPIAuditSection()
|
||||
}
|
||||
for u.showLocalLifecycle.Clicked(gtx) {
|
||||
u.lifecycleMode = "local"
|
||||
}
|
||||
@@ -1530,6 +1598,45 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
for u.lockVault.Clicked(gtx) {
|
||||
u.runAction("lock vault", u.lockAction)
|
||||
}
|
||||
for u.issueAPIToken.Clicked(gtx) {
|
||||
u.runAction("issue API token", u.issueAPITokenAction)
|
||||
}
|
||||
for u.saveAPIToken.Clicked(gtx) {
|
||||
u.runAction("save API token", u.saveAPITokenAction)
|
||||
}
|
||||
for u.rotateAPIToken.Clicked(gtx) {
|
||||
u.runAction("rotate API token", u.rotateAPITokenAction)
|
||||
}
|
||||
for u.disableAPIToken.Clicked(gtx) {
|
||||
u.runAction("disable API token", u.disableAPITokenAction)
|
||||
}
|
||||
for u.revokeAPIToken.Clicked(gtx) {
|
||||
u.runAction("revoke API token", u.revokeAPITokenAction)
|
||||
}
|
||||
for u.deleteAPIToken.Clicked(gtx) {
|
||||
u.runAction("delete API token", u.deleteAPITokenAction)
|
||||
}
|
||||
for u.addAPIPolicyRule.Clicked(gtx) {
|
||||
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
|
||||
}
|
||||
for i := range u.apiPolicyRemoves {
|
||||
for u.apiPolicyRemoves[i].Clicked(gtx) {
|
||||
index := i
|
||||
u.runAction("remove API policy rule", func() error { return u.removeAPIPolicyRuleAction(index) })
|
||||
}
|
||||
}
|
||||
for u.copyAPITokenSecret.Clicked(gtx) {
|
||||
secret := u.apiTokenSecret
|
||||
u.runAction("copy API token secret", func() error {
|
||||
if strings.TrimSpace(secret) == "" {
|
||||
return fmt.Errorf("no API token secret to copy")
|
||||
}
|
||||
if u.clipboardWriter != nil {
|
||||
return u.clipboardWriter.WriteText(secret)
|
||||
}
|
||||
return clipboard.WriteText(secret)
|
||||
})
|
||||
}
|
||||
for u.editEntry.Clicked(gtx) {
|
||||
u.editingEntry = true
|
||||
u.loadSelectedEntryIntoEditor()
|
||||
@@ -1737,6 +1844,15 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
||||
)
|
||||
}
|
||||
|
||||
func (u *ui) syncHostedAPI() {
|
||||
if u.apiHost == nil {
|
||||
return
|
||||
}
|
||||
if err := u.apiHost.SyncFromLifecycle(); err != nil {
|
||||
u.state.ErrorMessage = fmt.Sprintf("sync gRPC API: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *ui) lifecycleScreen(gtx layout.Context) layout.Dimensions {
|
||||
panel := card
|
||||
if u.mode == "phone" {
|
||||
@@ -2116,21 +2232,21 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() {
|
||||
if u.isVaultLocked() || (u.state.Section != appstate.SectionEntries && u.state.Section != appstate.SectionRecycleBin) {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.pathBar(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() {
|
||||
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.groupBar(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() {
|
||||
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
return u.groupControlsSection(gtx)
|
||||
@@ -2149,18 +2265,31 @@ func (u *ui) listPanel(gtx layout.Context) layout.Dimensions {
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
if u.isVaultLocked() || u.state.Section != appstate.SectionEntries {
|
||||
if u.isVaultLocked() {
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
label := "Add Entry"
|
||||
if u.mode == "phone" {
|
||||
label = "+ " + label
|
||||
switch u.state.Section {
|
||||
case appstate.SectionEntries:
|
||||
label := "Add Entry"
|
||||
if u.mode == "phone" {
|
||||
label = "+ " + label
|
||||
}
|
||||
btn := material.Button(u.theme, &u.addEntry, label)
|
||||
return btn.Layout(gtx)
|
||||
case appstate.SectionAPITokens:
|
||||
return tonedButton(gtx, u.theme, &u.issueAPIToken, "Issue API Token")
|
||||
default:
|
||||
return layout.Dimensions{}
|
||||
}
|
||||
btn := material.Button(u.theme, &u.addEntry, label)
|
||||
return btn.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Height: spacing}.Layout),
|
||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||
if u.state.Section == appstate.SectionAPITokens {
|
||||
return u.apiTokenListPanel(gtx)
|
||||
}
|
||||
if u.state.Section == appstate.SectionAPIAudit {
|
||||
return u.apiAuditListPanel(gtx)
|
||||
}
|
||||
if len(u.visible) == 0 {
|
||||
lbl := material.Label(u.theme, unit.Sp(16), u.listEmptyMessage())
|
||||
lbl.Color = mutedColor
|
||||
@@ -2209,6 +2338,24 @@ func (u *ui) sectionBar(gtx layout.Context) layout.Dimensions {
|
||||
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
|
||||
return btn.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(u.theme, &u.showAPITokens, "API Tokens")
|
||||
btn.Background = accentColor
|
||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
||||
btn.TextSize = unit.Sp(11)
|
||||
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
|
||||
return btn.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||
btn := material.Button(u.theme, &u.showAPIAudit, "API Audit")
|
||||
btn.Background = accentColor
|
||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
||||
btn.TextSize = unit.Sp(11)
|
||||
btn.Inset = layout.Inset{Top: 5, Bottom: 5, Left: 9, Right: 9}
|
||||
return btn.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2395,6 +2542,16 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
|
||||
layout.Rigid(u.unlockPanel),
|
||||
}
|
||||
}
|
||||
if u.state.Section == appstate.SectionAPITokens {
|
||||
return []layout.FlexChild{
|
||||
layout.Flexed(1, u.apiTokenDetailPanel),
|
||||
}
|
||||
}
|
||||
if u.state.Section == appstate.SectionAPIAudit {
|
||||
return []layout.FlexChild{
|
||||
layout.Flexed(1, u.apiAuditDetailPanel),
|
||||
}
|
||||
}
|
||||
item, ok := u.selectedEntry()
|
||||
if !ok && !u.editingEntry {
|
||||
return []layout.FlexChild{
|
||||
@@ -2913,10 +3070,12 @@ func fill(c color.NRGBA) layout.Widget {
|
||||
func main() {
|
||||
mode := flag.String("mode", "", "window mode: desktop or phone")
|
||||
stateDir := flag.String("state-dir", "", "directory for KeePassGO state such as recent-vault history and default save targets")
|
||||
grpcAddr := flag.String("grpc-addr", "", "address for the local gRPC API listener; use 'off' to disable")
|
||||
flag.Parse()
|
||||
|
||||
resolvedMode := resolveFlagOrEnv(*mode, "KEEPASSGO_MODE", defaultModeForRuntime(runtime.GOOS))
|
||||
resolvedStateDir := resolveFlagOrEnv(*stateDir, "KEEPASSGO_STATE_DIR", "")
|
||||
resolvedGRPCAddr := resolveFlagOrEnv(*grpcAddr, "KEEPASSGO_GRPC_ADDR", defaultGRPCAddr(runtime.GOOS))
|
||||
|
||||
width := unit.Dp(1180)
|
||||
height := unit.Dp(760)
|
||||
@@ -2933,7 +3092,7 @@ func main() {
|
||||
options = append(options, app.Size(width, height))
|
||||
}
|
||||
w.Option(options...)
|
||||
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir)); err != nil {
|
||||
if err := run(w, strings.ToLower(resolvedMode), defaultStatePaths(resolvedStateDir), resolvedGRPCAddr); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if !strings.EqualFold(runtime.GOOS, "android") {
|
||||
@@ -2943,9 +3102,27 @@ func main() {
|
||||
app.Main()
|
||||
}
|
||||
|
||||
func run(w *app.Window, mode string, paths statePaths) error {
|
||||
func defaultGRPCAddr(goos string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(goos), "android") {
|
||||
return "off"
|
||||
}
|
||||
return "127.0.0.1:47777"
|
||||
}
|
||||
|
||||
func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
||||
var ops op.Ops
|
||||
ui := newUI(mode, paths)
|
||||
manager := &session.Manager{}
|
||||
ui := newUIWithSession(mode, manager, paths)
|
||||
host, err := api.StartHost(grpcAddr, manager, passwords.DefaultProfiles(), ui.clipboardWriter, func() bool { return ui.state.Dirty })
|
||||
if err != nil {
|
||||
ui.state.ErrorMessage = fmt.Sprintf("start gRPC API: %v", err)
|
||||
} else if host != nil {
|
||||
ui.apiHost = host
|
||||
ui.auditLog = host.Server().AuditLog()
|
||||
ui.grpcAddress = host.Address()
|
||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||
defer func() { _ = host.Stop() }()
|
||||
}
|
||||
for {
|
||||
e := w.Event()
|
||||
switch e := e.(type) {
|
||||
@@ -2959,6 +3136,24 @@ func run(w *app.Window, mode string, paths statePaths) error {
|
||||
}
|
||||
}
|
||||
|
||||
type uiApprovalManager struct {
|
||||
server *api.Server
|
||||
}
|
||||
|
||||
func (m *uiApprovalManager) Pending() []apiapproval.Request {
|
||||
if m == nil || m.server == nil {
|
||||
return nil
|
||||
}
|
||||
return m.server.ApprovalBroker().Pending()
|
||||
}
|
||||
|
||||
func (m *uiApprovalManager) Resolve(id string, outcome apiapproval.Outcome) (apiapproval.Request, *apitokens.PolicyRule, error) {
|
||||
if m == nil || m.server == nil {
|
||||
return apiapproval.Request{}, nil, fmt.Errorf("approval manager is not configured")
|
||||
}
|
||||
return m.server.ResolveApproval(id, outcome)
|
||||
}
|
||||
|
||||
type uiSession struct {
|
||||
model vault.Model
|
||||
locked bool
|
||||
|
||||
Reference in New Issue
Block a user