Release v0.20: регистрация, авторизация, личный кабинет

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
shop
2026-05-16 17:31:56 +03:00
parent 4ea2b429b3
commit b3e3a06858
23 changed files with 981 additions and 27 deletions
+2
View File
@@ -11,6 +11,8 @@ POSTGRES_DB=shopdb
DATABASE_URL=postgres://shop:shop_secret_change_me@postgres:5432/shopdb?sslmode=require DATABASE_URL=postgres://shop:shop_secret_change_me@postgres:5432/shopdb?sslmode=require
APP_PORT=8080 APP_PORT=8080
SESSION_TTL_HOURS=168
COOKIE_SECURE=false
DB_HOST=postgres DB_HOST=postgres
DB_PORT=5432 DB_PORT=5432
+9 -1
View File
@@ -1,9 +1,17 @@
# Changelog # Changelog
## [0.10-beta] — 2026-05-16 ## [0.20] — 2026-05-16
### Добавлено ### Добавлено
- Регистрация (`/register`), вход (`/login`), выход
- Личный кабинет (`/account`) с редактированием профиля
- Сессии в cookie, хеширование паролей bcrypt
- Таблицы `users` и `sessions` в PostgreSQL
## [0.10-beta] — 2026-05-16
### Добавлено
- Главная страница интернет-магазина (Go, HTML/CSS) - Главная страница интернет-магазина (Go, HTML/CSS)
- PostgreSQL 17 с SSL, Docker Compose, Caddy - PostgreSQL 17 с SSL, Docker Compose, Caddy
- Интерактивный установщик (`install.sh` / `install.ps1`) — домен и база данных - Интерактивный установщик (`install.sh` / `install.ps1`) — домен и база данных
+19 -2
View File
@@ -1,6 +1,6 @@
# ShopNova — интернет-магазин (Go) # ShopNova — интернет-магазин (Go)
**Версия:** `0.10-beta` · [Релизы](https://git.evilfox.cc/test/shop3/releases) **Версия:** `0.20` · [Релизы](https://git.evilfox.cc/test/shop3/releases)
Главная страница интернет-магазина на Go с PostgreSQL 17 (SSL), reverse proxy Caddy и Docker Compose. Главная страница интернет-магазина на Go с PostgreSQL 17 (SSL), reverse proxy Caddy и Docker Compose.
@@ -9,7 +9,7 @@
Клонировать конкретную версию: Клонировать конкретную версию:
```bash ```bash
git clone --branch v0.10-beta https://git.evilfox.cc/test/shop3.git git clone --branch v0.20 https://git.evilfox.cc/test/shop3.git
``` ```
## Быстрая установка на сервере ## Быстрая установка на сервере
@@ -92,6 +92,23 @@ docker compose up --build -d
- `GET /health` — статус и проверки - `GET /health` — статус и проверки
- `GET /version` — версии приложения, Go и PostgreSQL - `GET /version` — версии приложения, Go и PostgreSQL
## Регистрация и личный кабинет
| URL | Описание |
|-----|----------|
| `/register` | Регистрация |
| `/login` | Вход |
| `/account` | Личный кабинет (только для авторизованных) |
| `POST /logout` | Выход |
Сессии в cookie `shop_session`, пароли — bcrypt.
Если БД уже была создана до обновления, примените миграцию:
```bash
docker compose exec -T postgres psql -U shop -d shopdb < postgres/init/02_users.sql
```
## Локальная разработка ## Локальная разработка
```bash ```bash
+13 -1
View File
@@ -11,6 +11,7 @@ import (
"syscall" "syscall"
"time" "time"
"shop/internal/auth"
"shop/internal/check" "shop/internal/check"
"shop/internal/config" "shop/internal/config"
"shop/internal/database" "shop/internal/database"
@@ -49,9 +50,16 @@ func main() {
log.Fatalf("templates: %v", err) log.Fatalf("templates: %v", err)
} }
users := repository.NewUserRepository(pool)
sessions := repository.NewSessionRepository(pool)
authSvc := auth.NewService(users, sessions, cfg.SessionTTL, cfg.CookieSecure)
pages := handlers.NewPages(tmpl, authSvc)
products := repository.NewProductRepository(pool) products := repository.NewProductRepository(pool)
home := handlers.NewHomeHandler(products, tmpl) home := handlers.NewHomeHandler(products, pages)
health := handlers.NewHealthHandler(pool) health := handlers.NewHealthHandler(pool)
authH := handlers.NewAuthHandler(pages, authSvc)
account := handlers.NewAccountHandler(pages, authSvc)
staticSub, err := fs.Sub(web.StaticFS, "static") staticSub, err := fs.Sub(web.StaticFS, "static")
if err != nil { if err != nil {
@@ -63,6 +71,10 @@ func main() {
mux.HandleFunc("GET /health", health.Health) mux.HandleFunc("GET /health", health.Health)
mux.HandleFunc("GET /version", health.Version) mux.HandleFunc("GET /version", health.Version)
mux.Handle("GET /", home) mux.Handle("GET /", home)
mux.HandleFunc("/register", authH.Register)
mux.HandleFunc("/login", authH.Login)
mux.HandleFunc("POST /logout", authH.Logout)
mux.HandleFunc("/account", account.Account)
srv := &http.Server{ srv := &http.Server{
Addr: cfg.HTTPAddr, Addr: cfg.HTTPAddr,
+4 -2
View File
@@ -2,13 +2,15 @@ module shop
go 1.22 go 1.22
require github.com/jackc/pgx/v5 v5.7.2 require (
github.com/jackc/pgx/v5 v5.7.2
golang.org/x/crypto v0.31.0
)
require ( require (
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
) )
+3
View File
@@ -0,0 +1,3 @@
package auth
const SessionCookieName = "shop_session"
+27
View File
@@ -0,0 +1,27 @@
package auth
import (
"errors"
"unicode/utf8"
"golang.org/x/crypto/bcrypt"
)
const (
minPasswordLen = 8
bcryptCost = 12
)
var ErrInvalidCredentials = errors.New("неверный email или пароль")
func HashPassword(password string) (string, error) {
if utf8.RuneCountInString(password) < minPasswordLen {
return "", errors.New("пароль должен быть не короче 8 символов")
}
b, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
return string(b), err
}
func CheckPassword(hash, password string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}
+137
View File
@@ -0,0 +1,137 @@
package auth
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"shop/internal/models"
"shop/internal/repository"
)
var (
ErrEmailTaken = errors.New("этот email уже зарегистрирован")
ErrInvalidEmail = errors.New("некорректный email")
ErrInvalidName = errors.New("имя должно быть не короче 2 символов")
ErrNotAuthenticated = errors.New("требуется авторизация")
)
var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
type Service struct {
users *repository.UserRepository
sessions *repository.SessionRepository
ttl time.Duration
secure bool
}
func NewService(users *repository.UserRepository, sessions *repository.SessionRepository, ttl time.Duration, cookieSecure bool) *Service {
return &Service{users: users, sessions: sessions, ttl: ttl, secure: cookieSecure}
}
func (s *Service) Register(ctx context.Context, email, password, name string) (*models.User, error) {
email = strings.TrimSpace(strings.ToLower(email))
name = strings.TrimSpace(name)
if !emailRe.MatchString(email) {
return nil, ErrInvalidEmail
}
if len([]rune(name)) < 2 {
return nil, ErrInvalidName
}
hash, err := HashPassword(password)
if err != nil {
return nil, err
}
user, err := s.users.Create(ctx, email, hash, name)
if err != nil {
if repository.IsUniqueViolation(err) {
return nil, ErrEmailTaken
}
return nil, err
}
return user, nil
}
func (s *Service) Login(ctx context.Context, w http.ResponseWriter, email, password string) error {
email = strings.TrimSpace(strings.ToLower(email))
user, hash, err := s.users.ByEmailWithHash(ctx, email)
if err != nil || !CheckPassword(hash, password) {
return ErrInvalidCredentials
}
return s.setSession(ctx, w, user.ID)
}
func (s *Service) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(SessionCookieName)
if err == nil && c.Value != "" {
_ = s.sessions.Delete(ctx, c.Value)
}
s.clearCookie(w)
}
func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (*models.User, error) {
c, err := r.Cookie(SessionCookieName)
if err != nil || c.Value == "" {
return nil, nil
}
userID, err := s.sessions.UserID(ctx, c.Value)
if err != nil || userID == 0 {
return nil, nil
}
return s.users.ByID(ctx, userID)
}
func (s *Service) UpdateName(ctx context.Context, userID int, name string) error {
name = strings.TrimSpace(name)
if len([]rune(name)) < 2 {
return ErrInvalidName
}
return s.users.UpdateName(ctx, userID, name)
}
func (s *Service) setSession(ctx context.Context, w http.ResponseWriter, userID int) error {
token, err := newToken()
if err != nil {
return err
}
expires := time.Now().Add(s.ttl)
if err := s.sessions.Create(ctx, token, userID, expires); err != nil {
return err
}
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: token,
Path: "/",
Expires: expires,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.secure,
})
return nil
}
func (s *Service) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: s.secure,
})
}
func newToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("session token: %w", err)
}
return hex.EncodeToString(b), nil
}
+13
View File
@@ -12,6 +12,8 @@ type Config struct {
DatabaseURL string DatabaseURL string
ReadTimeout time.Duration ReadTimeout time.Duration
WriteTimeout time.Duration WriteTimeout time.Duration
SessionTTL time.Duration
CookieSecure bool
} }
func Load() (Config, error) { func Load() (Config, error) {
@@ -21,6 +23,8 @@ func Load() (Config, error) {
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
ReadTimeout: durationEnv("HTTP_READ_TIMEOUT", 10*time.Second), ReadTimeout: durationEnv("HTTP_READ_TIMEOUT", 10*time.Second),
WriteTimeout: durationEnv("HTTP_WRITE_TIMEOUT", 30*time.Second), WriteTimeout: durationEnv("HTTP_WRITE_TIMEOUT", 30*time.Second),
SessionTTL: sessionTTL(),
CookieSecure: env("COOKIE_SECURE", "false") == "true",
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {
return cfg, fmt.Errorf("DATABASE_URL is required") return cfg, fmt.Errorf("DATABASE_URL is required")
@@ -35,6 +39,15 @@ func env(key, fallback string) string {
return fallback return fallback
} }
func sessionTTL() time.Duration {
if v := os.Getenv("SESSION_TTL_HOURS"); v != "" {
if h, err := strconv.Atoi(v); err == nil && h > 0 {
return time.Duration(h) * time.Hour
}
}
return 168 * time.Hour
}
func durationEnv(key string, fallback time.Duration) time.Duration { func durationEnv(key string, fallback time.Duration) time.Duration {
v := os.Getenv(key) v := os.Getenv(key)
if v == "" { if v == "" {
+66
View File
@@ -0,0 +1,66 @@
package handlers
import (
"net/http"
"shop/internal/auth"
)
type AccountHandler struct {
pages *Pages
auth *auth.Service
}
func NewAccountHandler(pages *Pages, authSvc *auth.Service) *AccountHandler {
return &AccountHandler{pages: pages, auth: authSvc}
}
type accountPageData struct {
Layout
Name string
}
func (h *AccountHandler) Account(w http.ResponseWriter, r *http.Request) {
user, err := h.auth.UserFromRequest(r.Context(), r)
if err != nil || user == nil {
http.Redirect(w, r, "/login?next=/account", http.StatusSeeOther)
return
}
switch r.Method {
case http.MethodGet:
data := accountPageData{
Layout: h.pages.layout(r, "Личный кабинет", "account"),
Name: user.Name,
}
data.Success = flashMsg(r, "ok")
h.pages.render(w, "account.html", data)
case http.MethodPost:
h.updateProfile(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *AccountHandler) updateProfile(w http.ResponseWriter, r *http.Request) {
user, _ := h.auth.UserFromRequest(r.Context(), r)
if user == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Redirect(w, r, "/account", http.StatusSeeOther)
return
}
name := r.FormValue("name")
if err := h.auth.UpdateName(r.Context(), user.ID, name); err != nil {
data := accountPageData{
Layout: h.pages.layout(r, "Личный кабинет", "account"),
Name: name,
}
data.Error = err.Error()
h.pages.render(w, "account.html", data)
return
}
http.Redirect(w, r, "/account?ok=profile", http.StatusSeeOther)
}
+131
View File
@@ -0,0 +1,131 @@
package handlers
import (
"errors"
"net/http"
"strings"
"shop/internal/auth"
)
type AuthHandler struct {
pages *Pages
auth *auth.Service
}
func NewAuthHandler(pages *Pages, authSvc *auth.Service) *AuthHandler {
return &AuthHandler{pages: pages, auth: authSvc}
}
type authPageData struct {
Layout
Email string
Name string
Next string
}
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.showRegister(w, r, "", "")
case http.MethodPost:
h.postRegister(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *AuthHandler) showRegister(w http.ResponseWriter, r *http.Request, errMsg string, email string) {
data := authPageData{
Layout: h.pages.layout(r, "Регистрация", "register"),
Email: email,
}
data.Error = errMsg
if msg := flashMsg(r, "ok"); msg != "" {
data.Success = msg
}
h.pages.render(w, "register.html", data)
}
func (h *AuthHandler) postRegister(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.showRegister(w, r, "Неверные данные формы", "")
return
}
email := r.FormValue("email")
name := r.FormValue("name")
password := r.FormValue("password")
password2 := r.FormValue("password_confirm")
if password != password2 {
h.showRegister(w, r, "Пароли не совпадают", email)
return
}
_, err := h.auth.Register(r.Context(), email, password, name)
if err != nil {
h.showRegister(w, r, err.Error(), email)
return
}
http.Redirect(w, r, "/login?ok=registered", http.StatusSeeOther)
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
h.showLogin(w, r, "", "")
case http.MethodPost:
h.postLogin(w, r)
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
}
func (h *AuthHandler) showLogin(w http.ResponseWriter, r *http.Request, errMsg, email string) {
data := authPageData{
Layout: h.pages.layout(r, "Вход", "login"),
Email: email,
Next: safeNext(r.URL.Query().Get("next")),
}
data.Error = errMsg
data.Success = flashMsg(r, "ok")
if data.Layout.User != nil {
http.Redirect(w, r, "/account", http.StatusSeeOther)
return
}
h.pages.render(w, "login.html", data)
}
func (h *AuthHandler) postLogin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
h.showLogin(w, r, "Неверные данные формы", "")
return
}
email := r.FormValue("email")
password := r.FormValue("password")
if err := h.auth.Login(r.Context(), w, email, password); err != nil {
msg := err.Error()
if errors.Is(err, auth.ErrInvalidCredentials) {
msg = "Неверный email или пароль"
}
h.showLogin(w, r, msg, email)
return
}
next := safeNext(r.FormValue("next"))
http.Redirect(w, r, next+"?ok=login", http.StatusSeeOther)
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
h.auth.Logout(r.Context(), w, r)
http.Redirect(w, r, "/?ok=logout", http.StatusSeeOther)
}
func safeNext(next string) string {
next = strings.TrimSpace(next)
if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
return "/account"
}
return next
}
+7 -10
View File
@@ -1,7 +1,6 @@
package handlers package handlers
import ( import (
"html/template"
"log" "log"
"net/http" "net/http"
@@ -11,15 +10,15 @@ import (
type HomeHandler struct { type HomeHandler struct {
products *repository.ProductRepository products *repository.ProductRepository
templates *template.Template pages *Pages
} }
func NewHomeHandler(products *repository.ProductRepository, templates *template.Template) *HomeHandler { func NewHomeHandler(products *repository.ProductRepository, pages *Pages) *HomeHandler {
return &HomeHandler{products: products, templates: templates} return &HomeHandler{products: products, pages: pages}
} }
type homePageData struct { type homePageData struct {
Title string Layout
Products []models.Product Products []models.Product
Categories []string Categories []string
TotalItems int TotalItems int
@@ -52,14 +51,12 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
data := homePageData{ data := homePageData{
Title: "Главная", Layout: h.pages.layout(r, "Главная", "home"),
Products: featured, Products: featured,
Categories: categories, Categories: categories,
TotalItems: total, TotalItems: total,
} }
data.Success = flashMsg(r, "ok")
w.Header().Set("Content-Type", "text/html; charset=utf-8") h.pages.render(w, "home.html", data)
if err := h.templates.ExecuteTemplate(w, "home.html", data); err != nil {
log.Printf("render home: %v", err)
}
} }
+53
View File
@@ -0,0 +1,53 @@
package handlers
import (
"html/template"
"net/http"
"shop/internal/auth"
"shop/internal/models"
)
type Layout struct {
Title string
Nav string
User *models.User
Error string
Success string
}
type Pages struct {
tmpl *template.Template
auth *auth.Service
}
func NewPages(tmpl *template.Template, authSvc *auth.Service) *Pages {
return &Pages{tmpl: tmpl, auth: authSvc}
}
func (p *Pages) layout(r *http.Request, title, nav string) Layout {
user, _ := p.auth.UserFromRequest(r.Context(), r)
return Layout{Title: title, Nav: nav, User: user}
}
func (p *Pages) render(w http.ResponseWriter, name string, data any) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := p.tmpl.ExecuteTemplate(w, name, data); err != nil {
http.Error(w, "ошибка шаблона", http.StatusInternalServerError)
}
}
func flashMsg(r *http.Request, key string) string {
switch r.URL.Query().Get(key) {
case "registered":
return "Регистрация успешна. Войдите в аккаунт."
case "login":
return "Вы успешно вошли."
case "logout":
return "Вы вышли из аккаунта."
case "profile":
return "Профиль обновлён."
default:
return ""
}
}
+10
View File
@@ -0,0 +1,10 @@
package models
import "time"
type User struct {
ID int
Email string
Name string
CreatedAt time.Time
}
+44
View File
@@ -0,0 +1,44 @@
package repository
import (
"context"
"errors"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type SessionRepository struct {
pool *pgxpool.Pool
}
func NewSessionRepository(pool *pgxpool.Pool) *SessionRepository {
return &SessionRepository{pool: pool}
}
func (r *SessionRepository) Create(ctx context.Context, token string, userID int, expires time.Time) error {
_, err := r.pool.Exec(ctx, `
INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, $3)`,
token, userID, expires)
return err
}
func (r *SessionRepository) UserID(ctx context.Context, token string) (int, error) {
var userID int
err := r.pool.QueryRow(ctx, `
SELECT user_id FROM sessions
WHERE id = $1 AND expires_at > NOW()`, token).Scan(&userID)
if errors.Is(err, pgx.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
return userID, nil
}
func (r *SessionRepository) Delete(ctx context.Context, token string) error {
_, err := r.pool.Exec(ctx, `DELETE FROM sessions WHERE id = $1`, token)
return err
}
+80
View File
@@ -0,0 +1,80 @@
package repository
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"shop/internal/models"
)
type UserRepository struct {
pool *pgxpool.Pool
}
func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
return &UserRepository{pool: pool}
}
func (r *UserRepository) Create(ctx context.Context, email, passwordHash, name string) (*models.User, error) {
var u models.User
err := r.pool.QueryRow(ctx, `
INSERT INTO users (email, password_hash, name)
VALUES ($1, $2, $3)
RETURNING id, email, name, created_at`,
email, passwordHash, name,
).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
if err != nil {
return nil, err
}
return &u, nil
}
func (r *UserRepository) ByID(ctx context.Context, id int) (*models.User, error) {
var u models.User
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, created_at FROM users WHERE id = $1`, id,
).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &u, nil
}
func (r *UserRepository) ByEmailWithHash(ctx context.Context, email string) (*models.User, string, error) {
var u models.User
var hash string
err := r.pool.QueryRow(ctx, `
SELECT id, email, name, created_at, password_hash
FROM users WHERE email = $1`, email,
).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt, &hash)
if errors.Is(err, pgx.ErrNoRows) {
return nil, "", nil
}
if err != nil {
return nil, "", err
}
return &u, hash, nil
}
func (r *UserRepository) UpdateName(ctx context.Context, userID int, name string) error {
_, err := r.pool.Exec(ctx, `UPDATE users SET name = $1 WHERE id = $2`, name, userID)
return err
}
func (r *UserRepository) Count(ctx context.Context) (int, error) {
var n int
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
return n, err
}
func IsUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+1 -1
View File
@@ -3,7 +3,7 @@ package version
import "runtime" import "runtime"
const ( const (
AppVersion = "0.10-beta" AppVersion = "0.20"
ExpectedPostgresMajor = 17 ExpectedPostgresMajor = 17
MinGoVersion = "1.22" MinGoVersion = "1.22"
) )
+216
View File
@@ -396,4 +396,220 @@ a {
.features-grid { .features-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.account-grid {
grid-template-columns: 1fr;
}
.user-greeting {
display: none;
}
}
/* Auth */
.inline-form {
display: inline;
margin: 0;
}
.user-greeting {
font-size: 0.9rem;
color: var(--text-muted);
margin-right: 0.25rem;
}
.alert {
margin: 1rem 0 0;
padding: 0.75rem 1rem;
border-radius: var(--radius);
font-size: 0.95rem;
}
.alert-success {
background: rgba(72, 187, 120, 0.15);
border: 1px solid rgba(72, 187, 120, 0.35);
color: #9ae6b4;
}
.alert-error {
background: rgba(245, 101, 101, 0.12);
border: 1px solid rgba(245, 101, 101, 0.35);
color: #feb2b2;
}
.auth-section {
padding: 3rem 0 5rem;
}
.auth-container {
display: flex;
justify-content: center;
}
.auth-card {
width: min(420px, 100%);
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem;
}
.auth-title {
font-family: var(--font-display);
font-size: 2rem;
font-weight: 400;
margin: 0 0 0.35rem;
}
.auth-sub {
color: var(--text-muted);
margin: 0 0 1.5rem;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.form-field span {
font-size: 0.85rem;
color: var(--text-muted);
}
.form-field input {
padding: 0.65rem 0.85rem;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-family: inherit;
font-size: 1rem;
}
.form-field input:focus {
outline: none;
border-color: var(--accent);
}
.form-field input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-block {
width: 100%;
margin-top: 0.5rem;
}
.auth-footer {
margin: 1.25rem 0 0;
text-align: center;
color: var(--text-muted);
font-size: 0.95rem;
}
.auth-footer a {
color: var(--accent);
}
.auth-footer a:hover {
color: var(--accent-hover);
}
/* Account */
.account-section {
padding: 2.5rem 0 4rem;
}
.account-grid {
display: grid;
grid-template-columns: 260px 1fr;
gap: 2rem;
margin-top: 1.5rem;
}
.account-user-card {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.25rem;
}
.account-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent);
margin: 0 0 0.5rem;
}
.account-name {
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.25rem;
}
.account-email {
color: var(--text-muted);
font-size: 0.9rem;
margin: 0 0 0.75rem;
word-break: break-all;
}
.account-meta {
font-size: 0.82rem;
color: var(--text-muted);
margin: 0;
}
.account-nav {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-top: 1rem;
}
.account-nav-item {
display: block;
padding: 0.55rem 0.85rem;
border-radius: 8px;
font-size: 0.95rem;
color: var(--text-muted);
}
.account-nav-item.active {
background: var(--surface);
color: var(--text);
}
.account-nav-item:hover:not(.active) {
background: rgba(255, 255, 255, 0.04);
color: var(--text);
}
.account-main {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.account-card h2 {
margin: 0 0 1rem;
font-size: 1.15rem;
}
.account-hint h3 {
margin: 0 0 0.5rem;
}
.text-muted {
color: var(--text-muted);
font-size: 0.95rem;
} }
+46
View File
@@ -0,0 +1,46 @@
{{define "account.html"}}
{{template "layout" .}}
{{end}}
{{define "content"}}
<section class="section account-section">
<div class="container">
<h1 class="section-title">Личный кабинет</h1>
<div class="account-grid">
<aside class="account-sidebar">
<div class="account-user-card">
<p class="account-label">Аккаунт</p>
<p class="account-name">{{.User.Name}}</p>
<p class="account-email">{{.User.Email}}</p>
<p class="account-meta">С нами с {{.User.CreatedAt.Format "02.01.2006"}}</p>
</div>
<nav class="account-nav">
<span class="account-nav-item active">Профиль</span>
<a href="/#catalog" class="account-nav-item">Каталог</a>
</nav>
</aside>
<div class="account-main">
<div class="auth-card account-card">
<h2>Настройки профиля</h2>
<form method="POST" action="/account" class="auth-form">
<label class="form-field">
<span>Имя</span>
<input type="text" name="name" value="{{.Name}}" required minlength="2">
</label>
<label class="form-field">
<span>Email</span>
<input type="email" value="{{.User.Email}}" disabled>
</label>
<button type="submit" class="btn btn-primary">Сохранить</button>
</form>
</div>
<div class="account-hint auth-card">
<h3>Заказы</h3>
<p class="text-muted">История заказов появится в следующих версиях. Пока вы можете просматривать каталог и добавлять товары в корзину.</p>
<a href="/#catalog" class="btn btn-ghost">Перейти в каталог</a>
</div>
</div>
</div>
</div>
</section>
{{end}}
+16 -5
View File
@@ -15,16 +15,27 @@
<div class="container header-inner"> <div class="container header-inner">
<a href="/" class="logo">Shop<span>Nova</span></a> <a href="/" class="logo">Shop<span>Nova</span></a>
<nav class="nav"> <nav class="nav">
<a href="/" class="nav-link active">Главная</a> <a href="/" class="nav-link{{if eq .Nav "home"}} active{{end}}">Главная</a>
<a href="#catalog" class="nav-link">Каталог</a> <a href="/#catalog" class="nav-link">Каталог</a>
<a href="#categories" class="nav-link">Категории</a> {{if .User}}
<a href="/account" class="nav-link{{if eq .Nav "account"}} active{{end}}">Личный кабинет</a>
{{end}}
</nav> </nav>
<div class="header-actions"> <div class="header-actions">
<button type="button" class="btn btn-ghost" aria-label="Поиск">Поиск</button> {{if .User}}
<button type="button" class="btn btn-primary">Корзина</button> <span class="user-greeting">{{.User.Name}}</span>
<form method="POST" action="/logout" class="inline-form">
<button type="submit" class="btn btn-ghost">Выйти</button>
</form>
{{else}}
<a href="/login" class="btn btn-ghost">Вход</a>
<a href="/register" class="btn btn-primary">Регистрация</a>
{{end}}
</div> </div>
</div> </div>
</header> </header>
{{if .Success}}<div class="container"><p class="alert alert-success">{{.Success}}</p></div>{{end}}
{{if .Error}}<div class="container"><p class="alert alert-error">{{.Error}}</p></div>{{end}}
<main> <main>
{{template "content" .}} {{template "content" .}}
</main> </main>
+28
View File
@@ -0,0 +1,28 @@
{{define "login.html"}}
{{template "layout" .}}
{{end}}
{{define "content"}}
<section class="section auth-section">
<div class="container auth-container">
<div class="auth-card">
<h1 class="auth-title">Вход</h1>
<p class="auth-sub">Войдите в личный кабинет</p>
<form method="POST" action="/login" class="auth-form">
<input type="hidden" name="next" value="{{.Next}}">
<label class="form-field">
<span>Email</span>
<input type="email" name="email" value="{{.Email}}" required autocomplete="email">
</label>
<label class="form-field">
<span>Пароль</span>
<input type="password" name="password" required autocomplete="current-password">
</label>
<button type="submit" class="btn btn-primary btn-lg btn-block">Войти</button>
</form>
<p class="auth-footer">Нет аккаунта? <a href="/register">Регистрация</a></p>
</div>
</div>
</div>
</section>
{{end}}
+34
View File
@@ -0,0 +1,34 @@
{{define "register.html"}}
{{template "layout" .}}
{{end}}
{{define "content"}}
<section class="section auth-section">
<div class="container auth-container">
<div class="auth-card">
<h1 class="auth-title">Регистрация</h1>
<p class="auth-sub">Создайте аккаунт для заказов и личного кабинета</p>
<form method="POST" action="/register" class="auth-form">
<label class="form-field">
<span>Имя</span>
<input type="text" name="name" value="{{.Name}}" required minlength="2" autocomplete="name">
</label>
<label class="form-field">
<span>Email</span>
<input type="email" name="email" value="{{.Email}}" required autocomplete="email">
</label>
<label class="form-field">
<span>Пароль</span>
<input type="password" name="password" required minlength="8" autocomplete="new-password">
</label>
<label class="form-field">
<span>Повторите пароль</span>
<input type="password" name="password_confirm" required minlength="8" autocomplete="new-password">
</label>
<button type="submit" class="btn btn-primary btn-lg btn-block">Зарегистрироваться</button>
</form>
<p class="auth-footer">Уже есть аккаунт? <a href="/login">Войти</a></p>
</div>
</div>
</section>
{{end}}
+17
View File
@@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name VARCHAR(120) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS sessions (
id VARCHAR(64) PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions (expires_at);