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 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 12:34:44 -04:00
parent 777e32ca24
commit a509ec538b
9 changed files with 411 additions and 8 deletions
+22
View File
@@ -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 {
+76
View File
@@ -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
}
+71
View File
@@ -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
}
+129 -8
View File
@@ -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
}