Compare commits
19 Commits
2f1cd7876c
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 885d599db1 | |||
| e757be66d9 | |||
| bc226647e1 | |||
| 533fb2d550 | |||
| 8dfba6e94f | |||
| 6cc86bb944 | |||
| a9c15c2d23 | |||
| b7d6dbdc97 | |||
| 2deca549f5 | |||
| fe3c07e3dd | |||
| c4f110e0ad | |||
| 56a0711860 | |||
| 54f13d352c | |||
| 550d9f362c | |||
| ac3478889c | |||
| 44da1e6599 | |||
| b59cf8044b | |||
| 5838588fc5 | |||
| 0e9fd478e5 |
@@ -0,0 +1,109 @@
|
|||||||
|
---
|
||||||
|
name: keepassgo-ship-it
|
||||||
|
description: KeePassGO-specific ship workflow. Use when the user says `ship it` in this repository and expects the current work to be committed, the Arch package rebuilt and installed, the Android APK rebuilt and zipped, the ZIP uploaded to Nextcloud, and the rebuilt app launched in the emulator with a controlled demo vault opened.
|
||||||
|
---
|
||||||
|
|
||||||
|
# KeePassGO Ship It
|
||||||
|
|
||||||
|
Use this skill only in the KeePassGO repository. This is not a global shorthand.
|
||||||
|
|
||||||
|
Use it together with:
|
||||||
|
- `android-emulator-debug` for emulator and `adb` mechanics
|
||||||
|
- `keepass-credentials` for Nextcloud credentials
|
||||||
|
- `public-repo-sanitization` before the commit/push step
|
||||||
|
|
||||||
|
## Meaning Of `ship it`
|
||||||
|
|
||||||
|
When the user says `ship it`, do all of this unless they narrow the scope:
|
||||||
|
|
||||||
|
1. Commit the relevant KeePassGO source changes first.
|
||||||
|
2. Build and install the Arch package from that committed source.
|
||||||
|
3. Build the Android APK from that same committed source.
|
||||||
|
4. Zip the APK.
|
||||||
|
5. Upload the ZIP to the user's configured Nextcloud DAV destination for this repository.
|
||||||
|
6. Install the rebuilt APK in the emulator.
|
||||||
|
7. Launch the rebuilt app in the emulator.
|
||||||
|
8. Open a controlled demo vault in the emulator.
|
||||||
|
|
||||||
|
Do not stop after the commit or after the package build. `ship it` means finish the full loop.
|
||||||
|
|
||||||
|
## Required Sequence
|
||||||
|
|
||||||
|
### 1. Commit First
|
||||||
|
|
||||||
|
- Make sure the worktree state intended for shipping is committed before building.
|
||||||
|
- If the repo is dirty in unrelated ways, commit only the relevant changes.
|
||||||
|
- Before the commit or push, run the public-repo sanitization checks.
|
||||||
|
|
||||||
|
### 2. Build And Install The Arch Package
|
||||||
|
|
||||||
|
From the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make archlinux-pkgbuild
|
||||||
|
cd packaging/archlinux/keepassgo-git
|
||||||
|
makepkg -si --noconfirm
|
||||||
|
```
|
||||||
|
|
||||||
|
The installed package version must correspond to the committed source, not a dirty worktree.
|
||||||
|
|
||||||
|
### 3. Build The APK
|
||||||
|
|
||||||
|
Use the repo's known-good local JDK unless the environment already proves otherwise:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
JAVA_HOME=/usr/lib/jvm/java-25-openjdk make apk
|
||||||
|
```
|
||||||
|
|
||||||
|
If that JDK is unavailable on the current host, use the working replacement already established for the machine and say so in the closeout.
|
||||||
|
|
||||||
|
### 4. Zip The APK
|
||||||
|
|
||||||
|
- Create the ZIP under the globally required temporary secret-safe directory.
|
||||||
|
- Use a name that includes the commit, for example:
|
||||||
|
`keepassgo-<shortsha>-apk.zip`
|
||||||
|
|
||||||
|
### 5. Upload To Nextcloud
|
||||||
|
|
||||||
|
- Get credentials and the DAV endpoint with `keepass-http`, not by asking the user if KeePass likely has them.
|
||||||
|
- Prefer the established KeePass entry and DAV destination already in use for this repository's shipping workflow.
|
||||||
|
- Use the globally required temporary secret-safe directory for any temporary curl config or secret material.
|
||||||
|
- Ensure that directory exists with mode `700`.
|
||||||
|
- Create secret temp files with mode `600`.
|
||||||
|
- After upload, zero and unlink the temp secret file. Do not use `rm -f` or `rm -rf`.
|
||||||
|
|
||||||
|
### 6. Emulator Install And Launch
|
||||||
|
|
||||||
|
- Reuse the existing emulator session if one is already running.
|
||||||
|
- Install with replacement:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
adb install -r build/keepassgo.apk
|
||||||
|
```
|
||||||
|
|
||||||
|
- Launch KeePassGO and confirm it is focused.
|
||||||
|
- Treat the emulator as timing-sensitive. If Android shows a transient "Wait" style ANR dialog and the user says the app is otherwise fine, do not misclassify that as an app-logic failure.
|
||||||
|
|
||||||
|
### 7. Open A Controlled Demo Vault
|
||||||
|
|
||||||
|
- Do not rely on the user's real vault for this step.
|
||||||
|
- Use a controlled/sanitized demo vault that you can unlock yourself.
|
||||||
|
- Open it in the emulator before closing out `ship it`.
|
||||||
|
- Capture a screenshot if needed to verify the app really rendered and opened the vault.
|
||||||
|
|
||||||
|
## Closeout Requirements
|
||||||
|
|
||||||
|
When reporting back after `ship it`, include:
|
||||||
|
- the commit that was shipped
|
||||||
|
- the installed Arch package version
|
||||||
|
- the APK path
|
||||||
|
- the uploaded ZIP URL
|
||||||
|
- confirmation that the emulator app was launched
|
||||||
|
- confirmation that the controlled demo vault was opened
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
- Keep this workflow specific to KeePassGO.
|
||||||
|
- Preserve emulator state; do not kill or reset it unless the user explicitly asks.
|
||||||
|
- Do not use `rm -rf`.
|
||||||
|
- Do not use `rm -f`.
|
||||||
@@ -132,195 +132,6 @@ These are important, but they should likely move behind a dedicated settings gea
|
|||||||
- Phone and desktop layouts both present a clear information hierarchy.
|
- Phone and desktop layouts both present a clear information hierarchy.
|
||||||
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
|
- The Android open flow is reliable enough to review and use without ANR during ordinary vault-open operations.
|
||||||
|
|
||||||
## API Token And gRPC Authorization Parallel Segments
|
|
||||||
|
|
||||||
These segments define the work for programmatic access control over gRPC.
|
|
||||||
They are designed to be independently landable wherever file overlap permits.
|
|
||||||
The feature is not complete until all segment exit criteria and the global exit criteria are satisfied.
|
|
||||||
|
|
||||||
### API Segment A: Token Domain Model
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Represent API tokens as first-class vault-backed records.
|
|
||||||
- Mark token entries explicitly as API credentials rather than generic passwords.
|
|
||||||
- Store token metadata:
|
|
||||||
token id,
|
|
||||||
hashed secret or verifier,
|
|
||||||
display name,
|
|
||||||
client name,
|
|
||||||
created at,
|
|
||||||
expires at,
|
|
||||||
disabled state.
|
|
||||||
- Keep the persisted representation compatible with KDBX entry fields.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- A domain type exists for API tokens and round-trips through the persisted vault model.
|
|
||||||
- Generic entry listing can distinguish API token entries from ordinary secrets.
|
|
||||||
- Tests cover create, load, save, and parse behavior for API token entries.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment B: Token Issuance And Rotation
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Generate new API tokens for external tools.
|
|
||||||
- Return the cleartext token only at creation or explicit rotation time.
|
|
||||||
- Rotate an existing token while preserving its identity and policy linkage.
|
|
||||||
- Revoke or disable a token without deleting policy history.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Token issuance, rotation, disable, and revoke operations exist in the domain/service layer.
|
|
||||||
- Cleartext token material is only exposed on creation or rotation paths.
|
|
||||||
- Tests cover generation, rotation, and disable/revoke semantics.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment C: Token Expiration
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Allow tokens to have optional expiration timestamps.
|
|
||||||
- Treat expired tokens as unauthenticated.
|
|
||||||
- Surface expiration in UI and gRPC management views.
|
|
||||||
- Support non-expiring tokens explicitly.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Expired tokens are rejected by the gRPC authentication path.
|
|
||||||
- Token expiration can be created, edited, and removed through the service layer.
|
|
||||||
- Tests cover valid, expired, and non-expiring token behavior.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment D: Authorization Policy Model
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Define an authorization model for token-scoped access.
|
|
||||||
- Support allow and deny rules over:
|
|
||||||
folders/groups,
|
|
||||||
specific entries,
|
|
||||||
entry fields where needed,
|
|
||||||
and operation types.
|
|
||||||
- Keep specific deny rules higher priority than broad allow rules.
|
|
||||||
- Model “not yet decided” separately from “denied”.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- A policy evaluator exists for token, resource, and operation tuples.
|
|
||||||
- Explicit deny overrides allow.
|
|
||||||
- Unspecified access is distinguishable from denied access.
|
|
||||||
- Tests cover allow, deny, inherited group scope, and exact-entry scope behavior.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment E: gRPC Authentication And Authorization Enforcement
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Replace the current single static bearer-token interceptor with token-backed auth.
|
|
||||||
- Authenticate callers using issued KeePassGO API tokens.
|
|
||||||
- Authorize every gRPC method against token policy.
|
|
||||||
- Apply scope checks to lifecycle, list, read, mutation, copy, and password-generation RPCs.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- gRPC requests authenticate through stored API tokens rather than one static shared secret.
|
|
||||||
- Every RPC enforces token-specific authorization before mutating or revealing vault data.
|
|
||||||
- Unauthorized requests return the correct authz/authn gRPC status.
|
|
||||||
- Integration tests cover permitted, denied, expired, and revoked token behavior.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment F: Approval Queue And Pending Access Requests
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- When a token requests access to a resource that is neither explicitly allowed nor denied:
|
|
||||||
create a pending approval request.
|
|
||||||
- Include:
|
|
||||||
token identity,
|
|
||||||
client name,
|
|
||||||
requested operation,
|
|
||||||
requested group/entry scope,
|
|
||||||
requested time,
|
|
||||||
and permanence choice.
|
|
||||||
- Allow the request to be accepted, denied, or canceled by the user.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Unspecified access creates a pending approval instead of silently denying or allowing.
|
|
||||||
- Pending approvals are queryable from the application layer.
|
|
||||||
- Canceling the prompt results in the API request failing without granting access.
|
|
||||||
- Tests cover pending creation, approval, denial, and cancellation.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment G: Approval UI
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Show a user-facing approval screen/dialog when a pending API request needs a decision.
|
|
||||||
- Provide actions:
|
|
||||||
allow once,
|
|
||||||
deny once,
|
|
||||||
allow permanently,
|
|
||||||
deny permanently,
|
|
||||||
cancel.
|
|
||||||
- Make the requested scope and operation clear to the user.
|
|
||||||
- Ensure the dialog appears only for requests not already decided.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- A pending request triggers a visible approval surface in the app.
|
|
||||||
- The user can allow, deny, or cancel from the UI.
|
|
||||||
- Permanent decisions become persisted policy rules.
|
|
||||||
- UI tests cover each approval outcome.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment H: gRPC Request Blocking And Resume Behavior
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Define how an in-flight gRPC call waits for or fails on user approval.
|
|
||||||
- Hold the request while approval is pending within a bounded timeout.
|
|
||||||
- Return unauthenticated or permission-denied when denied/canceled/expired.
|
|
||||||
- Resume the original call automatically when approval is granted.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Pending requests block safely without leaking goroutines.
|
|
||||||
- Allowed requests resume and complete without the client reissuing the call where practical.
|
|
||||||
- Denied and canceled requests return a consistent gRPC status code and message.
|
|
||||||
- Tests cover timeout, allow, deny, and cancel paths.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment I: Token Management UI
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Add UI for listing API tokens.
|
|
||||||
- Create token flow with one-time secret display.
|
|
||||||
- Edit token display metadata and expiration.
|
|
||||||
- Disable, revoke, and rotate tokens.
|
|
||||||
- Show effective policy summary per token.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Users can manage API tokens from the app UI end to end.
|
|
||||||
- One-time token display is explicit and not re-shown later.
|
|
||||||
- Expiration and disable state are visible.
|
|
||||||
- UI tests cover create, rotate, disable, revoke, and edit flows.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment J: Policy Management UI
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Let users define folder, entry, and operation scopes for each token.
|
|
||||||
- Show explicit allow and deny rules.
|
|
||||||
- Show inherited implications of a folder-level rule.
|
|
||||||
- Let users review prior permanent decisions created from approval prompts.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Users can inspect and edit token policy from the UI.
|
|
||||||
- Folder-level and entry-level rules are distinguishable and editable.
|
|
||||||
- Permanent prompt decisions are visible as policy.
|
|
||||||
- UI tests cover rule creation, update, and deletion.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### API Segment K: Audit And Event History
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Record token issuance, rotation, revoke, approval, deny, and prompt outcomes.
|
|
||||||
- Record authorization failures and expirations without logging secret material.
|
|
||||||
- Provide a bounded event history visible in the UI and/or gRPC admin surface.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Security-relevant API token events are captured without secret leakage.
|
|
||||||
- Approval outcomes and policy changes are auditable.
|
|
||||||
- Tests cover audit generation for the main token lifecycle and approval actions.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### Segment 1: Application State Ownership
|
### Segment 1: Application State Ownership
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -389,19 +200,6 @@ Exit criteria:
|
|||||||
- Validation and visible error states exist for missing or invalid key material.
|
- Validation and visible error states exist for missing or invalid key material.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
|
|
||||||
### Segment 5: KDBX Security Settings Preservation
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Preserve supported cipher and KDF settings when reopening and saving.
|
|
||||||
- Surface relevant settings in product-facing docs or UI where appropriate.
|
|
||||||
- Document unsupported settings explicitly.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- Reopen-and-save cycles preserve supported KDBX security settings.
|
|
||||||
- Compatibility notes are current in `docs/kdbx-compatibility.md`.
|
|
||||||
- Tests cover settings preservation across save cycles.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### Segment 6: Entry CRUD UI
|
### Segment 6: Entry CRUD UI
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -607,33 +405,6 @@ Exit criteria:
|
|||||||
- UI tests or controller-integrated tests cover these states.
|
- UI tests or controller-integrated tests cover these states.
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
|
|
||||||
### Segment 18: Desktop Automation Resolution
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Either implement a desktop login automation mechanism comparable in purpose to KeePass auto-type,
|
|
||||||
- or explicitly finalize the design that secure gRPC supersedes auto-type.
|
|
||||||
- Keep the decision documented in-repo.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- The desktop automation requirement is explicitly resolved in code or docs.
|
|
||||||
- The chosen approach is documented in `docs/desktop-automation.md`.
|
|
||||||
- Any implemented behavior is tested.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### Segment 19: Packaging And Runbook
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Keep the app runnable from source.
|
|
||||||
- Document desktop build and run steps.
|
|
||||||
- Document Android packaging with `gogio`.
|
|
||||||
- Add icon and metadata placeholders if missing.
|
|
||||||
|
|
||||||
Exit criteria:
|
|
||||||
- `README.md` is accurate for local build, run, and Android packaging guidance.
|
|
||||||
- Placeholder metadata exists where needed for packaging.
|
|
||||||
- The app still builds from the repo.
|
|
||||||
- `go test ./...` passes.
|
|
||||||
|
|
||||||
### Segment 20: Regression And Integration Coverage
|
### Segment 20: Regression And Integration Coverage
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
@@ -651,11 +422,10 @@ Exit criteria:
|
|||||||
|
|
||||||
Do not treat the product as complete until all of the following are true:
|
Do not treat the product as complete until all of the following are true:
|
||||||
|
|
||||||
- Segment 1 through Segment 20 are all complete.
|
- All remaining numbered segments, API segments, and UI review follow-ups are complete.
|
||||||
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
|
- KeePassGO can create, open, edit, save, save-as, lock, and unlock local KDBX databases through the UI.
|
||||||
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling.
|
- KeePassGO can open and save remote WebDAV-backed KDBX databases through the UI, including visible conflict and error handling.
|
||||||
- KeePassGO supports master password, key file, and composite key workflows in the product.
|
- KeePassGO supports master password, key file, and composite key workflows in the product.
|
||||||
- KeePassGO preserves supported KDBX security and KDF settings and documents unsupported settings.
|
|
||||||
- KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation.
|
- KeePassGO supports nested groups, path-aware navigation, explicit template navigation, and explicit recycle-bin navigation.
|
||||||
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
|
- KeePassGO supports entry create, edit, duplicate, delete, restore, history browse, and history restore through the UI.
|
||||||
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
|
- KeePassGO supports title, username, password, URL, notes, tags, and custom string fields through the UI.
|
||||||
@@ -665,17 +435,7 @@ Do not treat the product as complete until all of the following are true:
|
|||||||
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
|
- KeePassGO supports copy username, copy password, copy URL, and reveal or hide password behavior end to end.
|
||||||
- KeePassGO exposes password generation profiles through both UI and gRPC.
|
- KeePassGO exposes password generation profiles through both UI and gRPC.
|
||||||
- The secure gRPC API is broad enough for trusted automation and browser-extension-style integration.
|
- The secure gRPC API is broad enough for trusted automation and browser-extension-style integration.
|
||||||
- The desktop automation requirement is explicitly resolved.
|
|
||||||
- Keyboard-first navigation and common shortcuts exist for major product workflows.
|
- Keyboard-first navigation and common shortcuts exist for major product workflows.
|
||||||
- The UI no longer depends on prototype-only mock behavior for any core workflow.
|
- The UI no longer depends on prototype-only mock behavior for any core workflow.
|
||||||
- Build and run instructions exist for desktop, and packaging guidance exists for Android.
|
|
||||||
- `go test ./...` passes.
|
- `go test ./...` passes.
|
||||||
- `go tool golangci-lint run ./...` passes.
|
- `go tool golangci-lint run ./...` passes.
|
||||||
|
|
||||||
## Remaining Gaps Against AGENTS.md
|
|
||||||
|
|
||||||
None currently identified.
|
|
||||||
|
|
||||||
The last explicitly tracked gaps are now closed:
|
|
||||||
- KDBX security settings are product-configurable at the major cipher/KDF family level for both new vault creation and existing sessions.
|
|
||||||
- The current accessibility support boundary is documented in `docs/accessibility.md`, while in-repo focus and labeling behavior remains tested.
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ func StartHost(addr string, lifecycle lifecycleBackend, profiles map[string]pass
|
|||||||
}
|
}
|
||||||
|
|
||||||
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
|
service := NewServerWithLifecycle(vault.Model{}, profiles, clipboardWriter, lifecycle)
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
server := grpc.NewServer()
|
||||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
|
||||||
host := &Host{
|
host := &Host{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/passwords"
|
"git.julianfamily.org/keepassgo/internal/passwords"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -19,7 +20,16 @@ func TestStartHostServesVaultLifecycleAndSyncsSessionState(t *testing.T) {
|
|||||||
lifecycle := &session.Manager{}
|
lifecycle := &session.Manager{}
|
||||||
if err := lifecycle.Create(vault.Model{
|
if err := lifecycle.Create(vault.Model{
|
||||||
Entries: []vault.Entry{
|
Entries: []vault.Entry{
|
||||||
testAPITokenEntry(t),
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{
|
||||||
|
Effect: apitokens.EffectAllow,
|
||||||
|
Operation: apitokens.OperationManageVault,
|
||||||
|
Resource: apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceGroup,
|
||||||
|
Path: []string{"Root"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
{ID: "entry-1", Title: "Vault Console", Path: []string{"Root", "Internet"}},
|
||||||
},
|
},
|
||||||
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
}, vault.MasterKey{Password: "correct horse battery staple"}); err != nil {
|
||||||
|
|||||||
+71
-37
@@ -19,7 +19,6 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
keepassgov1 "git.julianfamily.org/keepassgo/proto/keepassgo/v1"
|
||||||
"google.golang.org/grpc"
|
|
||||||
"google.golang.org/grpc/codes"
|
"google.golang.org/grpc/codes"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -89,7 +88,10 @@ func (s *Server) SetSessionState(model vault.Model, locked, dirty bool) {
|
|||||||
s.dirty = dirty
|
s.dirty = dirty
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
func (s *Server) GetSessionStatus(ctx context.Context, _ *keepassgov1.GetSessionStatusRequest) (*keepassgov1.GetSessionStatusResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
@@ -100,7 +102,10 @@ func (s *Server) GetSessionStatus(_ context.Context, _ *keepassgov1.GetSessionSt
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
|
func (s *Server) OpenVault(ctx context.Context, req *keepassgov1.OpenVaultRequest) (*keepassgov1.OpenVaultResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if s.lifecycle == nil {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -124,7 +129,10 @@ func (s *Server) OpenVault(_ context.Context, req *keepassgov1.OpenVaultRequest)
|
|||||||
return &keepassgov1.OpenVaultResponse{}, nil
|
return &keepassgov1.OpenVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
|
func (s *Server) OpenRemoteVault(ctx context.Context, req *keepassgov1.OpenRemoteVaultRequest) (*keepassgov1.OpenRemoteVaultResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if s.lifecycle == nil {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -153,7 +161,10 @@ func (s *Server) OpenRemoteVault(_ context.Context, req *keepassgov1.OpenRemoteV
|
|||||||
return &keepassgov1.OpenRemoteVaultResponse{}, nil
|
return &keepassgov1.OpenRemoteVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
|
func (s *Server) SaveVault(ctx context.Context, _ *keepassgov1.SaveVaultRequest) (*keepassgov1.SaveVaultResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if s.lifecycle == nil {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -169,7 +180,10 @@ func (s *Server) SaveVault(_ context.Context, _ *keepassgov1.SaveVaultRequest) (
|
|||||||
return &keepassgov1.SaveVaultResponse{}, nil
|
return &keepassgov1.SaveVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
|
func (s *Server) LockVault(ctx context.Context, _ *keepassgov1.LockVaultRequest) (*keepassgov1.LockVaultResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if s.lifecycle == nil {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -185,7 +199,10 @@ func (s *Server) LockVault(_ context.Context, _ *keepassgov1.LockVaultRequest) (
|
|||||||
return &keepassgov1.LockVaultResponse{}, nil
|
return &keepassgov1.LockVaultResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) UnlockVault(_ context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
|
func (s *Server) UnlockVault(ctx context.Context, req *keepassgov1.UnlockVaultRequest) (*keepassgov1.UnlockVaultResponse, error) {
|
||||||
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationManageVault); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if s.lifecycle == nil {
|
if s.lifecycle == nil {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
return nil, status.Error(codes.FailedPrecondition, "vault lifecycle backend is not configured")
|
||||||
}
|
}
|
||||||
@@ -465,7 +482,10 @@ func (s *Server) RestoreEntryHistory(ctx context.Context, req *keepassgov1.Resto
|
|||||||
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
|
return &keepassgov1.RestoreEntryHistoryResponse{Entry: entryToProto(entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
|
func (s *Server) ListTemplates(ctx context.Context, _ *keepassgov1.ListTemplatesRequest) (*keepassgov1.ListTemplatesResponse, error) {
|
||||||
|
if _, err := s.authorizeTemplateCollectionRequest(ctx, apitokens.OperationListTemplates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s.mu.RLock()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
@@ -483,10 +503,13 @@ func (s *Server) ListTemplates(_ context.Context, _ *keepassgov1.ListTemplatesRe
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
|
func (s *Server) UpsertTemplate(ctx context.Context, req *keepassgov1.UpsertTemplateRequest) (*keepassgov1.UpsertTemplateResponse, error) {
|
||||||
if req.GetTemplate() == nil {
|
if req.GetTemplate() == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "missing template")
|
return nil, status.Error(codes.InvalidArgument, "missing template")
|
||||||
}
|
}
|
||||||
|
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetTemplate().GetId()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -502,7 +525,10 @@ func (s *Server) UpsertTemplate(_ context.Context, req *keepassgov1.UpsertTempla
|
|||||||
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
|
return &keepassgov1.UpsertTemplateResponse{Template: entryToProto(entry)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
|
func (s *Server) DeleteTemplate(ctx context.Context, req *keepassgov1.DeleteTemplateRequest) (*keepassgov1.DeleteTemplateResponse, error) {
|
||||||
|
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationMutateTemplate, req.GetId()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
@@ -521,10 +547,16 @@ func (s *Server) DeleteTemplate(_ context.Context, req *keepassgov1.DeleteTempla
|
|||||||
return &keepassgov1.DeleteTemplateResponse{}, nil
|
return &keepassgov1.DeleteTemplateResponse{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) InstantiateTemplate(_ context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
|
func (s *Server) InstantiateTemplate(ctx context.Context, req *keepassgov1.InstantiateTemplateRequest) (*keepassgov1.InstantiateTemplateResponse, error) {
|
||||||
if req.GetOverrides() == nil {
|
if req.GetOverrides() == nil {
|
||||||
return nil, status.Error(codes.InvalidArgument, "missing overrides")
|
return nil, status.Error(codes.InvalidArgument, "missing overrides")
|
||||||
}
|
}
|
||||||
|
if _, err := s.authorizeTemplateRequest(ctx, apitokens.OperationListTemplates, req.GetTemplateId()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.authorizePathRequest(ctx, apitokens.OperationMutateEntry, req.GetOverrides().GetPath()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
@@ -685,12 +717,11 @@ func (s *Server) CopyEntryField(ctx context.Context, req *keepassgov1.CopyEntryF
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
func (s *Server) GeneratePassword(ctx context.Context, req *keepassgov1.GeneratePasswordRequest) (*keepassgov1.GeneratePasswordResponse, error) {
|
||||||
s.mu.RLock()
|
if _, err := s.authorizeVaultRequest(ctx, apitokens.OperationGeneratePassword); err != nil {
|
||||||
defer s.mu.RUnlock()
|
|
||||||
|
|
||||||
if _, err := s.authenticateRequest(ctx); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
if s.locked {
|
if s.locked {
|
||||||
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
return nil, status.Error(codes.FailedPrecondition, "vault is locked")
|
||||||
@@ -784,6 +815,11 @@ func (s *Server) snapshotModel() (vault.Model, bool) {
|
|||||||
|
|
||||||
var timeNow = func() time.Time { return time.Now().UTC() }
|
var timeNow = func() time.Time { return time.Now().UTC() }
|
||||||
|
|
||||||
|
var (
|
||||||
|
vaultPolicyPath = []string{"Root"}
|
||||||
|
templatePolicyPath = []string{"Root", "Templates"}
|
||||||
|
)
|
||||||
|
|
||||||
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
|
func (s *Server) authenticateRequest(ctx context.Context) (apitokens.Token, error) {
|
||||||
md, ok := metadata.FromIncomingContext(ctx)
|
md, ok := metadata.FromIncomingContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -826,6 +862,14 @@ func (s *Server) authorizePathRequest(ctx context.Context, op apitokens.Operatio
|
|||||||
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
|
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizeVaultRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
|
||||||
|
return s.authorizePathRequest(ctx, op, vaultPolicyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizeTemplateCollectionRequest(ctx context.Context, op apitokens.Operation) (apitokens.Token, error) {
|
||||||
|
return s.authorizePathRequest(ctx, op, templatePolicyPath)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
|
func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operation, entry vault.Entry) (apitokens.Token, error) {
|
||||||
token, err := s.authenticateRequest(ctx)
|
token, err := s.authenticateRequest(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -834,6 +878,18 @@ func (s *Server) authorizeEntryRequest(ctx context.Context, op apitokens.Operati
|
|||||||
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
|
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entry.ID, Path: entry.Path})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) authorizeTemplateRequest(ctx context.Context, op apitokens.Operation, templateID string) (apitokens.Token, error) {
|
||||||
|
token, err := s.authenticateRequest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return apitokens.Token{}, err
|
||||||
|
}
|
||||||
|
return s.authorizeResourceRequest(ctx, token, op, apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceEntry,
|
||||||
|
Path: templatePolicyPath,
|
||||||
|
EntryID: templateID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
|
func (s *Server) authorizeResourceRequest(ctx context.Context, token apitokens.Token, op apitokens.Operation, resource apitokens.Resource) (apitokens.Token, error) {
|
||||||
switch apitokens.Evaluate(token, op, resource) {
|
switch apitokens.Evaluate(token, op, resource) {
|
||||||
case apitokens.DecisionAllow:
|
case apitokens.DecisionAllow:
|
||||||
@@ -955,25 +1011,3 @@ func copyOperation(target string) apitokens.Operation {
|
|||||||
return apitokens.OperationCopyPassword
|
return apitokens.OperationCopyPassword
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func AuthInterceptor(server *Server) grpc.UnaryServerInterceptor {
|
|
||||||
return func(
|
|
||||||
ctx context.Context,
|
|
||||||
req any,
|
|
||||||
info *grpc.UnaryServerInfo,
|
|
||||||
handler grpc.UnaryHandler,
|
|
||||||
) (any, error) {
|
|
||||||
switch info.FullMethod {
|
|
||||||
case "/keepassgo.v1.VaultService/GetSessionStatus",
|
|
||||||
"/keepassgo.v1.VaultService/OpenVault",
|
|
||||||
"/keepassgo.v1.VaultService/OpenRemoteVault",
|
|
||||||
"/keepassgo.v1.VaultService/SaveVault",
|
|
||||||
"/keepassgo.v1.VaultService/LockVault",
|
|
||||||
"/keepassgo.v1.VaultService/UnlockVault":
|
|
||||||
if _, err := server.authenticateRequest(ctx); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return handler(ctx, req)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -99,6 +99,66 @@ func TestVaultServiceRejectsUnauthorizedEntryAccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceRejectsUnauthorizedVaultManagement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.GetSessionStatus(tokenContext(defaultTestTokenSecret), &keepassgov1.GetSessionStatusRequest{})
|
||||||
|
if status.Code(err) != codes.PermissionDenied {
|
||||||
|
t.Fatalf("GetSessionStatus() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceRejectsUnauthorizedTemplateMutation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
Templates: []vault.Entry{
|
||||||
|
{ID: "website-login", Title: "Website Login", Path: []string{"Templates"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.UpsertTemplate(tokenContext(defaultTestTokenSecret), &keepassgov1.UpsertTemplateRequest{
|
||||||
|
Template: &keepassgov1.Entry{Id: "website-login", Title: "Updated"},
|
||||||
|
})
|
||||||
|
if status.Code(err) != codes.PermissionDenied {
|
||||||
|
t.Fatalf("UpsertTemplate() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultServiceRejectsUnauthorizedPasswordGeneration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client, _, cleanup := newTestClientForModel(t, vault.Model{
|
||||||
|
Entries: []vault.Entry{
|
||||||
|
testAPITokenEntry(t,
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectDeny, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.GeneratePassword(tokenContext(defaultTestTokenSecret), &keepassgov1.GeneratePasswordRequest{Profile: "strong"})
|
||||||
|
if status.Code(err) != codes.PermissionDenied {
|
||||||
|
t.Fatalf("GeneratePassword() code = %v, want %v", status.Code(err), codes.PermissionDenied)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
func TestVaultServicePromptsAndResumesWhenApproved(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1087,9 +1147,12 @@ func newTestClient(t *testing.T) (keepassgov1.VaultServiceClient, *memoryClipboa
|
|||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationManageVault, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListEntries, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListGroups, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationListTemplates, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateGroup, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationMutateTemplate, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root", "Templates"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationReadEntry, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationGeneratePassword, Resource: apitokens.Resource{Kind: apitokens.ResourceGroup, Path: []string{"Root"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyPassword, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyUsername, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
apitokens.PolicyRule{Effect: apitokens.EffectAllow, Operation: apitokens.OperationCopyURL, Resource: apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: "vault-console", Path: []string{"Root", "Internet"}}},
|
||||||
@@ -1125,7 +1188,7 @@ func newTestHarnessForModel(t *testing.T, model vault.Model) (keepassgov1.VaultS
|
|||||||
listener := bufconn.Listen(1024 * 1024)
|
listener := bufconn.Listen(1024 * 1024)
|
||||||
clipboardWriter := &memoryClipboardWriter{}
|
clipboardWriter := &memoryClipboardWriter{}
|
||||||
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
|
service := NewServer(model, passwords.DefaultProfiles(), clipboardWriter)
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
server := grpc.NewServer()
|
||||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -1168,7 +1231,7 @@ func newTestHarnessWithLifecycle(t *testing.T, lifecycle *stubLifecycle) (keepas
|
|||||||
))
|
))
|
||||||
lifecycle.model = model
|
lifecycle.model = model
|
||||||
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
|
service := NewServerWithLifecycle(model, passwords.DefaultProfiles(), clipboardWriter, lifecycle)
|
||||||
server := grpc.NewServer(grpc.UnaryInterceptor(AuthInterceptor(service)))
|
server := grpc.NewServer()
|
||||||
keepassgov1.RegisterVaultServiceServer(server, service)
|
keepassgov1.RegisterVaultServiceServer(server, service)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ const (
|
|||||||
EventApprovalDenied EventType = "approval_denied"
|
EventApprovalDenied EventType = "approval_denied"
|
||||||
EventApprovalCanceled EventType = "approval_canceled"
|
EventApprovalCanceled EventType = "approval_canceled"
|
||||||
EventApprovalTimedOut EventType = "approval_timed_out"
|
EventApprovalTimedOut EventType = "approval_timed_out"
|
||||||
|
EventTokenIssued EventType = "token_issued"
|
||||||
|
EventTokenUpdated EventType = "token_updated"
|
||||||
|
EventTokenRotated EventType = "token_rotated"
|
||||||
|
EventTokenDisabled EventType = "token_disabled"
|
||||||
|
EventTokenRevoked EventType = "token_revoked"
|
||||||
|
EventTokenDeleted EventType = "token_deleted"
|
||||||
EventAutofillFound EventType = "autofill_found"
|
EventAutofillFound EventType = "autofill_found"
|
||||||
EventAutofillAmbiguous EventType = "autofill_ambiguous"
|
EventAutofillAmbiguous EventType = "autofill_ambiguous"
|
||||||
EventAutofillBlocked EventType = "autofill_blocked"
|
EventAutofillBlocked EventType = "autofill_blocked"
|
||||||
|
|||||||
@@ -55,15 +55,18 @@ const (
|
|||||||
DecisionDeny Decision = "deny"
|
DecisionDeny Decision = "deny"
|
||||||
DecisionPrompt Decision = "prompt"
|
DecisionPrompt Decision = "prompt"
|
||||||
|
|
||||||
OperationListEntries Operation = "list_entries"
|
OperationListEntries Operation = "list_entries"
|
||||||
OperationListGroups Operation = "list_groups"
|
OperationListGroups Operation = "list_groups"
|
||||||
OperationReadEntry Operation = "read_entry"
|
OperationListTemplates Operation = "list_templates"
|
||||||
OperationCopyPassword Operation = "copy_password"
|
OperationReadEntry Operation = "read_entry"
|
||||||
OperationCopyUsername Operation = "copy_username"
|
OperationCopyPassword Operation = "copy_password"
|
||||||
OperationCopyURL Operation = "copy_url"
|
OperationCopyUsername Operation = "copy_username"
|
||||||
OperationMutateEntry Operation = "mutate_entry"
|
OperationCopyURL Operation = "copy_url"
|
||||||
OperationMutateGroup Operation = "mutate_group"
|
OperationMutateEntry Operation = "mutate_entry"
|
||||||
OperationManageVault Operation = "manage_vault"
|
OperationMutateGroup Operation = "mutate_group"
|
||||||
|
OperationMutateTemplate Operation = "mutate_template"
|
||||||
|
OperationGeneratePassword Operation = "generate_password"
|
||||||
|
OperationManageVault Operation = "manage_vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Resource struct {
|
type Resource struct {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
"git.julianfamily.org/keepassgo/internal/webdav"
|
"git.julianfamily.org/keepassgo/internal/webdav"
|
||||||
@@ -101,6 +102,7 @@ type ApprovalManager interface {
|
|||||||
type State struct {
|
type State struct {
|
||||||
Session CurrentSession
|
Session CurrentSession
|
||||||
Approvals ApprovalManager
|
Approvals ApprovalManager
|
||||||
|
AuditLog *apiaudit.Log
|
||||||
Section Section
|
Section Section
|
||||||
CurrentPath []string
|
CurrentPath []string
|
||||||
SearchQuery string
|
SearchQuery string
|
||||||
@@ -195,6 +197,7 @@ func (s *State) IssueAPIToken(name, clientName string, expiresAt *time.Time, now
|
|||||||
apitokens.Upsert(&model, token)
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenIssued, token, "issued API token")
|
||||||
return token, secret, nil
|
return token, secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +221,7 @@ func (s *State) RotateAPIToken(id string, now time.Time) (apitokens.Token, strin
|
|||||||
apitokens.Upsert(&model, token)
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenRotated, token, "rotated API token")
|
||||||
return token, secret, nil
|
return token, secret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +237,7 @@ func (s *State) UpsertAPIToken(token apitokens.Token) error {
|
|||||||
apitokens.Upsert(&model, token)
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenUpdated, token, "updated API token")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,9 +254,11 @@ func (s *State) DisableAPIToken(id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
apitokens.Upsert(&model, apitokens.Disable(token))
|
token = apitokens.Disable(token)
|
||||||
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenDisabled, token, "disabled API token")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,9 +275,11 @@ func (s *State) RevokeAPIToken(id string, when time.Time) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
apitokens.Upsert(&model, apitokens.Revoke(token, when))
|
token = apitokens.Revoke(token, when)
|
||||||
|
apitokens.Upsert(&model, token)
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenRevoked, token, "revoked API token")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,14 +292,37 @@ func (s *State) DeleteAPIToken(id string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
token, err := apitokens.Find(model, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := apitokens.Delete(&model, id); err != nil {
|
if err := apitokens.Delete(&model, id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
session.Replace(model)
|
session.Replace(model)
|
||||||
s.Dirty = true
|
s.Dirty = true
|
||||||
|
s.recordTokenAudit(apiaudit.EventTokenDeleted, token, "deleted API token")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *State) recordTokenAudit(eventType apiaudit.EventType, token apitokens.Token, message string) {
|
||||||
|
if s.AuditLog == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.AuditLog.Record(apiaudit.Event{
|
||||||
|
Type: eventType,
|
||||||
|
TokenID: token.ID,
|
||||||
|
TokenName: token.Name,
|
||||||
|
ClientName: token.ClientName,
|
||||||
|
Resource: apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceEntry,
|
||||||
|
Path: apitokens.EntryPath,
|
||||||
|
EntryID: token.ID,
|
||||||
|
},
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
|
func (s *State) SecuritySettings() (vault.SecuritySettings, error) {
|
||||||
security, ok := s.Session.(SecurityConfigurableSession)
|
security, ok := s.Session.(SecurityConfigurableSession)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
"git.julianfamily.org/keepassgo/internal/apiapproval"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
@@ -109,7 +110,8 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
session := &mutableStubSession{model: vault.Model{}}
|
session := &mutableStubSession{model: vault.Model{}}
|
||||||
state := State{Session: session}
|
auditLog := apiaudit.New(10)
|
||||||
|
state := State{Session: session, AuditLog: auditLog}
|
||||||
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 3, 29, 12, 0, 0, 0, time.UTC)
|
||||||
expiresAt := now.Add(24 * time.Hour)
|
expiresAt := now.Add(24 * time.Hour)
|
||||||
|
|
||||||
@@ -162,6 +164,24 @@ func TestIssueRotateDisableRevokeAndDeleteAPIToken(t *testing.T) {
|
|||||||
if len(tokens) != 0 {
|
if len(tokens) != 0 {
|
||||||
t.Fatalf("APITokens() after delete = %#v, want empty", tokens)
|
t.Fatalf("APITokens() after delete = %#v, want empty", tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
events := auditLog.Events()
|
||||||
|
if len(events) != 5 {
|
||||||
|
t.Fatalf("len(AuditLog.Events()) = %d, want 5", len(events))
|
||||||
|
}
|
||||||
|
if events[0].Type != apiaudit.EventTokenDeleted ||
|
||||||
|
events[1].Type != apiaudit.EventTokenRevoked ||
|
||||||
|
events[2].Type != apiaudit.EventTokenDisabled ||
|
||||||
|
events[3].Type != apiaudit.EventTokenRotated ||
|
||||||
|
events[4].Type != apiaudit.EventTokenIssued {
|
||||||
|
t.Fatalf("AuditLog.Events() types = %#v, want deleted/revoked/disabled/rotated/issued", events)
|
||||||
|
}
|
||||||
|
if events[0].TokenID != issued.ID || events[0].Resource.EntryID != issued.ID {
|
||||||
|
t.Fatalf("delete audit event = %#v, want token/resource id %q", events[0], issued.ID)
|
||||||
|
}
|
||||||
|
if events[4].TokenName != "CLI" || events[4].ClientName != "grpc-cli" {
|
||||||
|
t.Fatalf("issued audit event = %#v, want CLI/grpc-cli metadata", events[4])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
func TestRemoteProfilesReturnsVaultProfiles(t *testing.T) {
|
||||||
|
|||||||
@@ -16,12 +16,15 @@ func Operations() []apitokens.Operation {
|
|||||||
return []apitokens.Operation{
|
return []apitokens.Operation{
|
||||||
apitokens.OperationListEntries,
|
apitokens.OperationListEntries,
|
||||||
apitokens.OperationListGroups,
|
apitokens.OperationListGroups,
|
||||||
|
apitokens.OperationListTemplates,
|
||||||
apitokens.OperationReadEntry,
|
apitokens.OperationReadEntry,
|
||||||
apitokens.OperationCopyPassword,
|
apitokens.OperationCopyPassword,
|
||||||
apitokens.OperationCopyUsername,
|
apitokens.OperationCopyUsername,
|
||||||
apitokens.OperationCopyURL,
|
apitokens.OperationCopyURL,
|
||||||
apitokens.OperationMutateEntry,
|
apitokens.OperationMutateEntry,
|
||||||
apitokens.OperationMutateGroup,
|
apitokens.OperationMutateGroup,
|
||||||
|
apitokens.OperationMutateTemplate,
|
||||||
|
apitokens.OperationGeneratePassword,
|
||||||
apitokens.OperationManageVault,
|
apitokens.OperationManageVault,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-11
@@ -129,7 +129,22 @@ func (u *ui) ensureAPIPolicyRemoveClickables(count int) []widget.Clickable {
|
|||||||
return clicks
|
return clicks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) ensureAPIPolicyEditClickables(count int) []widget.Clickable {
|
||||||
|
if count <= 0 {
|
||||||
|
u.apiPolicyEdits = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(u.apiPolicyEdits) == count {
|
||||||
|
return u.apiPolicyEdits
|
||||||
|
}
|
||||||
|
clicks := make([]widget.Clickable, count)
|
||||||
|
copy(clicks, u.apiPolicyEdits)
|
||||||
|
u.apiPolicyEdits = clicks
|
||||||
|
return clicks
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) loadSelectedAPITokenIntoEditor() {
|
func (u *ui) loadSelectedAPITokenIntoEditor() {
|
||||||
|
u.selectedAPIPolicyIndex = -1
|
||||||
token, ok := u.selectedAPIToken()
|
token, ok := u.selectedAPIToken()
|
||||||
if !ok {
|
if !ok {
|
||||||
u.apiTokenSecret = ""
|
u.apiTokenSecret = ""
|
||||||
@@ -143,6 +158,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
|
|||||||
u.apiPolicyAllow.Value = true
|
u.apiPolicyAllow.Value = true
|
||||||
u.apiPolicyGroupScope = true
|
u.apiPolicyGroupScope = true
|
||||||
u.apiPolicyGroupScopeW.Value = true
|
u.apiPolicyGroupScopeW.Value = true
|
||||||
|
u.ensureAPIPolicyEditClickables(0)
|
||||||
u.ensureAPIPolicyRemoveClickables(0)
|
u.ensureAPIPolicyRemoveClickables(0)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -154,6 +170,7 @@ func (u *ui) loadSelectedAPITokenIntoEditor() {
|
|||||||
u.apiTokenExpiresAt.SetText("")
|
u.apiTokenExpiresAt.SetText("")
|
||||||
}
|
}
|
||||||
u.apiTokenDisabled.Value = token.Disabled
|
u.apiTokenDisabled.Value = token.Disabled
|
||||||
|
u.ensureAPIPolicyEditClickables(len(token.Policies))
|
||||||
u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,14 +267,10 @@ func parseAPIPolicyOperation(text string) (apitokens.Operation, error) {
|
|||||||
return "", fmt.Errorf("unknown API operation %q", text)
|
return "", fmt.Errorf("unknown API operation %q", text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) addAPIPolicyRuleAction() error {
|
func (u *ui) apiPolicyRuleFromEditor() (apitokens.PolicyRule, error) {
|
||||||
token, ok := u.selectedAPIToken()
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("no API token selected")
|
|
||||||
}
|
|
||||||
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
|
operation, err := parseAPIPolicyOperation(u.apiPolicyOperation.Text())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return apitokens.PolicyRule{}, err
|
||||||
}
|
}
|
||||||
rule := apitokens.PolicyRule{
|
rule := apitokens.PolicyRule{
|
||||||
Operation: operation,
|
Operation: operation,
|
||||||
@@ -270,16 +283,28 @@ func (u *ui) addAPIPolicyRuleAction() error {
|
|||||||
if u.apiPolicyGroupScope {
|
if u.apiPolicyGroupScope {
|
||||||
path := parsePath(u.apiPolicyPath.Text())
|
path := parsePath(u.apiPolicyPath.Text())
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
return fmt.Errorf("policy path is required for group scope")
|
return apitokens.PolicyRule{}, fmt.Errorf("policy path is required for group scope")
|
||||||
}
|
}
|
||||||
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
|
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceGroup, Path: path}
|
||||||
} else {
|
} else {
|
||||||
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
|
entryID := strings.TrimSpace(u.apiPolicyEntryID.Text())
|
||||||
if entryID == "" {
|
if entryID == "" {
|
||||||
return fmt.Errorf("entry id is required for entry scope")
|
return apitokens.PolicyRule{}, fmt.Errorf("entry id is required for entry scope")
|
||||||
}
|
}
|
||||||
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
|
rule.Resource = apitokens.Resource{Kind: apitokens.ResourceEntry, EntryID: entryID}
|
||||||
}
|
}
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) addAPIPolicyRuleAction() error {
|
||||||
|
token, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no API token selected")
|
||||||
|
}
|
||||||
|
rule, err := u.apiPolicyRuleFromEditor()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if !uiHasPolicyRule(token.Policies, rule) {
|
if !uiHasPolicyRule(token.Policies, rule) {
|
||||||
token.Policies = append(token.Policies, rule)
|
token.Policies = append(token.Policies, rule)
|
||||||
}
|
}
|
||||||
@@ -290,6 +315,63 @@ func (u *ui) addAPIPolicyRuleAction() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) editAPIPolicyRuleAction(index int) error {
|
||||||
|
token, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no API token selected")
|
||||||
|
}
|
||||||
|
if index < 0 || index >= len(token.Policies) {
|
||||||
|
return fmt.Errorf("policy index %d out of range", index)
|
||||||
|
}
|
||||||
|
rule := token.Policies[index]
|
||||||
|
u.selectedAPIPolicyIndex = index
|
||||||
|
u.apiPolicyOperation.SetText(string(rule.Operation))
|
||||||
|
u.apiPolicyAllow.Value = rule.Effect == apitokens.EffectAllow
|
||||||
|
if rule.Resource.Kind == apitokens.ResourceEntry {
|
||||||
|
u.apiPolicyGroupScope = false
|
||||||
|
u.apiPolicyGroupScopeW.Value = false
|
||||||
|
u.apiPolicyEntryID.SetText(strings.TrimSpace(rule.Resource.EntryID))
|
||||||
|
u.apiPolicyPath.SetText("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
u.apiPolicyGroupScope = true
|
||||||
|
u.apiPolicyGroupScopeW.Value = true
|
||||||
|
u.apiPolicyPath.SetText(strings.Join(rule.Resource.Path, " / "))
|
||||||
|
u.apiPolicyEntryID.SetText("")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) saveAPIPolicyRuleAction() error {
|
||||||
|
token, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no API token selected")
|
||||||
|
}
|
||||||
|
index := u.selectedAPIPolicyIndex
|
||||||
|
if index < 0 || index >= len(token.Policies) {
|
||||||
|
return fmt.Errorf("no API policy rule selected")
|
||||||
|
}
|
||||||
|
rule, err := u.apiPolicyRuleFromEditor()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, existing := range token.Policies {
|
||||||
|
if i != index && uiHasPolicyRule([]apitokens.PolicyRule{existing}, rule) {
|
||||||
|
token.Policies = append(token.Policies[:index], token.Policies[index+1:]...)
|
||||||
|
if err := u.state.UpsertAPIToken(token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.loadSelectedAPITokenIntoEditor()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
token.Policies[index] = rule
|
||||||
|
if err := u.state.UpsertAPIToken(token); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.loadSelectedAPITokenIntoEditor()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) apiPolicyGroupPathSummary() string {
|
func (u *ui) apiPolicyGroupPathSummary() string {
|
||||||
path := parsePath(u.apiPolicyPath.Text())
|
path := parsePath(u.apiPolicyPath.Text())
|
||||||
if len(path) == 0 {
|
if len(path) == 0 {
|
||||||
@@ -357,6 +439,11 @@ func (u *ui) removeAPIPolicyRuleAction(index int) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) cancelAPIPolicyEditAction() error {
|
||||||
|
u.loadSelectedAPITokenIntoEditor()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) apiAuditEvents() []apiaudit.Event {
|
func (u *ui) apiAuditEvents() []apiaudit.Event {
|
||||||
if u.auditLog == nil {
|
if u.auditLog == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -749,8 +836,10 @@ func (u *ui) auditQuickFilterButton(gtx layout.Context, click *widget.Clickable,
|
|||||||
|
|
||||||
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
||||||
token, ok := u.selectedAPIToken()
|
token, ok := u.selectedAPIToken()
|
||||||
|
editClicks := u.ensureAPIPolicyEditClickables(0)
|
||||||
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
|
removeClicks := u.ensureAPIPolicyRemoveClickables(0)
|
||||||
if ok {
|
if ok {
|
||||||
|
editClicks = u.ensureAPIPolicyEditClickables(len(token.Policies))
|
||||||
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
removeClicks = u.ensureAPIPolicyRemoveClickables(len(token.Policies))
|
||||||
}
|
}
|
||||||
rows := []layout.Widget{
|
rows := []layout.Widget{
|
||||||
@@ -918,6 +1007,10 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
|
layout.Flexed(1, detailLine(u.theme, "Effect", effect)),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
layout.Rigid(layout.Spacer{Width: unit.Dp(12)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &editClicks[index], "Edit")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(6)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
|
return tonedButton(gtx, u.theme, &removeClicks[index], "Remove")
|
||||||
}),
|
}),
|
||||||
@@ -951,15 +1044,23 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
rows = append(rows,
|
rows = append(rows,
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
actionLabel := "Add Rule"
|
||||||
|
title := "Policy Composer"
|
||||||
|
description := "Rules are evaluated per operation. Explicit deny rules override allow rules."
|
||||||
|
if 0 <= u.selectedAPIPolicyIndex {
|
||||||
|
actionLabel = "Save Rule"
|
||||||
|
title = "Policy Editor"
|
||||||
|
description = "Editing an existing rule. Save the updated scope or cancel to return to a blank composer."
|
||||||
|
}
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(14), "Policy Composer")
|
lbl := material.Label(u.theme, unit.Sp(14), title)
|
||||||
lbl.Color = accentColor
|
lbl.Color = accentColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(4)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(12), "Rules are evaluated per operation. Explicit deny rules override allow rules.")
|
lbl := material.Label(u.theme, unit.Sp(12), description)
|
||||||
lbl.Color = mutedColor
|
lbl.Color = mutedColor
|
||||||
return lbl.Layout(gtx)
|
return lbl.Layout(gtx)
|
||||||
}),
|
}),
|
||||||
@@ -1014,7 +1115,22 @@ func (u *ui) apiTokenDetailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, "Add Rule")
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if 0 <= u.selectedAPIPolicyIndex {
|
||||||
|
return tonedButton(gtx, u.theme, &u.saveAPIPolicyRule, actionLabel)
|
||||||
|
}
|
||||||
|
return tonedButton(gtx, u.theme, &u.addAPIPolicyRule, actionLabel)
|
||||||
|
}),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.selectedAPIPolicyIndex < 0 {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
return layout.Inset{Left: unit.Dp(6)}.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.cancelAPIPolicyEdit, "Cancel Edit")
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
+67
-49
@@ -15,6 +15,7 @@ import (
|
|||||||
"gioui.org/io/key"
|
"gioui.org/io/key"
|
||||||
"gioui.org/io/pointer"
|
"gioui.org/io/pointer"
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
"gioui.org/op/clip"
|
"gioui.org/op/clip"
|
||||||
"gioui.org/op/paint"
|
"gioui.org/op/paint"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
@@ -363,12 +364,13 @@ type ui struct {
|
|||||||
showAutofillApprovalAsk widget.Clickable
|
showAutofillApprovalAsk widget.Clickable
|
||||||
showAutofillApprovalAllow widget.Clickable
|
showAutofillApprovalAllow widget.Clickable
|
||||||
showAutofillApprovalBlock widget.Clickable
|
showAutofillApprovalBlock widget.Clickable
|
||||||
allowApproval widget.Clickable
|
allowApprovalOnce widget.Clickable
|
||||||
denyApproval widget.Clickable
|
allowApprovalPermanent widget.Clickable
|
||||||
|
denyApprovalOnce widget.Clickable
|
||||||
|
denyApprovalPermanent widget.Clickable
|
||||||
cancelApproval widget.Clickable
|
cancelApproval widget.Clickable
|
||||||
cancelLifecycleProgress widget.Clickable
|
cancelLifecycleProgress widget.Clickable
|
||||||
retryLifecycleOpen widget.Clickable
|
retryLifecycleOpen widget.Clickable
|
||||||
approvalPermanent widget.Bool
|
|
||||||
syncSetupAutomatic widget.Bool
|
syncSetupAutomatic widget.Bool
|
||||||
apiPolicyAllow widget.Bool
|
apiPolicyAllow widget.Bool
|
||||||
apiPolicyGroupScopeW widget.Bool
|
apiPolicyGroupScopeW widget.Bool
|
||||||
@@ -377,8 +379,10 @@ type ui struct {
|
|||||||
settingsLifecycleAdvanced widget.Bool
|
settingsLifecycleAdvanced widget.Bool
|
||||||
settingsHistory widget.Bool
|
settingsHistory widget.Bool
|
||||||
settingsDenseLayout widget.Bool
|
settingsDenseLayout widget.Bool
|
||||||
|
settingsDebugHeaderBounds widget.Bool
|
||||||
entryClicks []widget.Clickable
|
entryClicks []widget.Clickable
|
||||||
apiTokenClicks []widget.Clickable
|
apiTokenClicks []widget.Clickable
|
||||||
|
apiPolicyEdits []widget.Clickable
|
||||||
apiPolicyRemoves []widget.Clickable
|
apiPolicyRemoves []widget.Clickable
|
||||||
apiAuditClicks []widget.Clickable
|
apiAuditClicks []widget.Clickable
|
||||||
apiAuditTokenFilters []widget.Clickable
|
apiAuditTokenFilters []widget.Clickable
|
||||||
@@ -414,6 +418,8 @@ type ui struct {
|
|||||||
useSelectedEntryForPolicy widget.Clickable
|
useSelectedEntryForPolicy widget.Clickable
|
||||||
clearAPIPolicyTarget widget.Clickable
|
clearAPIPolicyTarget widget.Clickable
|
||||||
addAPIPolicyRule widget.Clickable
|
addAPIPolicyRule widget.Clickable
|
||||||
|
saveAPIPolicyRule widget.Clickable
|
||||||
|
cancelAPIPolicyEdit widget.Clickable
|
||||||
phoneSplit widget.Float
|
phoneSplit widget.Float
|
||||||
splitDrag gesture.Drag
|
splitDrag gesture.Drag
|
||||||
splitBase float32
|
splitBase float32
|
||||||
@@ -477,6 +483,7 @@ type ui struct {
|
|||||||
autofillNoticePreference autofillNoticeMode
|
autofillNoticePreference autofillNoticeMode
|
||||||
autofillFirstFillApprovalMode autofillFirstFillApprovalMode
|
autofillFirstFillApprovalMode autofillFirstFillApprovalMode
|
||||||
accessibilityPrefs accessibilityPreferences
|
accessibilityPrefs accessibilityPreferences
|
||||||
|
debugLogHeaderBounds bool
|
||||||
settingsDraft settingsDraft
|
settingsDraft settingsDraft
|
||||||
recentVaults []string
|
recentVaults []string
|
||||||
recentRemotes []recentRemoteRecord
|
recentRemotes []recentRemoteRecord
|
||||||
@@ -485,9 +492,14 @@ type ui struct {
|
|||||||
entriesState entriesSectionState
|
entriesState entriesSectionState
|
||||||
deleteGroupPath []string
|
deleteGroupPath []string
|
||||||
apiPolicyGroupScope bool
|
apiPolicyGroupScope bool
|
||||||
|
selectedAPIPolicyIndex int
|
||||||
apiTokenSecret string
|
apiTokenSecret string
|
||||||
phoneSyncMenuAnchor image.Point
|
phoneSyncMenuOrigin image.Point
|
||||||
phoneMainMenuAnchor image.Point
|
phoneMainMenuOrigin image.Point
|
||||||
|
phoneSyncMenuSize image.Point
|
||||||
|
phoneMainMenuSize image.Point
|
||||||
|
phoneSyncMenuCall op.CallOp
|
||||||
|
phoneMainMenuCall op.CallOp
|
||||||
phoneSyncMenuVisible bool
|
phoneSyncMenuVisible bool
|
||||||
phoneMainMenuVisible bool
|
phoneMainMenuVisible bool
|
||||||
selectedAuditIndex int
|
selectedAuditIndex int
|
||||||
@@ -502,6 +514,8 @@ type ui struct {
|
|||||||
lastLifecycleAction string
|
lastLifecycleAction string
|
||||||
pendingLifecycleOpenIntent lifecycleOpenIntent
|
pendingLifecycleOpenIntent lifecycleOpenIntent
|
||||||
requestMasterPassFocus bool
|
requestMasterPassFocus bool
|
||||||
|
lastHeaderBoundsLog string
|
||||||
|
frameInsetPx int
|
||||||
invalidate func()
|
invalidate func()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,6 +670,7 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
vaultSharer: platform.NewVaultSharer(runtime.GOOS),
|
vaultSharer: platform.NewVaultSharer(runtime.GOOS),
|
||||||
backgroundResults: make(chan backgroundActionResult, 8),
|
backgroundResults: make(chan backgroundActionResult, 8),
|
||||||
phoneGroupBrowserExpanded: true,
|
phoneGroupBrowserExpanded: true,
|
||||||
|
selectedAPIPolicyIndex: -1,
|
||||||
}
|
}
|
||||||
if mode == "phone" {
|
if mode == "phone" {
|
||||||
u.groupControlsHidden = true
|
u.groupControlsHidden = true
|
||||||
@@ -688,6 +703,9 @@ func newUIWithState(mode string, sess appstate.CurrentSession, paths statePaths)
|
|||||||
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
|
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
|
||||||
u.loadUIPreferences()
|
u.loadUIPreferences()
|
||||||
u.loadSettings()
|
u.loadSettings()
|
||||||
|
if u.debugLogHeaderBounds {
|
||||||
|
platform.LogInfo("KeePassGO", "keepassgo header-bounds logging enabled")
|
||||||
|
}
|
||||||
u.loadSettingsFormFromPreferences()
|
u.loadSettingsFormFromPreferences()
|
||||||
u.loadSettingsDraft()
|
u.loadSettingsDraft()
|
||||||
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
|
u.requestMasterPassFocus = u.hasSelectedLifecycleTarget()
|
||||||
@@ -1136,6 +1154,16 @@ func (u *ui) securityDialogContent(gtx layout.Context) layout.Dimensions {
|
|||||||
check := material.CheckBox(u.theme, &u.settingsHistory, "Keep entry history collapsed")
|
check := material.CheckBox(u.theme, &u.settingsHistory, "Keep entry history collapsed")
|
||||||
return check.Layout(gtx)
|
return check.Layout(gtx)
|
||||||
},
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
check := material.CheckBox(u.theme, &u.settingsDebugHeaderBounds, "Log compact header button bounds")
|
||||||
|
return check.Layout(gtx)
|
||||||
|
},
|
||||||
|
layout.Spacer{Height: unit.Dp(4)}.Layout,
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
lbl := material.Label(u.theme, unit.Sp(12), "Write compact Android header button screen coordinates to the app log so emulator taps can read exact bounds from logcat.")
|
||||||
|
lbl.Color = mutedColor
|
||||||
|
return lbl.Layout(gtx)
|
||||||
|
},
|
||||||
layout.Spacer{Height: unit.Dp(14)}.Layout,
|
layout.Spacer{Height: unit.Dp(14)}.Layout,
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
lbl := material.Label(u.theme, unit.Sp(16), "Vault Security")
|
lbl := material.Label(u.theme, unit.Sp(16), "Vault Security")
|
||||||
@@ -1409,23 +1437,33 @@ func (u *ui) approvalDialogContent(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx)
|
return approvalFact(u.theme, "Operation", string(request.Operation), resourceText)(gtx)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
check := material.CheckBox(u.theme, &u.approvalPermanent, "Make this decision permanent")
|
|
||||||
check.Color = accentColor
|
|
||||||
return check.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(14)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.allowApproval, "Allow")
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.allowApprovalOnce, "Allow Once")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.allowApprovalPermanent, "Allow Permanently")
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.denyApproval, "Deny")
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.denyApprovalOnce, "Deny Once")
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.denyApprovalPermanent, "Deny Permanently")
|
||||||
|
}),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel")
|
return tonedButton(gtx, u.theme, &u.cancelApproval, "Cancel")
|
||||||
}),
|
}),
|
||||||
@@ -1828,8 +1866,17 @@ func (u *ui) navigationHeaderLabel() string {
|
|||||||
|
|
||||||
func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions {
|
func (u *ui) entryRow(gtx layout.Context, click *widget.Clickable, idx int, item entry) layout.Dimensions {
|
||||||
for click.Clicked(gtx) {
|
for click.Clicked(gtx) {
|
||||||
_ = u.state.ToggleVisibleIndex(idx)
|
if !u.shouldShowDetailPane() {
|
||||||
|
if idx >= 0 && idx < len(u.visible) {
|
||||||
|
u.state.SelectedEntryID = u.visible[idx].ID
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
_ = u.state.ToggleVisibleIndex(idx)
|
||||||
|
}
|
||||||
u.loadSelectedEntryIntoEditor()
|
u.loadSelectedEntryIntoEditor()
|
||||||
|
if u.invalidate != nil {
|
||||||
|
u.invalidate()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
return click.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
selected := item.ID == u.state.SelectedEntryID
|
selected := item.ID == u.state.SelectedEntryID
|
||||||
@@ -1992,21 +2039,8 @@ func (u *ui) detailPanel(gtx layout.Context) layout.Dimensions {
|
|||||||
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
return panel(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
if u.shouldShowDesktopWorkingHeader() {
|
if u.shouldShowDesktopWorkingHeader() {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(u.headerActions),
|
||||||
return layout.Flex{Alignment: layout.Middle, Spacing: layout.SpaceStart}.Layout(gtx,
|
layout.Rigid(u.desktopHeaderMenus),
|
||||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Dimensions{}
|
|
||||||
}),
|
|
||||||
layout.Rigid(u.syncButtonGroup),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
|
||||||
return btn.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(u.mainMenuButtonGroup),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
layout.Rigid(layout.Spacer{Height: unit.Dp(12)}.Layout),
|
||||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
||||||
return u.detailPanelContent(gtx)
|
return u.detailPanelContent(gtx)
|
||||||
@@ -2027,7 +2061,7 @@ func (u *ui) detailPanelContent(gtx layout.Context) layout.Dimensions {
|
|||||||
panel := u.staticDetailPanel()
|
panel := u.staticDetailPanel()
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, panel...)
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, panel...)
|
||||||
case detaillayout.ModeEmpty:
|
case detaillayout.ModeEmpty:
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.emptyDetailChildren()...)
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
item, ok := u.selectedEntry()
|
item, ok := u.selectedEntry()
|
||||||
if mode == detaillayout.ModeEditor {
|
if mode == detaillayout.ModeEditor {
|
||||||
@@ -2067,22 +2101,6 @@ func (u *ui) staticDetailPanel() []layout.FlexChild {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) emptyDetailChildren() []layout.FlexChild {
|
|
||||||
return []layout.FlexChild{
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(18), "Entry details")
|
|
||||||
lbl.Color = accentColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(16), u.detailPlaceholderMessage())
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) detailEditorContent(gtx layout.Context, hasSelected bool) layout.Dimensions {
|
func (u *ui) detailEditorContent(gtx layout.Context, hasSelected bool) layout.Dimensions {
|
||||||
rows := []layout.Widget{
|
rows := []layout.Widget{
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
|||||||
+55
-22
@@ -3,6 +3,7 @@ package appui
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
"git.julianfamily.org/keepassgo/internal/apiaudit"
|
||||||
"git.julianfamily.org/keepassgo/internal/apitokens"
|
"git.julianfamily.org/keepassgo/internal/apitokens"
|
||||||
"git.julianfamily.org/keepassgo/internal/appstate"
|
"git.julianfamily.org/keepassgo/internal/appstate"
|
||||||
|
detaillayout "git.julianfamily.org/keepassgo/internal/appui/detail/layout"
|
||||||
"git.julianfamily.org/keepassgo/internal/clipboard"
|
"git.julianfamily.org/keepassgo/internal/clipboard"
|
||||||
"git.julianfamily.org/keepassgo/internal/session"
|
"git.julianfamily.org/keepassgo/internal/session"
|
||||||
)
|
)
|
||||||
@@ -590,6 +592,12 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op())
|
paint.FillShape(gtx.Ops, bgColor, clip.Rect{Max: gtx.Constraints.Max}.Op())
|
||||||
u.phoneSyncMenuVisible = false
|
u.phoneSyncMenuVisible = false
|
||||||
u.phoneMainMenuVisible = false
|
u.phoneMainMenuVisible = false
|
||||||
|
u.phoneSyncMenuOrigin = image.Point{}
|
||||||
|
u.phoneMainMenuOrigin = image.Point{}
|
||||||
|
u.phoneSyncMenuSize = image.Point{}
|
||||||
|
u.phoneMainMenuSize = image.Point{}
|
||||||
|
u.phoneSyncMenuCall = op.CallOp{}
|
||||||
|
u.phoneMainMenuCall = op.CallOp{}
|
||||||
u.syncHostedAPI()
|
u.syncHostedAPI()
|
||||||
u.filter()
|
u.filter()
|
||||||
u.processShortcuts(gtx)
|
u.processShortcuts(gtx)
|
||||||
@@ -603,6 +611,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
u.handleGroupClicks(gtx)
|
u.handleGroupClicks(gtx)
|
||||||
u.handleInputUpdates(gtx)
|
u.handleInputUpdates(gtx)
|
||||||
u.updateViewportLayoutMode(gtx)
|
u.updateViewportLayoutMode(gtx)
|
||||||
|
u.frameInsetPx = gtx.Dp(unit.Dp(16))
|
||||||
inset := layout.UniformInset(unit.Dp(16))
|
inset := layout.UniformInset(unit.Dp(16))
|
||||||
return layout.Stack{}.Layout(gtx,
|
return layout.Stack{}.Layout(gtx,
|
||||||
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
layout.Expanded(func(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -614,9 +623,7 @@ func (u *ui) layout(gtx layout.Context) layout.Dimensions {
|
|||||||
layout.Stacked(u.securityDialogOverlay),
|
layout.Stacked(u.securityDialogOverlay),
|
||||||
layout.Stacked(u.remotePrefsDialogOverlay),
|
layout.Stacked(u.remotePrefsDialogOverlay),
|
||||||
layout.Stacked(u.approvalDialogOverlay),
|
layout.Stacked(u.approvalDialogOverlay),
|
||||||
layout.Stacked(func(gtx layout.Context) layout.Dimensions {
|
layout.Expanded(u.phoneHeaderMenus),
|
||||||
return u.phoneHeaderMenus(gtx)
|
|
||||||
}),
|
|
||||||
layout.Stacked(u.statusToast),
|
layout.Stacked(u.statusToast),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -678,12 +685,14 @@ func (u *ui) handleHeaderActionClicks(gtx layout.Context) {
|
|||||||
if u.syncMenuOpen {
|
if u.syncMenuOpen {
|
||||||
u.mainMenuOpen = false
|
u.mainMenuOpen = false
|
||||||
}
|
}
|
||||||
|
u.maybeLogHeaderMenuToggle("sync", u.syncMenuOpen)
|
||||||
}
|
}
|
||||||
for u.toggleMainMenu.Clicked(gtx) {
|
for u.toggleMainMenu.Clicked(gtx) {
|
||||||
u.mainMenuOpen = !u.mainMenuOpen
|
u.mainMenuOpen = !u.mainMenuOpen
|
||||||
if u.mainMenuOpen {
|
if u.mainMenuOpen {
|
||||||
u.syncMenuOpen = false
|
u.syncMenuOpen = false
|
||||||
}
|
}
|
||||||
|
u.maybeLogHeaderMenuToggle("main", u.mainMenuOpen)
|
||||||
}
|
}
|
||||||
for u.openAdvancedSync.Clicked(gtx) {
|
for u.openAdvancedSync.Clicked(gtx) {
|
||||||
u.openAdvancedSyncDialog()
|
u.openAdvancedSyncDialog()
|
||||||
@@ -919,33 +928,29 @@ func (u *ui) handleApprovalAndAPIClicks(gtx layout.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) handleApprovalClicks(gtx layout.Context) {
|
func (u *ui) handleApprovalClicks(gtx layout.Context) {
|
||||||
for u.allowApproval.Clicked(gtx) {
|
for u.allowApprovalOnce.Clicked(gtx) {
|
||||||
u.runAction("allow API request", func() error {
|
u.runAction("allow API request", func() error {
|
||||||
outcome := apiapproval.OutcomeAllowOnce
|
return u.resolvePendingApproval(apiapproval.OutcomeAllowOnce)
|
||||||
if u.approvalPermanent.Value {
|
|
||||||
outcome = apiapproval.OutcomeAllowPermanent
|
|
||||||
}
|
|
||||||
err := u.resolvePendingApproval(outcome)
|
|
||||||
u.approvalPermanent.Value = false
|
|
||||||
return err
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for u.denyApproval.Clicked(gtx) {
|
for u.allowApprovalPermanent.Clicked(gtx) {
|
||||||
|
u.runAction("allow API request permanently", func() error {
|
||||||
|
return u.resolvePendingApproval(apiapproval.OutcomeAllowPermanent)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for u.denyApprovalOnce.Clicked(gtx) {
|
||||||
u.runAction("deny API request", func() error {
|
u.runAction("deny API request", func() error {
|
||||||
outcome := apiapproval.OutcomeDenyOnce
|
return u.resolvePendingApproval(apiapproval.OutcomeDenyOnce)
|
||||||
if u.approvalPermanent.Value {
|
})
|
||||||
outcome = apiapproval.OutcomeDenyPermanent
|
}
|
||||||
}
|
for u.denyApprovalPermanent.Clicked(gtx) {
|
||||||
err := u.resolvePendingApproval(outcome)
|
u.runAction("deny API request permanently", func() error {
|
||||||
u.approvalPermanent.Value = false
|
return u.resolvePendingApproval(apiapproval.OutcomeDenyPermanent)
|
||||||
return err
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
for u.cancelApproval.Clicked(gtx) {
|
for u.cancelApproval.Clicked(gtx) {
|
||||||
u.runAction("cancel API request", func() error {
|
u.runAction("cancel API request", func() error {
|
||||||
err := u.resolvePendingApproval(apiapproval.OutcomeCancel)
|
return u.resolvePendingApproval(apiapproval.OutcomeCancel)
|
||||||
u.approvalPermanent.Value = false
|
|
||||||
return err
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -987,6 +992,12 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
|
|||||||
for u.addAPIPolicyRule.Clicked(gtx) {
|
for u.addAPIPolicyRule.Clicked(gtx) {
|
||||||
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
|
u.runAction("add API policy rule", u.addAPIPolicyRuleAction)
|
||||||
}
|
}
|
||||||
|
for u.saveAPIPolicyRule.Clicked(gtx) {
|
||||||
|
u.runAction("save API policy rule", u.saveAPIPolicyRuleAction)
|
||||||
|
}
|
||||||
|
for u.cancelAPIPolicyEdit.Clicked(gtx) {
|
||||||
|
u.runAction("cancel API policy edit", u.cancelAPIPolicyEditAction)
|
||||||
|
}
|
||||||
for u.useCurrentGroupForPolicy.Clicked(gtx) {
|
for u.useCurrentGroupForPolicy.Clicked(gtx) {
|
||||||
u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction)
|
u.runAction("use current group for API policy", u.useCurrentGroupForPolicyAction)
|
||||||
}
|
}
|
||||||
@@ -996,6 +1007,12 @@ func (u *ui) handleAPIPolicyClicks(gtx layout.Context) {
|
|||||||
for u.clearAPIPolicyTarget.Clicked(gtx) {
|
for u.clearAPIPolicyTarget.Clicked(gtx) {
|
||||||
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
|
u.runAction("clear API policy target", u.clearAPIPolicyTargetAction)
|
||||||
}
|
}
|
||||||
|
for i := range u.apiPolicyEdits {
|
||||||
|
for u.apiPolicyEdits[i].Clicked(gtx) {
|
||||||
|
index := i
|
||||||
|
u.runAction("edit API policy rule", func() error { return u.editAPIPolicyRuleAction(index) })
|
||||||
|
}
|
||||||
|
}
|
||||||
for i := range u.apiPolicyRemoves {
|
for i := range u.apiPolicyRemoves {
|
||||||
for u.apiPolicyRemoves[i].Clicked(gtx) {
|
for u.apiPolicyRemoves[i].Clicked(gtx) {
|
||||||
index := i
|
index := i
|
||||||
@@ -1302,6 +1319,11 @@ func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions {
|
|||||||
return u.lifecycleScreen(gtx)
|
return u.lifecycleScreen(gtx)
|
||||||
case u.shouldUseLockedSinglePane():
|
case u.shouldUseLockedSinglePane():
|
||||||
return u.detailPanel(gtx)
|
return u.detailPanel(gtx)
|
||||||
|
case !u.shouldShowDetailPane():
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
return u.listPanel(gtx)
|
||||||
|
}
|
||||||
|
return u.widePrimaryContent(gtx)
|
||||||
case u.usesCompactViewport():
|
case u.usesCompactViewport():
|
||||||
return u.compactPrimaryContent(gtx)
|
return u.compactPrimaryContent(gtx)
|
||||||
default:
|
default:
|
||||||
@@ -1309,6 +1331,17 @@ func (u *ui) primaryContent(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) shouldShowDetailPane() bool {
|
||||||
|
_, hasSelectedEntry := u.selectedEntry()
|
||||||
|
mode := detaillayout.Resolve(
|
||||||
|
u.isVaultLocked(),
|
||||||
|
u.state.Section == appstate.SectionAPITokens || u.state.Section == appstate.SectionAPIAudit || u.state.Section == appstate.SectionAbout,
|
||||||
|
hasSelectedEntry,
|
||||||
|
u.editingEntry,
|
||||||
|
)
|
||||||
|
return mode != detaillayout.ModeEmpty
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) compactPrimaryContent(gtx layout.Context) layout.Dimensions {
|
func (u *ui) compactPrimaryContent(gtx layout.Context) layout.Dimensions {
|
||||||
u.phoneSpan = gtx.Constraints.Max.Y
|
u.phoneSpan = gtx.Constraints.Max.Y
|
||||||
listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value)
|
listHeight := int(float32(gtx.Constraints.Max.Y) * u.phoneSplit.Value)
|
||||||
|
|||||||
+215
-56
@@ -1,6 +1,7 @@
|
|||||||
package appui
|
package appui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
|
headerview "git.julianfamily.org/keepassgo/internal/appui/header"
|
||||||
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,74 +27,163 @@ func (u *ui) header(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
return card(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
layout.Flexed(1, func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return u.brandMark(gtx, 196, 56)
|
return u.brandMark(gtx, 196, 56)
|
||||||
}),
|
}),
|
||||||
layout.Rigid(u.headerActions),
|
layout.Flexed(1, u.headerActions),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
func (u *ui) headerActions(gtx layout.Context) layout.Dimensions {
|
||||||
if u.shouldShowLifecycleSetup() || u.isVaultLocked() || u.shouldShowDesktopWorkingHeader() {
|
if u.shouldShowLifecycleSetup() || u.isVaultLocked() {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
|
cluster := u.newHeaderActionCluster(gtx)
|
||||||
|
rowDims := cluster.layout(gtx, u)
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
u.maybeLogHeaderBounds(newHeaderButtonBounds(image.Pt(u.frameInsetPx, u.frameInsetPx), cluster.Metrics.Bounds()))
|
||||||
|
}
|
||||||
|
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
cluster.prepareCompactMenus(gtx, u)
|
||||||
|
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, rowDims.Size.Y)}
|
||||||
|
}
|
||||||
|
return rowDims
|
||||||
|
}
|
||||||
|
|
||||||
|
type headerActionCluster struct {
|
||||||
|
Metrics headerlayout.ActionMetrics
|
||||||
|
SyncMenu layout.Widget
|
||||||
|
MainMenu layout.Widget
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c headerActionCluster) ShowSyncMenu() bool { return c.SyncMenu != nil }
|
||||||
|
|
||||||
|
func (c headerActionCluster) ShowMainMenu() bool { return c.MainMenu != nil }
|
||||||
|
|
||||||
|
func (u *ui) newHeaderActionCluster(gtx layout.Context) headerActionCluster {
|
||||||
|
cluster := headerActionCluster{
|
||||||
|
SyncMenu: u.syncMenuWidget(),
|
||||||
|
MainMenu: u.mainMenuWidget(),
|
||||||
|
}
|
||||||
spacing := gtx.Dp(unit.Dp(8))
|
spacing := gtx.Dp(unit.Dp(8))
|
||||||
metrics := headerlayout.ActionMetrics{Spacing: spacing}
|
cluster.Metrics = headerlayout.ActionMetrics{Spacing: spacing, SyncInnerSpacing: gtx.Dp(unit.Dp(3))}
|
||||||
row := func(gtx layout.Context) layout.Dimensions {
|
if !u.usesCompactViewport() {
|
||||||
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
cluster.Metrics.SyncInnerSpacing = gtx.Dp(unit.Dp(4))
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
metrics.SyncDims = u.syncButtonGroup(gtx)
|
|
||||||
return metrics.SyncDims
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
|
||||||
metrics.LockDims = btn.Layout(gtx)
|
|
||||||
return metrics.LockDims
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
metrics.MainDims = u.mainMenuButtonGroup(gtx)
|
|
||||||
return metrics.MainDims
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
return cluster
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *headerActionCluster) layout(gtx layout.Context, u *ui) layout.Dimensions {
|
||||||
rowOps := op.Record(gtx.Ops)
|
rowOps := op.Record(gtx.Ops)
|
||||||
metrics.RowDims = row(gtx)
|
c.Metrics.RowDims = c.layoutRow(gtx, u)
|
||||||
rowCall := rowOps.Stop()
|
rowCall := rowOps.Stop()
|
||||||
|
c.Metrics.RowOriginX = max(0, gtx.Constraints.Max.X-c.Metrics.RowDims.Size.X)
|
||||||
|
return layout.E.Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
rowCall.Add(gtx.Ops)
|
||||||
|
return c.Metrics.RowDims
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if u.usesCompactViewport() {
|
func (c headerActionCluster) activeMenu() layout.Widget {
|
||||||
metrics.RowOriginX = max(0, gtx.Constraints.Max.X-metrics.RowDims.Size.X)
|
switch {
|
||||||
|
case c.ShowSyncMenu():
|
||||||
|
return c.SyncMenu
|
||||||
|
case c.ShowMainMenu():
|
||||||
|
return c.MainMenu
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
surface := headerlayout.DropdownSurface{ContainerWidth: gtx.Constraints.Max.X, LeftInset: 0, TopInset: 0}
|
func (c *headerActionCluster) layoutRow(gtx layout.Context, u *ui) layout.Dimensions {
|
||||||
|
return layout.Flex{Spacing: layout.SpaceStart}.Layout(gtx,
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
c.Metrics.SyncDims, c.Metrics.SyncPrimaryDims, c.Metrics.SyncToggleDims = u.syncButtonGroupWithMetrics(gtx)
|
||||||
|
return c.Metrics.SyncDims
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
||||||
|
c.Metrics.LockDims = btn.Layout(gtx)
|
||||||
|
return c.Metrics.LockDims
|
||||||
|
}),
|
||||||
|
layout.Rigid(layout.Spacer{Width: unit.Dp(8)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
c.Metrics.MainDims = u.mainMenuButtonGroup(gtx)
|
||||||
|
return c.Metrics.MainDims
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
rowStack := op.Offset(image.Pt(metrics.RowOriginX, 0)).Push(gtx.Ops)
|
func (c *headerActionCluster) prepareCompactMenus(gtx layout.Context, u *ui) {
|
||||||
rowCall.Add(gtx.Ops)
|
compactSurface := headerlayout.DropdownSurface{
|
||||||
rowStack.Pop()
|
ContainerWidth: gtx.Constraints.Max.X,
|
||||||
|
LeftInset: u.frameInsetPx,
|
||||||
if u.usesCompactViewport() {
|
TopInset: u.frameInsetPx,
|
||||||
if u.syncMenuOpen {
|
|
||||||
u.phoneSyncMenuVisible = true
|
|
||||||
u.phoneSyncMenuAnchor = metrics.SyncAnchor().Point()
|
|
||||||
}
|
|
||||||
if u.mainMenuOpen {
|
|
||||||
u.phoneMainMenuVisible = true
|
|
||||||
u.phoneMainMenuAnchor = metrics.MainAnchor().Point()
|
|
||||||
}
|
|
||||||
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, metrics.RowDims.Size.Y)}
|
|
||||||
}
|
}
|
||||||
|
if c.ShowSyncMenu() {
|
||||||
if u.syncMenuOpen {
|
u.phoneSyncMenuVisible = true
|
||||||
surface.Draw(gtx, metrics.SyncAnchor(), u.syncMenu)
|
u.maybeLogHeaderMenuToggle("sync-visible", true)
|
||||||
|
placement, menuCall := compactSurface.Place(gtx, c.Metrics.SyncAnchor(), c.SyncMenu)
|
||||||
|
u.phoneSyncMenuOrigin = placement.Origin
|
||||||
|
u.phoneSyncMenuSize = placement.Size
|
||||||
|
u.phoneSyncMenuCall = menuCall
|
||||||
|
u.maybeLogHeaderMenuPlacement("sync-phone", compactSurface, placement)
|
||||||
}
|
}
|
||||||
if u.mainMenuOpen {
|
if c.ShowMainMenu() {
|
||||||
surface.Draw(gtx, metrics.MainAnchor(), u.mainMenu)
|
u.phoneMainMenuVisible = true
|
||||||
|
u.maybeLogHeaderMenuToggle("main-visible", true)
|
||||||
|
placement, menuCall := compactSurface.Place(gtx, c.Metrics.MainAnchor(), c.MainMenu)
|
||||||
|
u.phoneMainMenuOrigin = placement.Origin
|
||||||
|
u.phoneMainMenuSize = placement.Size
|
||||||
|
u.phoneMainMenuCall = menuCall
|
||||||
|
u.maybeLogHeaderMenuPlacement("main-phone", compactSurface, placement)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return layout.Dimensions{Size: image.Pt(metrics.RowDims.Size.X, metrics.RowDims.Size.Y)}
|
func (c headerActionCluster) layoutMenuRow(gtx layout.Context) layout.Dimensions {
|
||||||
|
menu := c.activeMenu()
|
||||||
|
if menu == nil {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
fullWidthGTX := gtx
|
||||||
|
fullWidthGTX.Constraints.Min = image.Point{}
|
||||||
|
fullWidthGTX.Constraints.Min.X = fullWidthGTX.Constraints.Max.X
|
||||||
|
dims := layout.E.Layout(fullWidthGTX, menu)
|
||||||
|
return layout.Dimensions{Size: image.Pt(fullWidthGTX.Constraints.Max.X, dims.Size.Y)}
|
||||||
|
}
|
||||||
|
|
||||||
|
type headerButtonBounds struct {
|
||||||
|
SyncPrimary image.Rectangle
|
||||||
|
SyncToggle image.Rectangle
|
||||||
|
Lock image.Rectangle
|
||||||
|
MainMenu image.Rectangle
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHeaderButtonBounds(origin image.Point, bounds headerlayout.ActionBounds) headerButtonBounds {
|
||||||
|
return headerButtonBounds{
|
||||||
|
SyncPrimary: bounds.SyncPrimary.Add(origin),
|
||||||
|
SyncToggle: bounds.SyncToggle.Add(origin),
|
||||||
|
Lock: bounds.Lock.Add(origin),
|
||||||
|
MainMenu: bounds.MainMenu.Add(origin),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b headerButtonBounds) logLine(mode string) string {
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"keepassgo header-bounds mode=%s sync=%s sync_toggle=%s lock=%s menu=%s",
|
||||||
|
mode,
|
||||||
|
formatHeaderRect(b.SyncPrimary),
|
||||||
|
formatHeaderRect(b.SyncToggle),
|
||||||
|
formatHeaderRect(b.Lock),
|
||||||
|
formatHeaderRect(b.MainMenu),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatHeaderRect(rect image.Rectangle) string {
|
||||||
|
return fmt.Sprintf("%d,%d-%d,%d", rect.Min.X, rect.Min.Y, rect.Max.X, rect.Max.Y)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) topRightActionOrder() []string {
|
func (u *ui) topRightActionOrder() []string {
|
||||||
@@ -106,21 +197,40 @@ func (u *ui) phoneHeaderMenus(gtx layout.Context) layout.Dimensions {
|
|||||||
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
|
if !u.usesCompactViewport() || (!u.syncMenuVisibleOnPhone() && !u.mainMenuVisibleOnPhone()) {
|
||||||
return layout.Dimensions{}
|
return layout.Dimensions{}
|
||||||
}
|
}
|
||||||
gtx.Constraints.Min = gtx.Constraints.Max
|
|
||||||
contentInsetPx := gtx.Dp(unit.Dp(16))
|
|
||||||
surface := headerlayout.DropdownSurface{
|
|
||||||
ContainerWidth: max(0, gtx.Constraints.Max.X-(contentInsetPx*2)),
|
|
||||||
LeftInset: contentInsetPx,
|
|
||||||
TopInset: contentInsetPx,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
cluster := u.newHeaderActionCluster(gtx)
|
||||||
if u.syncMenuVisibleOnPhone() {
|
if u.syncMenuVisibleOnPhone() {
|
||||||
surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneSyncMenuAnchor.X, TriggerBottomY: u.phoneSyncMenuAnchor.Y}, u.syncMenu)
|
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
stack := op.Offset(image.Pt(0, max(0, u.phoneSyncMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
|
||||||
|
defer stack.Pop()
|
||||||
|
dims := cluster.layoutMenuRow(gtx)
|
||||||
|
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneSyncMenuOrigin.Y))}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if u.mainMenuVisibleOnPhone() {
|
if u.mainMenuVisibleOnPhone() {
|
||||||
surface.Draw(gtx, headerlayout.DropdownAnchor{TriggerRightX: u.phoneMainMenuAnchor.X, TriggerBottomY: u.phoneMainMenuAnchor.Y}, u.mainMenu)
|
return layout.UniformInset(unit.Dp(16)).Layout(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
stack := op.Offset(image.Pt(0, max(0, u.phoneMainMenuOrigin.Y-u.frameInsetPx))).Push(gtx.Ops)
|
||||||
|
defer stack.Pop()
|
||||||
|
dims := cluster.layoutMenuRow(gtx)
|
||||||
|
return layout.Dimensions{Size: image.Pt(gtx.Constraints.Max.X, max(dims.Size.Y, u.phoneMainMenuOrigin.Y))}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) desktopHeaderMenus(gtx layout.Context) layout.Dimensions {
|
||||||
|
if u.usesCompactViewport() || (!u.syncMenuOpen && !u.mainMenuOpen) {
|
||||||
|
return layout.Dimensions{}
|
||||||
|
}
|
||||||
|
cluster := u.newHeaderActionCluster(gtx)
|
||||||
|
dims := cluster.layoutMenuRow(gtx)
|
||||||
|
if dims.Size.Y == 0 {
|
||||||
|
return dims
|
||||||
|
}
|
||||||
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return dims }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuVisibleOnPhone() bool {
|
func (u *ui) syncMenuVisibleOnPhone() bool {
|
||||||
@@ -174,3 +284,52 @@ func (u *ui) brandImage(gtx layout.Context, src paint.ImageOp, widthDP, heightDP
|
|||||||
gtx.Constraints.Max = image.Pt(width, height)
|
gtx.Constraints.Max = image.Pt(width, height)
|
||||||
return img.Layout(gtx)
|
return img.Layout(gtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) mainMenuWidget() layout.Widget {
|
||||||
|
if !u.mainMenuOpen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return u.mainMenu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
||||||
|
rows := []layout.Widget{
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
||||||
|
},
|
||||||
|
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
|
||||||
|
func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return headerview.MainMenu(gtx, u.theme, rows, compactCard, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
|
||||||
|
icon := u.menuIcon
|
||||||
|
if icon == nil {
|
||||||
|
icon = u.settingsIcon
|
||||||
|
}
|
||||||
|
return headerview.MainMenuButtonGroup(gtx, u.theme, &u.toggleMainMenu, icon, u.mainMenuOpen, selectedColor, accentColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
||||||
|
return headerlayout.IntrinsicCompactCard(gtx, w, compactCard, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
||||||
|
return headerlayout.MenuActionWidth(gtx, rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
||||||
|
return headerlayout.RightAlignedAction(gtx, width, child)
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ type DropdownSurface struct {
|
|||||||
TopInset int
|
TopInset int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DropdownPlacement struct {
|
||||||
|
Anchor DropdownAnchor
|
||||||
|
Origin image.Point
|
||||||
|
Size image.Point
|
||||||
|
}
|
||||||
|
|
||||||
func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context {
|
func (s DropdownSurface) MenuConstraints(gtx layout.Context) layout.Context {
|
||||||
menuGTX := gtx
|
menuGTX := gtx
|
||||||
menuGTX.Constraints.Min = image.Point{}
|
menuGTX.Constraints.Min = image.Point{}
|
||||||
@@ -51,25 +57,37 @@ func (s DropdownSurface) Origin(anchor DropdownAnchor, menuWidth int) image.Poin
|
|||||||
return image.Pt(x, y)
|
return image.Pt(x, y)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
|
func (s DropdownSurface) Place(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) (DropdownPlacement, op.CallOp) {
|
||||||
menuGTX := s.MenuConstraints(gtx)
|
menuGTX := s.MenuConstraints(gtx)
|
||||||
menuOps := op.Record(gtx.Ops)
|
menuOps := op.Record(gtx.Ops)
|
||||||
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
|
menuDims := layout.Inset{Top: unit.Dp(6)}.Layout(menuGTX, menu)
|
||||||
menuCall := menuOps.Stop()
|
menuCall := menuOps.Stop()
|
||||||
menuOrigin := s.Origin(anchor, menuDims.Size.X)
|
menuOrigin := s.Origin(anchor, menuDims.Size.X)
|
||||||
stack := op.Offset(menuOrigin).Push(gtx.Ops)
|
return DropdownPlacement{
|
||||||
|
Anchor: anchor,
|
||||||
|
Origin: menuOrigin,
|
||||||
|
Size: menuDims.Size,
|
||||||
|
}, menuCall
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s DropdownSurface) Draw(gtx layout.Context, anchor DropdownAnchor, menu layout.Widget) layout.Dimensions {
|
||||||
|
placement, menuCall := s.Place(gtx, anchor, menu)
|
||||||
|
stack := op.Offset(placement.Origin).Push(gtx.Ops)
|
||||||
menuCall.Add(gtx.Ops)
|
menuCall.Add(gtx.Ops)
|
||||||
stack.Pop()
|
stack.Pop()
|
||||||
return layout.Dimensions{Size: gtx.Constraints.Max}
|
return layout.Dimensions{Size: gtx.Constraints.Max}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ActionMetrics struct {
|
type ActionMetrics struct {
|
||||||
RowOriginX int
|
RowOriginX int
|
||||||
Spacing int
|
Spacing int
|
||||||
RowDims layout.Dimensions
|
SyncInnerSpacing int
|
||||||
SyncDims layout.Dimensions
|
RowDims layout.Dimensions
|
||||||
LockDims layout.Dimensions
|
SyncDims layout.Dimensions
|
||||||
MainDims layout.Dimensions
|
SyncPrimaryDims layout.Dimensions
|
||||||
|
SyncToggleDims layout.Dimensions
|
||||||
|
LockDims layout.Dimensions
|
||||||
|
MainDims layout.Dimensions
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m ActionMetrics) SyncAnchor() DropdownAnchor {
|
func (m ActionMetrics) SyncAnchor() DropdownAnchor {
|
||||||
@@ -86,3 +104,28 @@ func (m ActionMetrics) MainAnchor() DropdownAnchor {
|
|||||||
TriggerBottomY: m.RowDims.Size.Y,
|
TriggerBottomY: m.RowDims.Size.Y,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ActionBounds struct {
|
||||||
|
SyncPrimary image.Rectangle
|
||||||
|
SyncToggle image.Rectangle
|
||||||
|
Lock image.Rectangle
|
||||||
|
MainMenu image.Rectangle
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m ActionMetrics) Bounds() ActionBounds {
|
||||||
|
top := 0
|
||||||
|
syncLeft := m.RowOriginX
|
||||||
|
syncPrimary := image.Rect(syncLeft, top, syncLeft+m.SyncPrimaryDims.Size.X, top+m.SyncPrimaryDims.Size.Y)
|
||||||
|
syncToggleLeft := syncPrimary.Max.X + m.SyncInnerSpacing
|
||||||
|
syncToggle := image.Rect(syncToggleLeft, top, syncToggleLeft+m.SyncToggleDims.Size.X, top+m.SyncToggleDims.Size.Y)
|
||||||
|
lockLeft := syncLeft + m.SyncDims.Size.X + m.Spacing
|
||||||
|
lock := image.Rect(lockLeft, top, lockLeft+m.LockDims.Size.X, top+m.LockDims.Size.Y)
|
||||||
|
mainLeft := lock.Max.X + m.Spacing
|
||||||
|
mainMenu := image.Rect(mainLeft, top, mainLeft+m.MainDims.Size.X, top+m.MainDims.Size.Y)
|
||||||
|
return ActionBounds{
|
||||||
|
SyncPrimary: syncPrimary,
|
||||||
|
SyncToggle: syncToggle,
|
||||||
|
Lock: lock,
|
||||||
|
MainMenu: mainMenu,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package appui
|
package layout
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image"
|
"image"
|
||||||
@@ -8,13 +8,16 @@ import (
|
|||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions {
|
func IntrinsicCompactCard(gtx layout.Context, w layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
|
||||||
measureGTX := gtx
|
measureGTX := gtx
|
||||||
measureGTX.Constraints.Min = image.Point{}
|
measureGTX.Constraints.Min = image.Point{}
|
||||||
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
|
measureGTX.Constraints.Max.X = gtx.Constraints.Max.X
|
||||||
macro := op.Record(gtx.Ops)
|
macro := op.Record(gtx.Ops)
|
||||||
contentDims := w(measureGTX)
|
contentDims := w(measureGTX)
|
||||||
_ = macro.Stop()
|
_ = macro.Stop()
|
||||||
|
if logger != nil {
|
||||||
|
logger("intrinsic-measure", measureGTX.Constraints, contentDims)
|
||||||
|
}
|
||||||
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
|
width := contentDims.Size.X + gtx.Dp(unit.Dp(20))
|
||||||
maxWidth := gtx.Constraints.Max.X
|
maxWidth := gtx.Constraints.Max.X
|
||||||
if maxWidth > 0 && width > maxWidth {
|
if maxWidth > 0 && width > maxWidth {
|
||||||
@@ -24,10 +27,14 @@ func intrinsicCompactCard(gtx layout.Context, w layout.Widget) layout.Dimensions
|
|||||||
gtx.Constraints.Min.X = width
|
gtx.Constraints.Min.X = width
|
||||||
gtx.Constraints.Max.X = width
|
gtx.Constraints.Max.X = width
|
||||||
}
|
}
|
||||||
return compactCard(gtx, w)
|
dims := card(gtx, w)
|
||||||
|
if logger != nil {
|
||||||
|
logger("intrinsic-card", gtx.Constraints, dims)
|
||||||
|
}
|
||||||
|
return dims
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
func MenuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
||||||
width := 0
|
width := 0
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
measureGTX := gtx
|
measureGTX := gtx
|
||||||
@@ -42,7 +49,7 @@ func menuActionWidth(gtx layout.Context, rows []layout.Widget) int {
|
|||||||
return width
|
return width
|
||||||
}
|
}
|
||||||
|
|
||||||
func rightAlignedMenuAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
func RightAlignedAction(gtx layout.Context, width int, child layout.Widget) layout.Dimensions {
|
||||||
if width <= 0 {
|
if width <= 0 {
|
||||||
return child(gtx)
|
return child(gtx)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package header
|
||||||
|
|
||||||
|
import (
|
||||||
|
"image/color"
|
||||||
|
"image"
|
||||||
|
|
||||||
|
"gioui.org/layout"
|
||||||
|
"gioui.org/unit"
|
||||||
|
"gioui.org/widget"
|
||||||
|
"gioui.org/widget/material"
|
||||||
|
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MainMenu(gtx layout.Context, theme *material.Theme, rows []layout.Widget, card func(layout.Context, layout.Widget) layout.Dimensions, logger func(name string, constraints layout.Constraints, dims layout.Dimensions)) layout.Dimensions {
|
||||||
|
rowWidth := headerlayout.MenuActionWidth(gtx, rows)
|
||||||
|
if logger != nil {
|
||||||
|
logger("row-width", gtx.Constraints, layout.Dimensions{Size: image.Pt(rowWidth, 0)})
|
||||||
|
}
|
||||||
|
dims := headerlayout.IntrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
|
children := make([]layout.FlexChild, 0, (len(rows)*2)-1)
|
||||||
|
for i, row := range rows {
|
||||||
|
if i > 0 {
|
||||||
|
children = append(children, layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout))
|
||||||
|
}
|
||||||
|
current := row
|
||||||
|
children = append(children, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return headerlayout.RightAlignedAction(gtx, rowWidth, current)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
dims := layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||||
|
if logger != nil {
|
||||||
|
logger("rows", gtx.Constraints, dims)
|
||||||
|
}
|
||||||
|
return dims
|
||||||
|
}, card, logger)
|
||||||
|
if logger != nil {
|
||||||
|
logger("card", gtx.Constraints, dims)
|
||||||
|
}
|
||||||
|
return dims
|
||||||
|
}
|
||||||
|
|
||||||
|
func MainMenuButtonGroup(gtx layout.Context, theme *material.Theme, click *widget.Clickable, icon *widget.Icon, open bool, selectedColor, accentColor color.NRGBA) layout.Dimensions {
|
||||||
|
btn := material.IconButton(theme, click, icon, "Menu")
|
||||||
|
if open {
|
||||||
|
btn.Background = accentColor
|
||||||
|
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
||||||
|
} else {
|
||||||
|
btn.Background = selectedColor
|
||||||
|
btn.Color = accentColor
|
||||||
|
}
|
||||||
|
btn.Size = unit.Dp(18)
|
||||||
|
btn.Inset = layout.UniformInset(unit.Dp(8))
|
||||||
|
return btn.Layout(gtx)
|
||||||
|
}
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package appui
|
|
||||||
|
|
||||||
import (
|
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"gioui.org/layout"
|
|
||||||
"gioui.org/unit"
|
|
||||||
"gioui.org/widget/material"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (u *ui) mainMenu(gtx layout.Context) layout.Dimensions {
|
|
||||||
rows := []layout.Widget{
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showEntries, "Entries")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showRecycle, "Recycle Bin")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showAPITokens, "API Tokens")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.showAPIAudit, "API Audit")
|
|
||||||
},
|
|
||||||
func(gtx layout.Context) layout.Dimensions { return tonedButton(gtx, u.theme, &u.showAbout, "About") },
|
|
||||||
func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return tonedButton(gtx, u.theme, &u.openSecuritySettings, "Settings")
|
|
||||||
},
|
|
||||||
}
|
|
||||||
rowWidth := menuActionWidth(gtx, rows)
|
|
||||||
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[0]) }),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[1]) }),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[2]) }),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[3]) }),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[4]) }),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions { return rightAlignedMenuAction(gtx, rowWidth, rows[5]) }),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (u *ui) mainMenuButtonGroup(gtx layout.Context) layout.Dimensions {
|
|
||||||
icon := u.menuIcon
|
|
||||||
if icon == nil {
|
|
||||||
icon = u.settingsIcon
|
|
||||||
}
|
|
||||||
btn := material.IconButton(u.theme, &u.toggleMainMenu, icon, "Menu")
|
|
||||||
if u.mainMenuOpen {
|
|
||||||
btn.Background = accentColor
|
|
||||||
btn.Color = color.NRGBA{R: 255, G: 252, B: 247, A: 255}
|
|
||||||
} else {
|
|
||||||
btn.Background = selectedColor
|
|
||||||
btn.Color = accentColor
|
|
||||||
}
|
|
||||||
btn.Size = unit.Dp(18)
|
|
||||||
btn.Inset = layout.UniformInset(unit.Dp(8))
|
|
||||||
return btn.Layout(gtx)
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
package appui
|
package appui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gioui.org/layout"
|
"gioui.org/layout"
|
||||||
|
"gioui.org/op"
|
||||||
"gioui.org/unit"
|
"gioui.org/unit"
|
||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
@@ -14,20 +16,25 @@ import (
|
|||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (u *ui) syncButtonGroup(gtx layout.Context) layout.Dimensions {
|
func (u *ui) syncButtonGroupWithMetrics(gtx layout.Context) (layout.Dimensions, layout.Dimensions, layout.Dimensions) {
|
||||||
spacing := unit.Dp(4)
|
spacing := unit.Dp(4)
|
||||||
if u.usesCompactViewport() {
|
if u.usesCompactViewport() {
|
||||||
spacing = unit.Dp(3)
|
spacing = unit.Dp(3)
|
||||||
}
|
}
|
||||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
var primaryDims layout.Dimensions
|
||||||
|
var toggleDims layout.Dimensions
|
||||||
|
groupDims := layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport())
|
primaryDims = syncPrimaryButton(gtx, u.theme, &u.synchronizeVault, "Sync", u.usesCompactViewport())
|
||||||
|
return primaryDims
|
||||||
}),
|
}),
|
||||||
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
|
layout.Rigid(layout.Spacer{Width: spacing}.Layout),
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return u.syncMenuToggle(gtx)
|
toggleDims = u.syncMenuToggle(gtx)
|
||||||
|
return toggleDims
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
return groupDims, primaryDims, toggleDims
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
||||||
@@ -48,6 +55,13 @@ func (u *ui) syncMenuToggle(gtx layout.Context) layout.Dimensions {
|
|||||||
return btn.Layout(gtx)
|
return btn.Layout(gtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) syncMenuWidget() layout.Widget {
|
||||||
|
if !u.syncMenuOpen {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return u.syncMenu
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
||||||
model := u.buildSyncMenuModel()
|
model := u.buildSyncMenuModel()
|
||||||
profiles := u.availableRemoteProfiles()
|
profiles := u.availableRemoteProfiles()
|
||||||
@@ -60,9 +74,44 @@ func (u *ui) syncMenu(gtx layout.Context) layout.Dimensions {
|
|||||||
}
|
}
|
||||||
actionRows := u.syncMenuActionRows(model)
|
actionRows := u.syncMenuActionRows(model)
|
||||||
actionWidth := menuActionWidth(gtx, actionRows)
|
actionWidth := menuActionWidth(gtx, actionRows)
|
||||||
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
menu := func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...)
|
return intrinsicCompactCard(gtx, func(gtx layout.Context) layout.Dimensions {
|
||||||
})
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, u.syncMenuRows(model, profiles, credentials, actionWidth)...)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
reserveWidth := u.syncMenuTrailingReserveWidth(gtx)
|
||||||
|
if reserveWidth <= 0 {
|
||||||
|
return menu(gtx)
|
||||||
|
}
|
||||||
|
return layout.Flex{}.Layout(gtx,
|
||||||
|
layout.Rigid(menu),
|
||||||
|
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
|
return layout.Dimensions{Size: image.Pt(reserveWidth, 0)}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) syncMenuTrailingReserveWidth(gtx layout.Context) int {
|
||||||
|
spacing := gtx.Dp(unit.Dp(8))
|
||||||
|
if u.usesCompactViewport() {
|
||||||
|
spacing = gtx.Dp(unit.Dp(8))
|
||||||
|
}
|
||||||
|
|
||||||
|
measureGTX := gtx
|
||||||
|
measureGTX.Constraints.Min = image.Point{}
|
||||||
|
|
||||||
|
lockOps := op.Record(gtx.Ops)
|
||||||
|
lockDims := func(gtx layout.Context) layout.Dimensions {
|
||||||
|
btn := material.Button(u.theme, &u.lockVault, "Lock")
|
||||||
|
return btn.Layout(gtx)
|
||||||
|
}(measureGTX)
|
||||||
|
_ = lockOps.Stop()
|
||||||
|
|
||||||
|
menuOps := op.Record(gtx.Ops)
|
||||||
|
menuDims := u.mainMenuButtonGroup(measureGTX)
|
||||||
|
_ = menuOps.Stop()
|
||||||
|
|
||||||
|
return spacing + lockDims.Size.X + spacing + menuDims.Size.X
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget {
|
func (u *ui) syncMenuActionRows(model syncmodel.MenuModel) []layout.Widget {
|
||||||
@@ -109,14 +158,7 @@ func (u *ui) syncMenuRows(model syncmodel.MenuModel, profiles []vault.RemoteProf
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild {
|
func (u *ui) syncMenuPrimaryRows(model syncmodel.MenuModel, actionWidth int) []layout.FlexChild {
|
||||||
rows := []layout.FlexChild{
|
rows := []layout.FlexChild{}
|
||||||
layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
|
||||||
lbl := material.Label(u.theme, unit.Sp(11), "Need another source or direction?")
|
|
||||||
lbl.Color = mutedColor
|
|
||||||
return lbl.Layout(gtx)
|
|
||||||
}),
|
|
||||||
layout.Rigid(layout.Spacer{Height: unit.Dp(6)}.Layout),
|
|
||||||
}
|
|
||||||
if model.ShowShare {
|
if model.ShowShare {
|
||||||
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
rows = append(rows, layout.Rigid(func(gtx layout.Context) layout.Dimensions {
|
||||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||||
|
|||||||
+214
-1
@@ -754,6 +754,23 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) {
|
|||||||
t.Fatal("apiTokenSecret after rotate = empty, want one-time secret")
|
t.Fatal("apiTokenSecret after rotate = empty, want one-time secret")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
u.apiTokenName.SetText("Browser Extension Updated")
|
||||||
|
u.apiTokenClientName.SetText("firefox-desktop")
|
||||||
|
u.apiTokenExpiresAt.SetText("2026-05-01T00:00:00Z")
|
||||||
|
if err := u.saveAPITokenAction(); err != nil {
|
||||||
|
t.Fatalf("saveAPITokenAction() error = %v", err)
|
||||||
|
}
|
||||||
|
updated, ok := u.selectedAPIToken()
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("selectedAPIToken() ok = false, want true after save")
|
||||||
|
}
|
||||||
|
if updated.Name != "Browser Extension Updated" || updated.ClientName != "firefox-desktop" {
|
||||||
|
t.Fatalf("updated token = %#v, want renamed/firefox-desktop", updated)
|
||||||
|
}
|
||||||
|
if updated.ExpiresAt == nil || updated.ExpiresAt.UTC().Format(time.RFC3339) != "2026-05-01T00:00:00Z" {
|
||||||
|
t.Fatalf("updated.ExpiresAt = %#v, want 2026-05-01T00:00:00Z", updated.ExpiresAt)
|
||||||
|
}
|
||||||
|
|
||||||
if err := u.disableAPITokenAction(); err != nil {
|
if err := u.disableAPITokenAction(); err != nil {
|
||||||
t.Fatalf("disableAPITokenAction() error = %v", err)
|
t.Fatalf("disableAPITokenAction() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -761,9 +778,17 @@ func TestUIAPITokenLifecycleManagement(t *testing.T) {
|
|||||||
if !ok || !disabled.Disabled {
|
if !ok || !disabled.Disabled {
|
||||||
t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled)
|
t.Fatalf("selectedAPIToken() = %#v, want disabled token", disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := u.revokeAPITokenAction(); err != nil {
|
||||||
|
t.Fatalf("revokeAPITokenAction() error = %v", err)
|
||||||
|
}
|
||||||
|
revoked, ok := u.selectedAPIToken()
|
||||||
|
if !ok || revoked.RevokedAt == nil {
|
||||||
|
t.Fatalf("selectedAPIToken() = %#v, want revoked token", revoked)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
|
func TestUIAPITokenPolicyRulesCanBeCreatedUpdatedAndRemoved(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
u := newUIWithSession("desktop", &session.Manager{}, statePaths{
|
||||||
@@ -799,6 +824,33 @@ func TestUIAPITokenPolicyRulesCanBeAddedAndRemoved(t *testing.T) {
|
|||||||
if token.Policies[0].Resource.Kind != apitokens.ResourceGroup {
|
if token.Policies[0].Resource.Kind != apitokens.ResourceGroup {
|
||||||
t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind)
|
t.Fatalf("rule kind = %q, want group", token.Policies[0].Resource.Kind)
|
||||||
}
|
}
|
||||||
|
if len(u.apiPolicyEdits) != 1 {
|
||||||
|
t.Fatalf("len(apiPolicyEdits) = %d, want 1", len(u.apiPolicyEdits))
|
||||||
|
}
|
||||||
|
|
||||||
|
u.apiPolicyEdits[0].Click()
|
||||||
|
u.handleAPIPolicyClicks(layout.Context{})
|
||||||
|
if u.selectedAPIPolicyIndex != 0 {
|
||||||
|
t.Fatalf("selectedAPIPolicyIndex = %d, want 0 after edit click", u.selectedAPIPolicyIndex)
|
||||||
|
}
|
||||||
|
if got := u.apiPolicyPath.Text(); got != "Root / Internet" {
|
||||||
|
t.Fatalf("apiPolicyPath = %q, want Root / Internet after edit load", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
u.apiPolicyPath.SetText("Root / Security")
|
||||||
|
u.saveAPIPolicyRule.Click()
|
||||||
|
u.handleAPIPolicyClicks(layout.Context{})
|
||||||
|
|
||||||
|
token, ok = u.selectedAPIToken()
|
||||||
|
if !ok || len(token.Policies) != 1 {
|
||||||
|
t.Fatalf("selectedAPIToken().Policies after save = %#v, want 1 rule", token.Policies)
|
||||||
|
}
|
||||||
|
if got := strings.Join(token.Policies[0].Resource.Path, " / "); got != "Root / Security" {
|
||||||
|
t.Fatalf("updated policy path = %q, want Root / Security", got)
|
||||||
|
}
|
||||||
|
if u.selectedAPIPolicyIndex != -1 {
|
||||||
|
t.Fatalf("selectedAPIPolicyIndex after save = %d, want -1", u.selectedAPIPolicyIndex)
|
||||||
|
}
|
||||||
|
|
||||||
if err := u.removeAPIPolicyRuleAction(0); err != nil {
|
if err := u.removeAPIPolicyRuleAction(0); err != nil {
|
||||||
t.Fatalf("removeAPIPolicyRuleAction() error = %v", err)
|
t.Fatalf("removeAPIPolicyRuleAction() error = %v", err)
|
||||||
@@ -4858,6 +4910,112 @@ func TestUIResolvePendingApprovalDelegatesToApprovalManager(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIApprovalDialogVisibleForPendingRequest(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.state.Approvals = &mainStubApprovalManager{
|
||||||
|
pending: []apiapproval.Request{{
|
||||||
|
ID: "approval-1",
|
||||||
|
TokenName: "CLI",
|
||||||
|
ClientName: "grpc-cli",
|
||||||
|
Operation: apitokens.OperationReadEntry,
|
||||||
|
Resource: apitokens.Resource{
|
||||||
|
Kind: apitokens.ResourceEntry,
|
||||||
|
EntryID: "vault-console",
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
dims := u.approvalDialogContent(layout.Context{
|
||||||
|
Ops: new(op.Ops),
|
||||||
|
Constraints: layout.Exact(image.Pt(800, 600)),
|
||||||
|
})
|
||||||
|
if dims.Size.X == 0 || dims.Size.Y == 0 {
|
||||||
|
t.Fatalf("approvalDialogContent() = %v, want visible dimensions for pending approval", dims.Size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUIApprovalButtonsResolveAllOutcomes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
click func(*ui)
|
||||||
|
want apiapproval.Outcome
|
||||||
|
wantMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "allow once",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.allowApprovalOnce.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeAllowOnce,
|
||||||
|
wantMsg: "allow API request complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "allow permanently",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.allowApprovalPermanent.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeAllowPermanent,
|
||||||
|
wantMsg: "allow API request permanently complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deny once",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.denyApprovalOnce.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeDenyOnce,
|
||||||
|
wantMsg: "deny API request complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deny permanently",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.denyApprovalPermanent.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeDenyPermanent,
|
||||||
|
wantMsg: "deny API request permanently complete",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancel",
|
||||||
|
click: func(u *ui) {
|
||||||
|
u.cancelApproval.Click()
|
||||||
|
},
|
||||||
|
want: apiapproval.OutcomeCancel,
|
||||||
|
wantMsg: "cancel API request complete",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manager := &mainStubApprovalManager{
|
||||||
|
pending: []apiapproval.Request{{
|
||||||
|
ID: "approval-1",
|
||||||
|
TokenName: "CLI",
|
||||||
|
ClientName: "grpc-cli",
|
||||||
|
Operation: apitokens.OperationReadEntry,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
u := newUIWithModel("desktop", vault.Model{})
|
||||||
|
u.state.Approvals = manager
|
||||||
|
|
||||||
|
tt.click(u)
|
||||||
|
u.handleApprovalClicks(layout.Context{})
|
||||||
|
|
||||||
|
if manager.lastID != "approval-1" || manager.lastOutcome != tt.want {
|
||||||
|
t.Fatalf("handleApprovalClicks() delegated (%q, %q), want (approval-1, %q)", manager.lastID, manager.lastOutcome, tt.want)
|
||||||
|
}
|
||||||
|
if got := u.state.StatusMessage; got != tt.wantMsg {
|
||||||
|
t.Fatalf("state.StatusMessage = %q, want %q", got, tt.wantMsg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
|
func TestUIRequiresExplicitEditModeForEntryEditor(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -5460,6 +5618,28 @@ func TestUISyncDefaultsPersistInSettings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUIDebugHeaderBoundsPersistInSettings(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
configPath := filepath.Join(t.TempDir(), "settings.json")
|
||||||
|
|
||||||
|
first := newUIWithSession("phone", &session.Manager{}, statePaths{
|
||||||
|
SettingsPath: configPath,
|
||||||
|
})
|
||||||
|
first.debugLogHeaderBounds = true
|
||||||
|
first.saveSettings()
|
||||||
|
|
||||||
|
second := newUIWithSession("phone", &session.Manager{}, statePaths{
|
||||||
|
SettingsPath: configPath,
|
||||||
|
})
|
||||||
|
second.debugLogHeaderBounds = false
|
||||||
|
second.loadSettings()
|
||||||
|
|
||||||
|
if !second.debugLogHeaderBounds {
|
||||||
|
t.Fatal("debugLogHeaderBounds = false, want true after reload")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUILoadSettingsFallsBackToLegacySyncDefaultsInUIPreferences(t *testing.T) {
|
func TestUILoadSettingsFallsBackToLegacySyncDefaultsInUIPreferences(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -5552,6 +5732,39 @@ func TestUISaveSecuritySettingsPersistsSyncDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUISaveSecuritySettingsPersistsDebugHeaderBounds(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
manager := &session.Manager{}
|
||||||
|
dir := t.TempDir()
|
||||||
|
u := newUIWithSession("phone", manager, statePaths{
|
||||||
|
DefaultSaveAsPath: filepath.Join(dir, "vault.kdbx"),
|
||||||
|
SettingsPath: filepath.Join(dir, "settings.json"),
|
||||||
|
UIPreferencesPath: filepath.Join(dir, "ui-prefs.json"),
|
||||||
|
})
|
||||||
|
u.masterPassword.SetText("correct horse battery staple")
|
||||||
|
if err := u.createVaultAction(); err != nil {
|
||||||
|
t.Fatalf("createVaultAction() error = %v", err)
|
||||||
|
}
|
||||||
|
u.securityCipher.SetText(vault.CipherAES256)
|
||||||
|
u.securityKDF.SetText(vault.KDFAES)
|
||||||
|
u.loadSettingsDraft()
|
||||||
|
u.settingsDebugHeaderBounds.Value = true
|
||||||
|
|
||||||
|
if err := u.saveSecuritySettingsAction(); err != nil {
|
||||||
|
t.Fatalf("saveSecuritySettingsAction() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloaded := newUIWithSession("phone", &session.Manager{}, statePaths{
|
||||||
|
SettingsPath: u.settingsPath,
|
||||||
|
})
|
||||||
|
reloaded.loadSettings()
|
||||||
|
|
||||||
|
if !reloaded.debugLogHeaderBounds {
|
||||||
|
t.Fatal("reloaded debugLogHeaderBounds = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUIAccessibilityPreferencesPersist(t *testing.T) {
|
func TestUIAccessibilityPreferencesPersist(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
//go:build android
|
||||||
|
|
||||||
|
package platform
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo CFLAGS: -Werror
|
||||||
|
#cgo LDFLAGS: -landroid
|
||||||
|
|
||||||
|
#include <android/log.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
import "unsafe"
|
||||||
|
|
||||||
|
func LogInfo(tag, msg string) {
|
||||||
|
ctag := C.CString(tag)
|
||||||
|
defer C.free(unsafe.Pointer(ctag))
|
||||||
|
cmsg := C.CString(msg)
|
||||||
|
defer C.free(unsafe.Pointer(cmsg))
|
||||||
|
C.__android_log_write(C.ANDROID_LOG_INFO, ctag, cmsg)
|
||||||
|
}
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
package platform
|
package platform
|
||||||
|
|
||||||
|
import "log"
|
||||||
|
|
||||||
func NewVaultSharer(goos string) VaultSharer {
|
func NewVaultSharer(goos string) VaultSharer {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LogInfo(tag, msg string) {
|
||||||
|
log.Printf("%s: %s", tag, msg)
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func run(w *app.Window, mode string, paths statePaths, grpcAddr string) error {
|
|||||||
} else if host != nil {
|
} else if host != nil {
|
||||||
ui.apiHost = host
|
ui.apiHost = host
|
||||||
ui.auditLog = host.Server().AuditLog()
|
ui.auditLog = host.Server().AuditLog()
|
||||||
|
ui.state.AuditLog = ui.auditLog
|
||||||
ui.grpcAddress = host.Address()
|
ui.grpcAddress = host.Address()
|
||||||
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
ui.state.Approvals = &uiApprovalManager{server: host.Server()}
|
||||||
defer func() { _ = host.Stop() }()
|
defer func() { _ = host.Stop() }()
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import (
|
|||||||
"gioui.org/widget"
|
"gioui.org/widget"
|
||||||
"gioui.org/widget/material"
|
"gioui.org/widget/material"
|
||||||
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
editormodel "git.julianfamily.org/keepassgo/internal/appui/editor"
|
||||||
|
headerlayout "git.julianfamily.org/keepassgo/internal/appui/header/layout"
|
||||||
|
"git.julianfamily.org/keepassgo/internal/appui/platform"
|
||||||
settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings"
|
settingsmodel "git.julianfamily.org/keepassgo/internal/appui/settings"
|
||||||
"git.julianfamily.org/keepassgo/internal/vault"
|
"git.julianfamily.org/keepassgo/internal/vault"
|
||||||
)
|
)
|
||||||
@@ -35,7 +37,8 @@ const (
|
|||||||
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
|
type accessibilityPreferences = settingsmodel.AccessibilityPreferences
|
||||||
|
|
||||||
type settingsFile struct {
|
type settingsFile struct {
|
||||||
Sync syncSettings `json:"sync,omitempty"`
|
Sync syncSettings `json:"sync,omitempty"`
|
||||||
|
Debug debugSettings `json:"debug,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type syncSettings struct {
|
type syncSettings struct {
|
||||||
@@ -43,6 +46,10 @@ type syncSettings struct {
|
|||||||
DirectionDefault string `json:"directionDefault,omitempty"`
|
DirectionDefault string `json:"directionDefault,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type debugSettings struct {
|
||||||
|
LogHeaderBounds bool `json:"logHeaderBounds,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type syncSettingsDraft struct {
|
type syncSettingsDraft struct {
|
||||||
SourceDefault syncSourceMode
|
SourceDefault syncSourceMode
|
||||||
DirectionDefault syncDirection
|
DirectionDefault syncDirection
|
||||||
@@ -51,6 +58,7 @@ type syncSettingsDraft struct {
|
|||||||
type settingsDraft struct {
|
type settingsDraft struct {
|
||||||
Accessibility accessibilityPreferences
|
Accessibility accessibilityPreferences
|
||||||
Sync syncSettingsDraft
|
Sync syncSettingsDraft
|
||||||
|
Debug debugSettings
|
||||||
}
|
}
|
||||||
|
|
||||||
type legacySyncPreferences struct {
|
type legacySyncPreferences struct {
|
||||||
@@ -191,7 +199,11 @@ func (u *ui) loadSettingsDraft() {
|
|||||||
SourceDefault: u.syncDefaultSourceMode,
|
SourceDefault: u.syncDefaultSourceMode,
|
||||||
DirectionDefault: u.syncDefaultDirection,
|
DirectionDefault: u.syncDefaultDirection,
|
||||||
},
|
},
|
||||||
|
Debug: debugSettings{
|
||||||
|
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
u.settingsDebugHeaderBounds.Value = u.settingsDraft.Debug.LogHeaderBounds
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *ui) saveSecuritySettingsAction() error {
|
func (u *ui) saveSecuritySettingsAction() error {
|
||||||
@@ -213,9 +225,14 @@ func (u *ui) applySecuritySettingsLive() error {
|
|||||||
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
|
if u.settingsDraft.Accessibility.DisplayDensity == displayDensityForDenseLayout(u.denseLayout) {
|
||||||
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
u.settingsDraft.Accessibility.DisplayDensity = displayDensityForDenseLayout(u.settingsDenseLayout.Value)
|
||||||
}
|
}
|
||||||
|
u.settingsDraft.Debug.LogHeaderBounds = u.settingsDebugHeaderBounds.Value
|
||||||
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
u.settingsDenseLayout.Value = u.settingsDraft.Accessibility.DisplayDensity == displayDensityDense
|
||||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
u.syncDefaultSourceMode = sanitizeSyncSourceMode(u.settingsDraft.Sync.SourceDefault)
|
||||||
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
u.syncDefaultDirection = sanitizeSyncDirection(u.settingsDraft.Sync.DirectionDefault)
|
||||||
|
u.debugLogHeaderBounds = u.settingsDraft.Debug.LogHeaderBounds
|
||||||
|
if !u.debugLogHeaderBounds {
|
||||||
|
u.lastHeaderBoundsLog = ""
|
||||||
|
}
|
||||||
u.applySettingsFormToPreferences()
|
u.applySettingsFormToPreferences()
|
||||||
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
|
u.applyAccessibilityPreferences(u.settingsDraft.Accessibility)
|
||||||
u.saveSettings()
|
u.saveSettings()
|
||||||
@@ -234,6 +251,7 @@ func (u *ui) loadSettings() {
|
|||||||
if json.Unmarshal(content, &settings) == nil {
|
if json.Unmarshal(content, &settings) == nil {
|
||||||
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
u.syncDefaultSourceMode = sanitizeSyncSourceMode(syncSourceMode(settings.Sync.SourceDefault))
|
||||||
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
u.syncDefaultDirection = sanitizeSyncDirection(syncDirection(settings.Sync.DirectionDefault))
|
||||||
|
u.debugLogHeaderBounds = settings.Debug.LogHeaderBounds
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,6 +288,9 @@ func (u *ui) saveSettings() {
|
|||||||
SourceDefault: string(u.syncDefaultSourceMode),
|
SourceDefault: string(u.syncDefaultSourceMode),
|
||||||
DirectionDefault: string(u.syncDefaultDirection),
|
DirectionDefault: string(u.syncDefaultDirection),
|
||||||
},
|
},
|
||||||
|
Debug: debugSettings{
|
||||||
|
LogHeaderBounds: u.debugLogHeaderBounds,
|
||||||
|
},
|
||||||
}, "", " ")
|
}, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -277,6 +298,44 @@ func (u *ui) saveSettings() {
|
|||||||
_ = os.WriteFile(u.settingsPath, content, 0o600)
|
_ = os.WriteFile(u.settingsPath, content, 0o600)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (u *ui) maybeLogHeaderBounds(bounds headerButtonBounds) {
|
||||||
|
if !u.debugLogHeaderBounds {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
line := bounds.logLine(u.mode)
|
||||||
|
if line == u.lastHeaderBoundsLog {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platform.LogInfo("KeePassGO", line)
|
||||||
|
u.lastHeaderBoundsLog = line
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) maybeLogHeaderMenuToggle(menu string, open bool) {
|
||||||
|
if !u.debugLogHeaderBounds {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platform.LogInfo("KeePassGO", fmt.Sprintf("keepassgo header-menu-toggle menu=%s open=%t", menu, open))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *ui) maybeLogHeaderMenuPlacement(menu string, surface headerlayout.DropdownSurface, placement headerlayout.DropdownPlacement) {
|
||||||
|
if !u.debugLogHeaderBounds {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
platform.LogInfo("KeePassGO", fmt.Sprintf(
|
||||||
|
"keepassgo header-menu-placement menu=%s anchor=%d,%d origin=%d,%d size=%dx%d container=%d inset=%d,%d",
|
||||||
|
menu,
|
||||||
|
placement.Anchor.TriggerRightX,
|
||||||
|
placement.Anchor.TriggerBottomY,
|
||||||
|
placement.Origin.X,
|
||||||
|
placement.Origin.Y,
|
||||||
|
placement.Size.X,
|
||||||
|
placement.Size.Y,
|
||||||
|
surface.ContainerWidth,
|
||||||
|
surface.LeftInset,
|
||||||
|
surface.TopInset,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
func (u *ui) showStatusMessage(message string) {
|
func (u *ui) showStatusMessage(message string) {
|
||||||
u.state.StatusMessage = message
|
u.state.StatusMessage = message
|
||||||
if u.accessibilityPrefs.ReducedMotion {
|
if u.accessibilityPrefs.ReducedMotion {
|
||||||
|
|||||||
Reference in New Issue
Block a user