feat: add CLI commands for user management
Adds subcommands to the binary for admin tasks without needing the web UI: list users, set passwords, promote/demote roles, and lock/unlock accounts. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
04c6dd3df6
commit
a456d0096a
5 changed files with 275 additions and 12 deletions
15
CLAUDE.md
15
CLAUDE.md
|
|
@ -14,13 +14,24 @@ make test # Run tests
|
||||||
make container # Build container image
|
make container # Build container image
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### CLI Admin Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
favoritter user list # List all users
|
||||||
|
favoritter user set-password <user> # Change a user's password
|
||||||
|
favoritter user promote <user> # Promote user to admin
|
||||||
|
favoritter user demote <user> # Remove admin role
|
||||||
|
favoritter user lock <user> # Disable user account
|
||||||
|
favoritter user unlock <user> # Enable user account
|
||||||
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
Single Go binary serving HTML (server-rendered templates + HTMX) and a JSON API. SQLite for storage, filesystem for uploaded images. All templates and static assets are embedded via `go:embed`.
|
Single Go binary serving HTML (server-rendered templates + HTMX) and a JSON API. SQLite for storage, filesystem for uploaded images. All templates and static assets are embedded via `go:embed`.
|
||||||
|
|
||||||
### Directory Layout
|
### Directory Layout
|
||||||
|
|
||||||
- `cmd/favoritter/` — Entry point, wiring, graceful shutdown
|
- `cmd/favoritter/` — Entry point, wiring, graceful shutdown, CLI admin commands (`cli_*.go`)
|
||||||
- `internal/config/` — Environment variable configuration
|
- `internal/config/` — Environment variable configuration
|
||||||
- `internal/database/` — SQLite connection, PRAGMAs, migration runner
|
- `internal/database/` — SQLite connection, PRAGMAs, migration runner
|
||||||
- `internal/model/` — Domain types (no logic, no DB)
|
- `internal/model/` — Domain types (no logic, no DB)
|
||||||
|
|
@ -37,7 +48,7 @@ Single Go binary serving HTML (server-rendered templates + HTMX) and a JSON API.
|
||||||
### Key Design Decisions
|
### Key Design Decisions
|
||||||
|
|
||||||
- **Go 1.22+ stdlib router** — no framework, `http.ServeMux` with method routing
|
- **Go 1.22+ stdlib router** — no framework, `http.ServeMux` with method routing
|
||||||
- **3 external dependencies** — modernc.org/sqlite (pure Go), golang.org/x/crypto (Argon2id), gorilla/feeds
|
- **Minimal dependencies** — modernc.org/sqlite (pure Go), golang.org/x/crypto (Argon2id), gorilla/feeds, google/uuid, golang.org/x/term
|
||||||
- **`SetMaxOpenConns(1)`** — SQLite works best with a single writer; PRAGMAs are set once on the single connection
|
- **`SetMaxOpenConns(1)`** — SQLite works best with a single writer; PRAGMAs are set once on the single connection
|
||||||
- **Templates embedded in binary** — `//go:embed` for single-binary deployment; dev mode reads from disk for live reload
|
- **Templates embedded in binary** — `//go:embed` for single-binary deployment; dev mode reads from disk for live reload
|
||||||
- **Middleware chain order matters** — Recovery → SecurityHeaders → BasePath → RealIP → Logger → SessionLoader → CSRF → MustResetGuard
|
- **Middleware chain order matters** — Recovery → SecurityHeaders → BasePath → RealIP → Logger → SessionLoader → CSRF → MustResetGuard
|
||||||
|
|
|
||||||
246
cmd/favoritter/cli_user.go
Normal file
246
cmd/favoritter/cli_user.go
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"golang.org/x/term"
|
||||||
|
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/config"
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/database"
|
||||||
|
"kode.naiv.no/olemd/favoritter/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runUserCommand dispatches user management subcommands.
|
||||||
|
func runUserCommand(args []string) {
|
||||||
|
if len(args) == 0 {
|
||||||
|
printUserUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
runUserList()
|
||||||
|
case "set-password":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Bruk: favoritter user set-password <brukernavn>\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
runUserSetPassword(args[1])
|
||||||
|
case "promote":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Bruk: favoritter user promote <brukernavn>\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
runUserSetRole(args[1], "admin")
|
||||||
|
case "demote":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Bruk: favoritter user demote <brukernavn>\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
runUserSetRole(args[1], "user")
|
||||||
|
case "lock":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Bruk: favoritter user lock <brukernavn>\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
runUserSetDisabled(args[1], true)
|
||||||
|
case "unlock":
|
||||||
|
if len(args) < 2 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Bruk: favoritter user unlock <brukernavn>\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
runUserSetDisabled(args[1], false)
|
||||||
|
default:
|
||||||
|
fmt.Fprintf(os.Stderr, "Ukjent underkommando: %s\n\n", args[0])
|
||||||
|
printUserUsage()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printUserUsage prints help text for the user subcommand.
|
||||||
|
func printUserUsage() {
|
||||||
|
fmt.Fprintf(os.Stderr, "Bruk: favoritter user <kommando>\n\n")
|
||||||
|
fmt.Fprintf(os.Stderr, "Kommandoer:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " list Vis alle brukere\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " set-password <bruker> Endre passord for en bruker\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " promote <bruker> Gjør bruker til administrator\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " demote <bruker> Fjern administratorrettigheter\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " lock <bruker> Deaktiver brukerkonto\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " unlock <bruker> Aktiver brukerkonto\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// openDB loads config, opens the database, and runs migrations.
|
||||||
|
// It exits the process on failure.
|
||||||
|
func openDB() (*store.UserStore, func()) {
|
||||||
|
cfg := config.Load()
|
||||||
|
db, err := database.Open(cfg.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: kunne ikke åpne databasen: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := database.Migrate(db); err != nil {
|
||||||
|
db.Close()
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: migrering feilet: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure Argon2 parameters from config.
|
||||||
|
store.Argon2Memory = cfg.Argon2Memory
|
||||||
|
store.Argon2Time = cfg.Argon2Time
|
||||||
|
store.Argon2Parallelism = cfg.Argon2Parallelism
|
||||||
|
|
||||||
|
users := store.NewUserStore(db)
|
||||||
|
return users, func() { db.Close() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// runUserList prints all users in a table.
|
||||||
|
func runUserList() {
|
||||||
|
users, cleanup := openDB()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
all, err := users.ListAll()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: kunne ikke hente brukere: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(all) == 0 {
|
||||||
|
fmt.Println("Ingen brukere funnet.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(w, "ID\tBRUKERNAVN\tVISNINGSNAVN\tROLLE\tSTATUS\tOPPRETTET")
|
||||||
|
for _, u := range all {
|
||||||
|
status := "aktiv"
|
||||||
|
if u.Disabled {
|
||||||
|
status = "deaktivert"
|
||||||
|
}
|
||||||
|
if u.MustResetPassword {
|
||||||
|
status += ", må bytte passord"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%d\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
|
u.ID,
|
||||||
|
u.Username,
|
||||||
|
u.DisplayName,
|
||||||
|
u.Role,
|
||||||
|
status,
|
||||||
|
u.CreatedAt.Format("2006-01-02"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runUserSetPassword prompts for a new password and updates it.
|
||||||
|
func runUserSetPassword(username string) {
|
||||||
|
users, cleanup := openDB()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
user, err := users.GetByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: bruker %q ikke funnet\n", username)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read new password from terminal.
|
||||||
|
fmt.Fprintf(os.Stderr, "Nytt passord for %s: ", username)
|
||||||
|
pw1, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: kunne ikke lese passord: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "Gjenta passord: ")
|
||||||
|
pw2, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||||
|
fmt.Fprintln(os.Stderr)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: kunne ikke lese passord: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
password := strings.TrimSpace(string(pw1))
|
||||||
|
if password != strings.TrimSpace(string(pw2)) {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: passordene stemmer ikke overens\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(password) < 8 {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: passordet må være minst 8 tegn\n")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := users.UpdatePassword(user.ID, password); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: kunne ikke oppdatere passord: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Passord oppdatert for %s.\n", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runUserSetRole promotes or demotes a user.
|
||||||
|
func runUserSetRole(username, role string) {
|
||||||
|
users, cleanup := openDB()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
user, err := users.GetByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: bruker %q ikke funnet\n", username)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Role == role {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s har allerede rollen %q\n", username, role)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := users.SetRole(user.ID, role); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: kunne ikke endre rolle: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch role {
|
||||||
|
case "admin":
|
||||||
|
fmt.Printf("%s er nå administrator.\n", username)
|
||||||
|
default:
|
||||||
|
fmt.Printf("%s er ikke lenger administrator.\n", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runUserSetDisabled locks or unlocks a user account.
|
||||||
|
func runUserSetDisabled(username string, disabled bool) {
|
||||||
|
users, cleanup := openDB()
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
user, err := users.GetByUsername(username)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: bruker %q ikke funnet\n", username)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Disabled == disabled {
|
||||||
|
if disabled {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s er allerede deaktivert\n", username)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s er allerede aktiv\n", username)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := users.SetDisabled(user.ID, disabled); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "Feil: kunne ikke endre kontostatus: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if disabled {
|
||||||
|
fmt.Printf("%s er nå deaktivert.\n", username)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("%s er nå aktivert.\n", username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -28,16 +28,19 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Handle -healthcheck flag for container health checks.
|
// Handle flags and subcommands before starting the server.
|
||||||
if len(os.Args) > 1 && os.Args[1] == "-healthcheck" {
|
if len(os.Args) > 1 {
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "-healthcheck":
|
||||||
runHealthCheck()
|
runHealthCheck()
|
||||||
return
|
return
|
||||||
}
|
case "-version":
|
||||||
|
|
||||||
// Handle -version flag.
|
|
||||||
if len(os.Args) > 1 && os.Args[1] == "-version" {
|
|
||||||
fmt.Printf("favoritter %s (built %s)\n", version, buildDate)
|
fmt.Printf("favoritter %s (built %s)\n", version, buildDate)
|
||||||
return
|
return
|
||||||
|
case "user":
|
||||||
|
runUserCommand(os.Args[2:])
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg := config.Load()
|
cfg := config.Load()
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -6,6 +6,7 @@ require (
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/gorilla/feeds v1.2.0
|
github.com/gorilla/feeds v1.2.0
|
||||||
golang.org/x/crypto v0.49.0
|
golang.org/x/crypto v0.49.0
|
||||||
|
golang.org/x/term v0.41.0
|
||||||
modernc.org/sqlite v1.48.0
|
modernc.org/sqlite v1.48.0
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -29,6 +29,8 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||||
|
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue