diff --git a/CLAUDE.md b/CLAUDE.md index 8b1dd42..be175fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,10 +2,19 @@ Project-specific rules for Claude when working in this repo. +## Commands + +Is the user types in any of the following, follow the instructions in the table + +| Command | Action | +| --- | --- | +| :release | trigger subagent to commit (if needed), push (if needed), raise PR, wait for PR to pass or fail. If fail, report back. If pass, merge in to main | + ## Repo The repo lives inside a Gitea instance; `tea` CLI is available for use by agents + ## Run `go vet` before every commit CI runs `go vet ./...` and will fail the build on any vet error. diff --git a/internal/store/sessions.go b/internal/store/sessions.go index df26831..a2ef31c 100644 --- a/internal/store/sessions.go +++ b/internal/store/sessions.go @@ -86,3 +86,18 @@ func (s *Store) PurgeExpiredSessions(ctx context.Context) (int64, error) { n, _ := res.RowsAffected() return n, nil } + +// DeleteSessionsByUserID removes every session row owned by the +// user. Returns count for caller logging. Used by: +// - admin "Force logout" button +// - admin Disable user (sessions outlive the disable flag, so we +// also clear them so the user gets bounced immediately) +func (s *Store) DeleteSessionsByUserID(ctx context.Context, userID string) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM sessions WHERE user_id = ?`, userID) + if err != nil { + return 0, fmt.Errorf("store: delete sessions by user: %w", err) + } + n, _ := res.RowsAffected() + return n, nil +} diff --git a/internal/store/sessions_test.go b/internal/store/sessions_test.go new file mode 100644 index 0000000..81222ee --- /dev/null +++ b/internal/store/sessions_test.go @@ -0,0 +1,45 @@ +package store + +import ( + "context" + "testing" + "time" +) + +func TestDeleteSessionsByUserID(t *testing.T) { + t.Parallel() + s := openTestStore(t) + ctx := context.Background() + now := time.Now().UTC() + + uid := "u-force" + if err := s.CreateUser(ctx, User{ + ID: uid, Username: "victim", + PasswordHash: "x", Role: RoleOperator, CreatedAt: now, + }); err != nil { + t.Fatalf("create user: %v", err) + } + + // Create two sessions for that user. + for i, h := range []string{"hash1", "hash2"} { + if err := s.CreateSession(ctx, Session{ + ID: h, + UserID: uid, + CreatedAt: now, + ExpiresAt: now.Add(time.Hour), + }, h); err != nil { + t.Fatalf("create session %d: %v", i, err) + } + } + + n, err := s.DeleteSessionsByUserID(ctx, uid) + if err != nil { + t.Fatalf("delete: %v", err) + } + if n != 2 { + t.Errorf("count: got %d want 2", n) + } + if _, err := s.LookupSession(ctx, "hash1"); err == nil { + t.Error("hash1 should be gone") + } +}