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:
@@ -340,6 +340,14 @@ type LidarrRequest struct {
|
|||||||
UpdatedAt pgtype.Timestamptz
|
UpdatedAt pgtype.Timestamptz
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PasswordReset struct {
|
||||||
|
Token string
|
||||||
|
UserID pgtype.UUID
|
||||||
|
CreatedAt pgtype.Timestamptz
|
||||||
|
ExpiresAt pgtype.Timestamptz
|
||||||
|
UsedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type PlayEvent struct {
|
type PlayEvent struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
@@ -446,6 +454,19 @@ type SkipEvent struct {
|
|||||||
PositionMs int32
|
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 {
|
type SystemPlaylistRun struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
LastRunAt pgtype.Timestamptz
|
LastRunAt pgtype.Timestamptz
|
||||||
@@ -492,6 +513,7 @@ type User struct {
|
|||||||
ListenbrainzEnabled bool
|
ListenbrainzEnabled bool
|
||||||
DisplayName *string
|
DisplayName *string
|
||||||
AutoApproveRequests bool
|
AutoApproveRequests bool
|
||||||
|
Email *string
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserInvite struct {
|
type UserInvite struct {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -11,6 +11,24 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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
|
const countAdmins = `-- name: CountAdmins :one
|
||||||
SELECT count(*) FROM users WHERE is_admin = true
|
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
|
const createUser = `-- name: CreateUser :one
|
||||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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 {
|
type CreateUserParams struct {
|
||||||
@@ -68,6 +86,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
&i.ListenbrainzEnabled,
|
&i.ListenbrainzEnabled,
|
||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
|
&i.Email,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -75,7 +94,7 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, e
|
|||||||
const createUserAdmin = `-- name: CreateUserAdmin :one
|
const createUserAdmin = `-- name: CreateUserAdmin :one
|
||||||
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
INSERT INTO users (username, password_hash, api_token, is_admin, display_name)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
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 {
|
type CreateUserAdminParams struct {
|
||||||
@@ -110,6 +129,7 @@ func (q *Queries) CreateUserAdmin(ctx context.Context, arg CreateUserAdminParams
|
|||||||
&i.ListenbrainzEnabled,
|
&i.ListenbrainzEnabled,
|
||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
|
&i.Email,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -121,7 +141,7 @@ VALUES (
|
|||||||
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
||||||
$4
|
$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 {
|
type CreateUserFirstAdminRaceParams struct {
|
||||||
@@ -164,6 +184,7 @@ func (q *Queries) CreateUserFirstAdminRace(ctx context.Context, arg CreateUserFi
|
|||||||
&i.ListenbrainzEnabled,
|
&i.ListenbrainzEnabled,
|
||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
|
&i.Email,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -210,7 +231,7 @@ func (q *Queries) GetListenBrainzConfig(ctx context.Context, id pgtype.UUID) (Ge
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getUserByAPIToken = `-- name: GetUserByAPIToken :one
|
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) {
|
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.ListenbrainzEnabled,
|
||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&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
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByID = `-- name: GetUserByID :one
|
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) {
|
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.ListenbrainzEnabled,
|
||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
|
&i.Email,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
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) {
|
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.ListenbrainzEnabled,
|
||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
|
&i.Email,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -321,6 +372,38 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) {
|
|||||||
return items, nil
|
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
|
const resetUserPassword = `-- name: ResetUserPassword :exec
|
||||||
UPDATE users
|
UPDATE users
|
||||||
SET password_hash = $2
|
SET password_hash = $2
|
||||||
@@ -394,7 +477,7 @@ const updateUserAdmin = `-- name: UpdateUserAdmin :one
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET is_admin = $2
|
SET is_admin = $2
|
||||||
WHERE id = $1
|
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 {
|
type UpdateUserAdminParams struct {
|
||||||
@@ -419,6 +502,7 @@ func (q *Queries) UpdateUserAdmin(ctx context.Context, arg UpdateUserAdminParams
|
|||||||
&i.ListenbrainzEnabled,
|
&i.ListenbrainzEnabled,
|
||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&i.AutoApproveRequests,
|
||||||
|
&i.Email,
|
||||||
)
|
)
|
||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
@@ -427,7 +511,7 @@ const updateUserAutoApprove = `-- name: UpdateUserAutoApprove :one
|
|||||||
UPDATE users
|
UPDATE users
|
||||||
SET auto_approve_requests = $2
|
SET auto_approve_requests = $2
|
||||||
WHERE id = $1
|
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 {
|
type UpdateUserAutoApproveParams struct {
|
||||||
@@ -451,6 +535,43 @@ func (q *Queries) UpdateUserAutoApprove(ctx context.Context, arg UpdateUserAutoA
|
|||||||
&i.ListenbrainzEnabled,
|
&i.ListenbrainzEnabled,
|
||||||
&i.DisplayName,
|
&i.DisplayName,
|
||||||
&i.AutoApproveRequests,
|
&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
|
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;
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
@@ -114,3 +114,31 @@ UPDATE users
|
|||||||
SET auto_approve_requests = $2
|
SET auto_approve_requests = $2
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING *;
|
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);
|
||||||
|
|||||||
Reference in New Issue
Block a user