Release v0.20: регистрация, авторизация, личный кабинет
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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`) — домен и база данных
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
const SessionCookieName = "shop_session"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
ID int
|
||||||
|
Email string
|
||||||
|
Name string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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}}
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user