Skip to content

Commit 4b4bc90

Browse files
seletzclaude
andauthored
fix: clock in/out for non-admin users with 2FA #29 (#34)
## Summary - Switch clock in/out from direct XML-RPC `create`/`write` on `hr.attendance` (admin-only) to JSON-RPC session + `/hr_attendance/systray_check_in_out` controller endpoint, which uses `sudo()` internally - Add TOTP 2FA support: when Odoo's `/web/session/authenticate` returns `uid=false` (2FA pending), auto-generate a TOTP code from the configured secret and complete verification via `/web/login/totp` - Add dual credential support in `[op_secrets]`: `api-key` for XML-RPC reads, `password` for web session auth, `totp_secret` for 2FA - Update README: features, usage, secrets documentation Closes #29 ## New files - `internal/odoo/jsonrpc.go` — JSON-RPC session layer with cookie jar, TOTP flow, CSRF handling - `internal/odoo/jsonrpc_test.go` — tests with httptest (auth, 2FA, TOTP verification, error cases) ## Modified files - `internal/config/config.go` — `TOTPSecret` field, env var, merge - `internal/config/op.go` — `TOTPSecret` in OPSecrets, field mapping - `internal/config/op_test.go` — updated tests for new fields - `internal/odoo/xmlrpc.go` — pass TOTP secret to JSON-RPC session - `internal/odoo/attendance.go` — ClockIn/ClockOut use JSON-RPC toggle - `cmd/odoo-work-cli/main.go` — wire TOTPSecret to client constructor - `internal/config/default_config.toml` — document new op_secrets fields - `readme.md` — features, usage, secrets documentation ## Test plan - [x] `mise run test` — all tests pass - [x] `mise run lint` — 0 issues - [x] Manual: `clock status` (XML-RPC read, works for all users) - [x] Manual: `clock out` with 2FA-enabled account - [x] Manual: `clock in` with 2FA-enabled account - [x] Manual: `clock status` shows updated periods after in/out cycle 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f7f145f commit 4b4bc90

13 files changed

Lines changed: 748 additions & 133 deletions

File tree

cmd/odoo-work-cli/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ func loadConfig() (*config.Config, error) {
9191

9292
// newClient creates a new Odoo client from the merged config.
9393
func newClient(cfg *config.Config) (*odoo.XMLRPCClient, error) {
94-
return odoo.NewXMLRPCClient(cfg.URL, cfg.Database, cfg.Username, cfg.Password, cfg.Models)
94+
return odoo.NewXMLRPCClient(cfg.URL, cfg.Database, cfg.Username, cfg.Password, cfg.WebPassword, cfg.TOTPSecret, cfg.Models)
9595
}
9696

9797
var projectsCmd = &cobra.Command{

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ require (
1414

1515
require (
1616
github.com/atotto/clipboard v0.1.4 // indirect
17+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
1718
github.com/charmbracelet/colorprofile v0.4.2 // indirect
1819
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
1920
github.com/charmbracelet/x/ansi v0.11.6 // indirect
@@ -27,6 +28,7 @@ require (
2728
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
2829
github.com/mattn/go-runewidth v0.0.20 // indirect
2930
github.com/muesli/cancelreader v0.2.2 // indirect
31+
github.com/pquerna/otp v1.5.0 // indirect
3032
github.com/rivo/uniseg v0.4.7 // indirect
3133
github.com/spf13/pflag v1.0.9 // indirect
3234
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect

go.sum

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
1010
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
1111
github.com/aymanbagabas/go-udiff v0.4.0 h1:TKnLPh7IbnizJIBKFWa9mKayRUBQ9Kh1BPCk6w2PnYM=
1212
github.com/aymanbagabas/go-udiff v0.4.0/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
13+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
14+
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
1315
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
1416
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
1517
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
@@ -47,6 +49,8 @@ github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhg
4749
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
4850
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
4951
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
52+
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
53+
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
5054
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
5155
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
5256
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -60,6 +64,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
6064
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
6165
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
6266
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
67+
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
6368
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
6469
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
6570
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=

internal/config/config.go

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ type Config struct {
9696
Database string `toml:"database"`
9797
Username string `toml:"username"`
9898
Password string `toml:"-"`
99+
WebPassword string `toml:"-"`
100+
TOTPSecret string `toml:"-"`
99101
OPSecrets *OPSecrets `toml:"op_secrets"`
100102
Models map[string]ModelConfig `toml:"models"`
101103
Hours HoursLimits `toml:"hours"`
@@ -104,22 +106,25 @@ type Config struct {
104106
CompanyColors map[string]string `toml:"company_colors"` // company name → lipgloss color string
105107
}
106108

107-
func (c *Config) OdooURL() string { return c.URL }
108-
func (c *Config) OdooDatabase() string { return c.Database }
109-
func (c *Config) OdooUsername() string { return c.Username }
110-
func (c *Config) OdooPassword() string { return c.Password }
109+
func (c *Config) OdooURL() string { return c.URL }
110+
func (c *Config) OdooDatabase() string { return c.Database }
111+
func (c *Config) OdooUsername() string { return c.Username }
112+
func (c *Config) OdooPassword() string { return c.Password }
113+
func (c *Config) OdooWebPassword() string { return c.WebPassword }
111114

112115
// LoadFromEnv reads configuration from environment variables.
113116
// It reads whatever env vars are set without requiring any.
114117
// Use Validate to check that all required fields are present.
115118
func LoadFromEnv() *Config {
116119
return &Config{
117-
URL: os.Getenv("ODOO_URL"),
118-
Database: os.Getenv("ODOO_DATABASE"),
119-
Username: os.Getenv("ODOO_USERNAME"),
120-
Password: os.Getenv("ODOO_PASSWORD"),
121-
Hours: DefaultHoursLimits(),
122-
Bundesland: DefaultBundesland,
120+
URL: os.Getenv("ODOO_URL"),
121+
Database: os.Getenv("ODOO_DATABASE"),
122+
Username: os.Getenv("ODOO_USERNAME"),
123+
Password: os.Getenv("ODOO_PASSWORD"),
124+
WebPassword: os.Getenv("ODOO_WEB_PASSWORD"),
125+
TOTPSecret: os.Getenv("ODOO_TOTP_SECRET"),
126+
Hours: DefaultHoursLimits(),
127+
Bundesland: DefaultBundesland,
123128
}
124129
}
125130

@@ -197,6 +202,12 @@ func (c *Config) Merge(other *Config) {
197202
if other.Password != "" {
198203
c.Password = other.Password
199204
}
205+
if other.WebPassword != "" {
206+
c.WebPassword = other.WebPassword
207+
}
208+
if other.TOTPSecret != "" {
209+
c.TOTPSecret = other.TOTPSecret
210+
}
200211
if other.Bundesland != "" {
201212
c.Bundesland = other.Bundesland
202213
}
@@ -213,9 +224,15 @@ func (c *Config) Merge(other *Config) {
213224
if other.OPSecrets.Username != "" {
214225
c.OPSecrets.Username = other.OPSecrets.Username
215226
}
227+
if other.OPSecrets.APIKey != "" {
228+
c.OPSecrets.APIKey = other.OPSecrets.APIKey
229+
}
216230
if other.OPSecrets.Password != "" {
217231
c.OPSecrets.Password = other.OPSecrets.Password
218232
}
233+
if other.OPSecrets.TOTPSecret != "" {
234+
c.OPSecrets.TOTPSecret = other.OPSecrets.TOTPSecret
235+
}
219236
}
220237
if other.Hours.DailyLow != 0 {
221238
c.Hours.DailyLow = other.Hours.DailyLow

internal/config/default_config.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ username = "user@example.com"
1919
# url = "op://vault/item/url"
2020
# database = "op://vault/item/database"
2121
# username = "op://vault/item/username"
22-
# password = "op://vault/item/api-key"
22+
# api-key = "op://vault/item/api-key" # Odoo API key (for XML-RPC)
23+
# password = "op://vault/item/password" # Odoo login password (for clock in/out)
2324

2425
# German federal state for public holiday detection.
2526
# Valid values: Baden-Württemberg, Bayern, Berlin, Brandenburg, Bremen,

internal/config/op.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ import (
1010
// OPSecrets holds 1Password vault references (op:// URIs) for config fields.
1111
// When present in the config file, these are resolved at runtime via the op CLI.
1212
type OPSecrets struct {
13-
URL string `toml:"url"`
14-
Database string `toml:"database"`
15-
Username string `toml:"username"`
16-
Password string `toml:"password"`
13+
URL string `toml:"url"`
14+
Database string `toml:"database"`
15+
Username string `toml:"username"`
16+
APIKey string `toml:"api-key"`
17+
Password string `toml:"password"`
18+
TOTPSecret string `toml:"totp_secret"`
1719
}
1820

1921
// opInjectRunner abstracts the op inject call for testability.
@@ -56,7 +58,9 @@ func resolveOPSecrets(cfg *Config, runner opInjectRunner) error {
5658
{"url", cfg.OPSecrets.URL, &cfg.URL},
5759
{"database", cfg.OPSecrets.Database, &cfg.Database},
5860
{"username", cfg.OPSecrets.Username, &cfg.Username},
59-
{"password", cfg.OPSecrets.Password, &cfg.Password},
61+
{"api-key", cfg.OPSecrets.APIKey, &cfg.Password},
62+
{"password", cfg.OPSecrets.Password, &cfg.WebPassword},
63+
{"totp_secret", cfg.OPSecrets.TOTPSecret, &cfg.TOTPSecret},
6064
}
6165

6266
// Apply plain values directly; collect op:// refs for batch resolve.

0 commit comments

Comments
 (0)