Initial fullstack project setup with Next.js 15, Gin, PostgreSQL and Docker Compose

- Frontend: Next.js 15 (App Router), Auth.js v5, shadcn/ui, MagicUI
- Backend: Go + Gin + GORM with layered architecture
- Auth: Local credentials login with optional Keycloak OAuth binding
- Admin: RBAC user management for admin role
- Dev: Docker Compose with hot reload for both frontend and backend
- Docker: 3-service orchestration (frontend, backend, postgres)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-16 15:11:20 +00:00
commit b0b85f4d3a
62 changed files with 12113 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
package config
import (
"os"
"github.com/joho/godotenv"
)
type Config struct {
DatabaseURL string
ServerPort string
ServerAPIURL string
AuthSecret string
KeycloakIssuer string
KeycloakID string
KeycloakSecret string
}
func Load() *Config {
_ = godotenv.Load()
return &Config{
DatabaseURL: getEnv("DATABASE_URL", "postgres://evan:evanpass@localhost:5432/evanpage?sslmode=disable"),
ServerPort: getEnv("SERVER_PORT", "8080"),
ServerAPIURL: getEnv("SERVER_API_URL", "http://localhost:8080"),
AuthSecret: getEnv("AUTH_SECRET", ""),
KeycloakIssuer: getEnv("AUTH_KEYCLOAK_ISSUER", ""),
KeycloakID: getEnv("AUTH_KEYCLOAK_ID", ""),
KeycloakSecret: getEnv("AUTH_KEYCLOAK_SECRET", ""),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

27
backend/internal/db/db.go Normal file
View File

@@ -0,0 +1,27 @@
package db
import (
"log"
"evanpage-backend/internal/config"
"evanpage-backend/internal/domain"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
var DB *gorm.DB
func Init(cfg *config.Config) *gorm.DB {
var err error
DB, err = gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{})
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
if err := DB.AutoMigrate(&domain.User{}); err != nil {
log.Fatalf("failed to migrate database: %v", err)
}
log.Println("database connected and migrated")
return DB
}

View File

@@ -0,0 +1,15 @@
package domain
import "time"
type User struct {
ID uint `gorm:"primarykey" json:"id"`
Username string `gorm:"uniqueIndex;not null" json:"username"`
Email string `gorm:"uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"`
Role string `gorm:"default:'user';not null" json:"role"`
KeycloakID *string `gorm:"uniqueIndex" json:"keycloakId,omitempty"`
KeycloakEmail string `json:"keycloakEmail,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@@ -0,0 +1,68 @@
package handler
import (
"net/http"
"strconv"
"evanpage-backend/internal/service"
"github.com/gin-gonic/gin"
)
type AdminHandler struct {
userService *service.UserService
}
func NewAdminHandler(userService *service.UserService) *AdminHandler {
return &AdminHandler{userService: userService}
}
func (h *AdminHandler) ListUsers(c *gin.Context) {
users, err := h.userService.ListUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"users": users})
}
func (h *AdminHandler) CreateUser(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
Role string `json:"role" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.userService.Register(req.Username, req.Email, req.Password, req.Role)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
})
}
func (h *AdminHandler) DeleteUser(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
return
}
if err := h.userService.DeleteUser(uint(id)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "user deleted"})
}

View File

@@ -0,0 +1,156 @@
package handler
import (
"net/http"
"evanpage-backend/internal/service"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
userService *service.UserService
}
func NewAuthHandler(userService *service.UserService) *AuthHandler {
return &AuthHandler{userService: userService}
}
func (h *AuthHandler) Register(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.userService.Register(req.Username, req.Email, req.Password, "user")
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
})
}
func (h *AuthHandler) LocalLogin(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.userService.ValidateLocalLogin(req.Username, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
})
}
func (h *AuthHandler) LookupBinding(c *gin.Context) {
var req struct {
KeycloakID string `json:"keycloakId" binding:"required"`
Email string `json:"email"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.userService.LookupKeycloakBinding(req.KeycloakID)
if err != nil {
c.JSON(http.StatusOK, gin.H{"bound": false})
return
}
c.JSON(http.StatusOK, gin.H{
"bound": true,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
},
})
}
func (h *AuthHandler) BindKeycloak(c *gin.Context) {
var req struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
KeycloakID string `json:"keycloakId" binding:"required"`
KeycloakEmail string `json:"keycloakEmail"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.userService.BindKeycloak(req.Username, req.Password, req.KeycloakID, req.KeycloakEmail)
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"bound": true,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
},
})
}
func (h *AuthHandler) InitAdmin(c *gin.Context) {
count, err := h.userService.CountUsers()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"})
return
}
if count > 0 {
c.JSON(http.StatusForbidden, gin.H{"error": "already initialized"})
return
}
var req struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.userService.Register(req.Username, req.Email, req.Password, "admin")
if err != nil {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"role": user.Role,
})
}

View File

@@ -0,0 +1,32 @@
package handler
import (
"net/http"
"time"
"gorm.io/gorm"
"github.com/gin-gonic/gin"
)
type HealthHandler struct {
db *gorm.DB
}
func NewHealthHandler(db *gorm.DB) *HealthHandler {
return &HealthHandler{db: db}
}
func (h *HealthHandler) Check(c *gin.Context) {
sqlDB, err := h.db.DB()
if err != nil {
c.String(http.StatusInternalServerError, "DB connection error: "+err.Error())
return
}
if err := sqlDB.Ping(); err != nil {
c.String(http.StatusInternalServerError, "DB unreachable: "+err.Error())
return
}
c.String(http.StatusOK, "Database connected. Server time: %s", time.Now().Format(time.RFC3339))
}

View File

@@ -0,0 +1,23 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
func AuthProxy() gin.HandlerFunc {
return func(c *gin.Context) {
userID := c.GetHeader("X-User-Id")
userRole := c.GetHeader("X-User-Role")
if userID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}
c.Set("userID", userID)
c.Set("userRole", userRole)
c.Next()
}
}

View File

@@ -0,0 +1,33 @@
package middleware
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
)
func CORS() gin.HandlerFunc {
return func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-User-Id, X-User-Role")
c.Writer.Header().Set("Access-Control-Max-Age", "86400")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
}
func Logger() gin.HandlerFunc {
return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string {
return param.TimeStamp.Format(time.RFC3339) + " " +
param.Method + " " + param.Path + " " +
param.ClientIP + " " + strconv.Itoa(param.StatusCode) + " " +
param.Latency.String() + "\n"
})
}

View File

@@ -0,0 +1,18 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
)
func RequireRole(role string) gin.HandlerFunc {
return func(c *gin.Context) {
userRole := c.GetString("userRole")
if userRole != role {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "forbidden"})
return
}
c.Next()
}
}

View File

@@ -0,0 +1,62 @@
package repository
import (
"evanpage-backend/internal/domain"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(user *domain.User) error {
return r.db.Create(user).Error
}
func (r *UserRepository) FindByID(id uint) (*domain.User, error) {
var user domain.User
err := r.db.First(&user, id).Error
return &user, err
}
func (r *UserRepository) FindByUsername(username string) (*domain.User, error) {
var user domain.User
err := r.db.Where("username = ?", username).First(&user).Error
return &user, err
}
func (r *UserRepository) FindByEmail(email string) (*domain.User, error) {
var user domain.User
err := r.db.Where("email = ?", email).First(&user).Error
return &user, err
}
func (r *UserRepository) FindByKeycloakID(keycloakID string) (*domain.User, error) {
var user domain.User
err := r.db.Where("keycloak_id = ?", keycloakID).First(&user).Error
return &user, err
}
func (r *UserRepository) ListAll() ([]domain.User, error) {
var users []domain.User
err := r.db.Select("id", "username", "email", "role", "keycloak_id", "created_at", "updated_at").Find(&users).Error
return users, err
}
func (r *UserRepository) Update(user *domain.User) error {
return r.db.Save(user).Error
}
func (r *UserRepository) Delete(id uint) error {
return r.db.Delete(&domain.User{}, id).Error
}
func (r *UserRepository) Count() (int64, error) {
var count int64
err := r.db.Model(&domain.User{}).Count(&count).Error
return count, err
}

View File

@@ -0,0 +1,51 @@
package router
import (
"evanpage-backend/internal/config"
"evanpage-backend/internal/db"
"evanpage-backend/internal/handler"
"evanpage-backend/internal/middleware"
"evanpage-backend/internal/repository"
"evanpage-backend/internal/service"
"github.com/gin-gonic/gin"
)
func Setup(cfg *config.Config) *gin.Engine {
r := gin.New()
r.Use(middleware.Logger())
r.Use(middleware.CORS())
r.Use(gin.Recovery())
userRepo := repository.NewUserRepository(db.DB)
userService := service.NewUserService(userRepo)
authHandler := handler.NewAuthHandler(userService)
healthHandler := handler.NewHealthHandler(db.DB)
adminHandler := handler.NewAdminHandler(userService)
// Public routes
r.POST("/api/auth/register", authHandler.Register)
r.POST("/api/auth/local-login", authHandler.LocalLogin)
r.POST("/api/auth/lookup-binding", authHandler.LookupBinding)
r.POST("/api/auth/bind-keycloak", authHandler.BindKeycloak)
r.POST("/api/auth/init", authHandler.InitAdmin)
r.GET("/api/health", healthHandler.Check)
// Protected routes
api := r.Group("/api")
api.Use(middleware.AuthProxy())
{
}
// Admin routes
admin := api.Group("/admin")
admin.Use(middleware.RequireRole("admin"))
{
admin.GET("/users", adminHandler.ListUsers)
admin.POST("/users", adminHandler.CreateUser)
admin.DELETE("/users/:id", adminHandler.DeleteUser)
}
return r
}

View File

@@ -0,0 +1,106 @@
package service
import (
"errors"
"evanpage-backend/internal/domain"
"evanpage-backend/internal/repository"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type UserService struct {
repo *repository.UserRepository
}
func NewUserService(repo *repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) Register(username, email, password, role string) (*domain.User, error) {
_, err := s.repo.FindByUsername(username)
if err == nil {
return nil, errors.New("username already exists")
}
_, err = s.repo.FindByEmail(email)
if err == nil {
return nil, errors.New("email already exists")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
if role == "" {
role = "user"
}
user := &domain.User{
Username: username,
Email: email,
PasswordHash: string(hash),
Role: role,
}
if err := s.repo.Create(user); err != nil {
return nil, err
}
return user, nil
}
func (s *UserService) ValidateLocalLogin(username, password string) (*domain.User, error) {
user, err := s.repo.FindByUsername(username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.New("invalid credentials")
}
return nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, errors.New("invalid credentials")
}
return user, nil
}
func (s *UserService) LookupKeycloakBinding(keycloakID string) (*domain.User, error) {
return s.repo.FindByKeycloakID(keycloakID)
}
func (s *UserService) BindKeycloak(username, password, keycloakID, keycloakEmail string) (*domain.User, error) {
user, err := s.ValidateLocalLogin(username, password)
if err != nil {
return nil, err
}
if user.KeycloakID != nil && *user.KeycloakID != "" && *user.KeycloakID != keycloakID {
return nil, errors.New("user already bound to another keycloak account")
}
existing, err := s.repo.FindByKeycloakID(keycloakID)
if err == nil && existing.ID != user.ID {
return nil, errors.New("keycloak account already bound to another user")
}
user.KeycloakID = &keycloakID
user.KeycloakEmail = keycloakEmail
if err := s.repo.Update(user); err != nil {
return nil, err
}
return user, nil
}
func (s *UserService) ListUsers() ([]domain.User, error) {
return s.repo.ListAll()
}
func (s *UserService) DeleteUser(id uint) error {
return s.repo.Delete(id)
}
func (s *UserService) CountUsers() (int64, error) {
return s.repo.Count()
}