From a509ec538b16c72550807cc3621d8497174fe561 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 7 May 2026 12:34:44 -0400 Subject: [PATCH] feat(db/m7-user-mgmt): migration 0024 + self-service + SMTP queries (U3) Adds users.email (optional, lowercase-unique via partial index), smtp_config singleton, and password_resets tokens. Plus the queries U3-T2 / T3 / T4 use: - ChangeUserPassword: self-service password change. HTTP layer verifies the current password before this fires. - UpdateUserProfile: set display_name + email together. - RegenerateApiToken: invalidate old API token. - GetUserByEmail: case-insensitive lookup for forgot-password. - GetSMTPConfig + UpdateSMTPConfig: admin SMTP settings CRUD. - CreatePasswordReset / GetPasswordReset / UsePasswordReset (:execrows for atomic claim) / DeleteExpiredPasswordResets (cron-style cleanup, not yet wired). Email column is nullable; users without email have admin-reset as their only password-recovery path. The lower() unique index allows many NULLs and prevents case-insensitive duplicates. Co-Authored-By: Claude Sonnet 4.6 --- internal/db/dbq/models.go | 22 +++ internal/db/dbq/password_resets.sql.go | 76 ++++++++++ internal/db/dbq/smtp_config.sql.go | 71 +++++++++ internal/db/dbq/users.sql.go | 137 +++++++++++++++++- .../0024_user_management_u3.down.sql | 4 + .../migrations/0024_user_management_u3.up.sql | 44 ++++++ internal/db/queries/password_resets.sql | 22 +++ internal/db/queries/smtp_config.sql | 15 ++ internal/db/queries/users.sql | 28 ++++ 9 files changed, 411 insertions(+), 8 deletions(-) create mode 100644 internal/db/dbq/password_resets.sql.go create mode 100644 internal/db/dbq/smtp_config.sql.go create mode 100644 internal/db/migrations/0024_user_management_u3.down.sql create mode 100644 internal/db/migrations/0024_user_management_u3.up.sql create mode 100644 internal/db/queries/password_resets.sql create mode 100644 internal/db/queries/smtp_config.sql diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 929c1027..22f6fcec 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -340,6 +340,14 @@ type LidarrRequest struct { UpdatedAt pgtype.Timestamptz } +type PasswordReset struct { + Token string + UserID pgtype.UUID + CreatedAt pgtype.Timestamptz + ExpiresAt pgtype.Timestamptz + UsedAt pgtype.Timestamptz +} + type PlayEvent struct { ID pgtype.UUID UserID pgtype.UUID @@ -446,6 +454,19 @@ type SkipEvent struct { PositionMs int32 } +type SmtpConfig struct { + ID bool + Enabled bool + Host string + Port int32 + Username string + Password string + FromAddress string + FromName string + UseTls bool + UpdatedAt pgtype.Timestamptz +} + type SystemPlaylistRun struct { UserID pgtype.UUID LastRunAt pgtype.Timestamptz @@ -492,6 +513,7 @@ type User struct { ListenbrainzEnabled bool DisplayName *string AutoApproveRequests bool + Email *string } type UserInvite struct { diff --git a/internal/db/dbq/password_resets.sql.go b/internal/db/dbq/password_resets.sql.go new file mode 100644 index 00000000..64977b7f --- /dev/null +++ b/internal/db/dbq/password_resets.sql.go @@ -0,0 +1,76 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: password_resets.sql + +package dbq + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createPasswordReset = `-- name: CreatePasswordReset :exec +INSERT INTO password_resets (token, user_id, expires_at) +VALUES ($1, $2, $3) +` + +type CreatePasswordResetParams struct { + Token string + UserID pgtype.UUID + ExpiresAt pgtype.Timestamptz +} + +func (q *Queries) CreatePasswordReset(ctx context.Context, arg CreatePasswordResetParams) error { + _, err := q.db.Exec(ctx, createPasswordReset, arg.Token, arg.UserID, arg.ExpiresAt) + return err +} + +const deleteExpiredPasswordResets = `-- name: DeleteExpiredPasswordResets :exec +DELETE FROM password_resets WHERE expires_at < now() - interval '7 days' +` + +// Cleanup: drop tokens that have been expired more than 7 days. +// Not wired in U3; useful to have for a future cron sweep. +func (q *Queries) DeleteExpiredPasswordResets(ctx context.Context) error { + _, err := q.db.Exec(ctx, deleteExpiredPasswordResets) + return err +} + +const getPasswordReset = `-- name: GetPasswordReset :one +SELECT token, user_id, created_at, expires_at, used_at FROM password_resets WHERE token = $1 +` + +func (q *Queries) GetPasswordReset(ctx context.Context, token string) (PasswordReset, error) { + row := q.db.QueryRow(ctx, getPasswordReset, token) + var i PasswordReset + err := row.Scan( + &i.Token, + &i.UserID, + &i.CreatedAt, + &i.ExpiresAt, + &i.UsedAt, + ) + return i, err +} + +const usePasswordReset = `-- name: UsePasswordReset :execrows +UPDATE password_resets + SET used_at = now() + WHERE token = $1 + AND used_at IS NULL + AND expires_at > now() +` + +// Atomic claim: marks the token used only if currently unused +// and not expired. Returns rows-affected so the caller can +// distinguish "successful claim" (rows=1) from "already used / +// expired / nonexistent" (rows=0). +func (q *Queries) UsePasswordReset(ctx context.Context, token string) (int64, error) { + result, err := q.db.Exec(ctx, usePasswordReset, token) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/internal/db/dbq/smtp_config.sql.go b/internal/db/dbq/smtp_config.sql.go new file mode 100644 index 00000000..735523b1 --- /dev/null +++ b/internal/db/dbq/smtp_config.sql.go @@ -0,0 +1,71 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: smtp_config.sql + +package dbq + +import ( + "context" +) + +const getSMTPConfig = `-- name: GetSMTPConfig :one +SELECT id, enabled, host, port, username, password, from_address, from_name, use_tls, updated_at FROM smtp_config WHERE id = true +` + +func (q *Queries) GetSMTPConfig(ctx context.Context) (SmtpConfig, error) { + row := q.db.QueryRow(ctx, getSMTPConfig) + var i SmtpConfig + err := row.Scan( + &i.ID, + &i.Enabled, + &i.Host, + &i.Port, + &i.Username, + &i.Password, + &i.FromAddress, + &i.FromName, + &i.UseTls, + &i.UpdatedAt, + ) + return i, err +} + +const updateSMTPConfig = `-- name: UpdateSMTPConfig :exec +UPDATE smtp_config + SET enabled = $1, + host = $2, + port = $3, + username = $4, + password = $5, + from_address = $6, + from_name = $7, + use_tls = $8, + updated_at = now() + WHERE id = true +` + +type UpdateSMTPConfigParams struct { + Enabled bool + Host string + Port int32 + Username string + Password string + FromAddress string + FromName string + UseTls bool +} + +func (q *Queries) UpdateSMTPConfig(ctx context.Context, arg UpdateSMTPConfigParams) error { + _, err := q.db.Exec(ctx, updateSMTPConfig, + arg.Enabled, + arg.Host, + arg.Port, + arg.Username, + arg.Password, + arg.FromAddress, + arg.FromName, + arg.UseTls, + ) + return err +} diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index a221b73f..45ceb1ba 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -11,6 +11,24 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const changeUserPassword = `-- name: ChangeUserPassword :exec +UPDATE users SET password_hash = $2 WHERE id = $1 +` + +type ChangeUserPasswordParams struct { + ID pgtype.UUID + PasswordHash string +} + +// Self-service password change. Caller (HTTP handler) verifies the +// current password before calling this. Distinct from +// ResetUserPassword which is admin-driven (no current-password +// check). +func (q *Queries) ChangeUserPassword(ctx context.Context, arg ChangeUserPasswordParams) error { + _, err := q.db.Exec(ctx, changeUserPassword, arg.ID, arg.PasswordHash) + return err +} + const countAdmins = `-- name: CountAdmins :one SELECT count(*) FROM users WHERE is_admin = true ` @@ -36,7 +54,7 @@ func (q *Queries) CountUsers(ctx context.Context) (int64, error) { const createUser = `-- name: CreateUser :one INSERT INTO users (username, password_hash, api_token, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email ` type CreateUserParams struct { @@ -68,6 +86,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e &i.ListenbrainzEnabled, &i.DisplayName, &i.AutoApproveRequests, + &i.Email, ) return i, err } @@ -75,7 +94,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e const createUserAdmin = `-- name: CreateUserAdmin :one INSERT INTO users (username, password_hash, api_token, is_admin, display_name) VALUES ($1, $2, $3, $4, $5) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email ` type CreateUserAdminParams struct { @@ -110,6 +129,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams &i.ListenbrainzEnabled, &i.DisplayName, &i.AutoApproveRequests, + &i.Email, ) return i, err } @@ -121,7 +141,7 @@ VALUES ( (SELECT NOT EXISTS (SELECT 1 FROM users)), $4 ) -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email ` type CreateUserFirstAdminRaceParams struct { @@ -164,6 +184,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi &i.ListenbrainzEnabled, &i.DisplayName, &i.AutoApproveRequests, + &i.Email, ) return i, err } @@ -210,7 +231,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge } const getUserByAPIToken = `-- name: GetUserByAPIToken :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests FROM users WHERE api_token = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE api_token = $1 ` func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, error) { @@ -228,12 +249,40 @@ func (q *Queries) GetUserByAPIToken(ctx context.Context, apiToken string) (User, &i.ListenbrainzEnabled, &i.DisplayName, &i.AutoApproveRequests, + &i.Email, + ) + return i, err +} + +const getUserByEmail = `-- name: GetUserByEmail :one +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE lower(email) = lower($1) +` + +// Used by forgot-password lookup. Lowercase comparison both sides +// to keep the unique index happy and to make the lookup +// case-insensitive. +func (q *Queries) GetUserByEmail(ctx context.Context, lower string) (User, error) { + row := q.db.QueryRow(ctx, getUserByEmail, lower) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.ApiToken, + &i.IsAdmin, + &i.CreatedAt, + &i.SubsonicPassword, + &i.ListenbrainzToken, + &i.ListenbrainzEnabled, + &i.DisplayName, + &i.AutoApproveRequests, + &i.Email, ) return i, err } const getUserByID = `-- name: GetUserByID :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests FROM users WHERE id = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE id = $1 ` func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) { @@ -251,12 +300,13 @@ func (q *Queries) GetUserByID(ctx context.Context, id pgtype.UUID) (User, error) &i.ListenbrainzEnabled, &i.DisplayName, &i.AutoApproveRequests, + &i.Email, ) return i, err } const getUserByUsername = `-- name: GetUserByUsername :one -SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests FROM users WHERE username = $1 +SELECT id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email FROM users WHERE username = $1 ` func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, error) { @@ -274,6 +324,7 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User, &i.ListenbrainzEnabled, &i.DisplayName, &i.AutoApproveRequests, + &i.Email, ) return i, err } @@ -321,6 +372,38 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { return items, nil } +const regenerateApiToken = `-- name: RegenerateApiToken :one +UPDATE users SET api_token = $2 WHERE id = $1 +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +` + +type RegenerateApiTokenParams struct { + ID pgtype.UUID + ApiToken string +} + +// Self-service: caller wants a new API token. Used by the /settings +// API Token card's "Regenerate" button. +func (q *Queries) RegenerateApiToken(ctx context.Context, arg RegenerateApiTokenParams) (User, error) { + row := q.db.QueryRow(ctx, regenerateApiToken, arg.ID, arg.ApiToken) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.ApiToken, + &i.IsAdmin, + &i.CreatedAt, + &i.SubsonicPassword, + &i.ListenbrainzToken, + &i.ListenbrainzEnabled, + &i.DisplayName, + &i.AutoApproveRequests, + &i.Email, + ) + return i, err +} + const resetUserPassword = `-- name: ResetUserPassword :exec UPDATE users SET password_hash = $2 @@ -394,7 +477,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one UPDATE users SET is_admin = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email ` type UpdateUserAdminParams struct { @@ -419,6 +502,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams &i.ListenbrainzEnabled, &i.DisplayName, &i.AutoApproveRequests, + &i.Email, ) return i, err } @@ -427,7 +511,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one UPDATE users SET auto_approve_requests = $2 WHERE id = $1 -RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email ` type UpdateUserAutoApproveParams struct { @@ -451,6 +535,43 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA &i.ListenbrainzEnabled, &i.DisplayName, &i.AutoApproveRequests, + &i.Email, + ) + return i, err +} + +const updateUserProfile = `-- name: UpdateUserProfile :one +UPDATE users + SET display_name = $2, + email = $3 + WHERE id = $1 +RETURNING id, username, password_hash, api_token, is_admin, created_at, subsonic_password, listenbrainz_token, listenbrainz_enabled, display_name, auto_approve_requests, email +` + +type UpdateUserProfileParams struct { + ID pgtype.UUID + DisplayName *string + Email *string +} + +// Self-service: set display name and/or email. Both fields are +// nullable in the DB; pass NULL ptr to clear, non-NULL ptr to set. +func (q *Queries) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) { + row := q.db.QueryRow(ctx, updateUserProfile, arg.ID, arg.DisplayName, arg.Email) + var i User + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.ApiToken, + &i.IsAdmin, + &i.CreatedAt, + &i.SubsonicPassword, + &i.ListenbrainzToken, + &i.ListenbrainzEnabled, + &i.DisplayName, + &i.AutoApproveRequests, + &i.Email, ) return i, err } diff --git a/internal/db/migrations/0024_user_management_u3.down.sql b/internal/db/migrations/0024_user_management_u3.down.sql new file mode 100644 index 00000000..fc39e95e --- /dev/null +++ b/internal/db/migrations/0024_user_management_u3.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS password_resets; +DROP TABLE IF EXISTS smtp_config; +DROP INDEX IF EXISTS users_email_unique; +ALTER TABLE users DROP COLUMN IF EXISTS email; diff --git a/internal/db/migrations/0024_user_management_u3.up.sql b/internal/db/migrations/0024_user_management_u3.up.sql new file mode 100644 index 00000000..072daa05 --- /dev/null +++ b/internal/db/migrations/0024_user_management_u3.up.sql @@ -0,0 +1,44 @@ +-- User management U3: self-service profile + email + email-based +-- password reset infrastructure. +-- +-- - users.email: optional; required for the forgot-password flow. +-- Lowercase-unique via a partial index to allow many NULL emails +-- while preventing case-insensitive duplicates. +-- - smtp_config singleton: SMTP server settings + from-address + +-- enabled flag. The actual mailer reads this row at send time so +-- config edits take effect without a server restart. +-- - password_resets: short-lived single-use tokens for the +-- forgot-password flow. 24h TTL enforced at issue time. + +ALTER TABLE users ADD COLUMN IF NOT EXISTS email text; +CREATE UNIQUE INDEX IF NOT EXISTS users_email_unique + ON users (lower(email)) + WHERE email IS NOT NULL; + +CREATE TABLE smtp_config ( + id boolean PRIMARY KEY DEFAULT true, + enabled boolean NOT NULL DEFAULT false, + host text NOT NULL DEFAULT '', + port int NOT NULL DEFAULT 587, + username text NOT NULL DEFAULT '', + password text NOT NULL DEFAULT '', + from_address text NOT NULL DEFAULT '', + from_name text NOT NULL DEFAULT 'Minstrel', + use_tls boolean NOT NULL DEFAULT true, + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT smtp_config_singleton CHECK (id = true) +); +INSERT INTO smtp_config (id) VALUES (true) + ON CONFLICT (id) DO NOTHING; + +CREATE TABLE password_resets ( + token text PRIMARY KEY, + user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL, + used_at timestamptz +); +CREATE INDEX password_resets_user_id_idx ON password_resets (user_id); +CREATE INDEX password_resets_active_idx + ON password_resets (expires_at) + WHERE used_at IS NULL; diff --git a/internal/db/queries/password_resets.sql b/internal/db/queries/password_resets.sql new file mode 100644 index 00000000..8ff57f22 --- /dev/null +++ b/internal/db/queries/password_resets.sql @@ -0,0 +1,22 @@ +-- name: CreatePasswordReset :exec +INSERT INTO password_resets (token, user_id, expires_at) +VALUES ($1, $2, $3); + +-- name: GetPasswordReset :one +SELECT * FROM password_resets WHERE token = $1; + +-- name: UsePasswordReset :execrows +-- Atomic claim: marks the token used only if currently unused +-- and not expired. Returns rows-affected so the caller can +-- distinguish "successful claim" (rows=1) from "already used / +-- expired / nonexistent" (rows=0). +UPDATE password_resets + SET used_at = now() + WHERE token = $1 + AND used_at IS NULL + AND expires_at > now(); + +-- name: DeleteExpiredPasswordResets :exec +-- Cleanup: drop tokens that have been expired more than 7 days. +-- Not wired in U3; useful to have for a future cron sweep. +DELETE FROM password_resets WHERE expires_at < now() - interval '7 days'; diff --git a/internal/db/queries/smtp_config.sql b/internal/db/queries/smtp_config.sql new file mode 100644 index 00000000..c197f658 --- /dev/null +++ b/internal/db/queries/smtp_config.sql @@ -0,0 +1,15 @@ +-- name: GetSMTPConfig :one +SELECT * FROM smtp_config WHERE id = true; + +-- name: UpdateSMTPConfig :exec +UPDATE smtp_config + SET enabled = $1, + host = $2, + port = $3, + username = $4, + password = $5, + from_address = $6, + from_name = $7, + use_tls = $8, + updated_at = now() + WHERE id = true; diff --git a/internal/db/queries/users.sql b/internal/db/queries/users.sql index 55084c65..c8b81315 100644 --- a/internal/db/queries/users.sql +++ b/internal/db/queries/users.sql @@ -114,3 +114,31 @@ UPDATE users SET auto_approve_requests = $2 WHERE id = $1 RETURNING *; + +-- name: ChangeUserPassword :exec +-- Self-service password change. Caller (HTTP handler) verifies the +-- current password before calling this. Distinct from +-- ResetUserPassword which is admin-driven (no current-password +-- check). +UPDATE users SET password_hash = $2 WHERE id = $1; + +-- name: UpdateUserProfile :one +-- Self-service: set display name and/or email. Both fields are +-- nullable in the DB; pass NULL ptr to clear, non-NULL ptr to set. +UPDATE users + SET display_name = $2, + email = $3 + WHERE id = $1 +RETURNING *; + +-- name: RegenerateApiToken :one +-- Self-service: caller wants a new API token. Used by the /settings +-- API Token card's "Regenerate" button. +UPDATE users SET api_token = $2 WHERE id = $1 +RETURNING *; + +-- name: GetUserByEmail :one +-- Used by forgot-password lookup. Lowercase comparison both sides +-- to keep the unique index happy and to make the lookup +-- case-insensitive. +SELECT * FROM users WHERE lower(email) = lower($1);