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
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
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;
|
||||
@@ -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
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user