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
}
@@ -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;
@@ -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;
+22
View File
@@ -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';
+15
View File
@@ -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;
+28
View File
@@ -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);