Add user authentication and game persistence with SQLite
- User registration/login with bcrypt password hashing - SQLite database with goose migrations and sqlc-generated queries - Games and players persisted to database, resumable after restart - Guest play still supported alongside authenticated users - Auth UI components (login/register forms, auth header, guest banner)
This commit is contained in:
179
main.go
179
main.go
@@ -1,6 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/ryanhamamura/c4/auth"
|
||||
"github.com/ryanhamamura/c4/db"
|
||||
"github.com/ryanhamamura/c4/db/gen"
|
||||
"github.com/ryanhamamura/c4/game"
|
||||
"github.com/ryanhamamura/c4/ui"
|
||||
"github.com/ryanhamamura/via"
|
||||
@@ -8,8 +16,15 @@ import (
|
||||
)
|
||||
|
||||
var store = game.NewGameStore()
|
||||
var queries *gen.Queries
|
||||
|
||||
func main() {
|
||||
if err := db.Init("c4.db"); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
queries = gen.New(db.DB)
|
||||
store.SetPersister(db.NewGamePersister(queries))
|
||||
|
||||
v := via.New()
|
||||
v.Config(via.Options{
|
||||
LogLvl: via.LogLevelDebug,
|
||||
@@ -24,7 +39,19 @@ func main() {
|
||||
|
||||
// Home page - enter nickname and create game
|
||||
v.Page("/", func(c *via.Context) {
|
||||
userID := c.Session().GetString("user_id")
|
||||
username := c.Session().GetString("username")
|
||||
isLoggedIn := userID != ""
|
||||
|
||||
nickname := c.Signal("")
|
||||
if isLoggedIn {
|
||||
nickname = c.Signal(username)
|
||||
}
|
||||
|
||||
logout := c.Action(func() {
|
||||
c.Session().Clear()
|
||||
c.Redirect("/")
|
||||
})
|
||||
|
||||
createGame := c.Action(func() {
|
||||
name := nickname.String()
|
||||
@@ -42,6 +69,113 @@ func main() {
|
||||
nickname.Bind(),
|
||||
createGame.OnKeyDown("Enter"),
|
||||
createGame.OnClick(),
|
||||
isLoggedIn,
|
||||
username,
|
||||
logout.OnClick(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Login page
|
||||
v.Page("/login", func(c *via.Context) {
|
||||
username := c.Signal("")
|
||||
password := c.Signal("")
|
||||
errorMsg := c.Signal("")
|
||||
|
||||
login := c.Action(func() {
|
||||
ctx := context.Background()
|
||||
user, err := queries.GetUserByUsername(ctx, username.String())
|
||||
if err == sql.ErrNoRows {
|
||||
errorMsg.SetValue("Invalid username or password")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
errorMsg.SetValue("An error occurred")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if !auth.CheckPassword(password.String(), user.PasswordHash) {
|
||||
errorMsg.SetValue("Invalid username or password")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
c.Session().Set("user_id", user.ID)
|
||||
c.Session().Set("username", user.Username)
|
||||
c.Session().Set("nickname", user.Username)
|
||||
c.Redirect("/")
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.LoginView(
|
||||
username.Bind(),
|
||||
password.Bind(),
|
||||
login.OnKeyDown("Enter"),
|
||||
login.OnClick(),
|
||||
errorMsg.String(),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// Register page
|
||||
v.Page("/register", func(c *via.Context) {
|
||||
username := c.Signal("")
|
||||
password := c.Signal("")
|
||||
confirm := c.Signal("")
|
||||
errorMsg := c.Signal("")
|
||||
|
||||
register := c.Action(func() {
|
||||
if err := auth.ValidateUsername(username.String()); err != nil {
|
||||
errorMsg.SetValue(err.Error())
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if err := auth.ValidatePassword(password.String()); err != nil {
|
||||
errorMsg.SetValue(err.Error())
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
if password.String() != confirm.String() {
|
||||
errorMsg.SetValue("Passwords do not match")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(password.String())
|
||||
if err != nil {
|
||||
errorMsg.SetValue("An error occurred")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
id := uuid.New().String()
|
||||
user, err := queries.CreateUser(ctx, gen.CreateUserParams{
|
||||
ID: id,
|
||||
Username: username.String(),
|
||||
PasswordHash: hash,
|
||||
})
|
||||
if err != nil {
|
||||
errorMsg.SetValue("Username already taken")
|
||||
c.Sync()
|
||||
return
|
||||
}
|
||||
|
||||
c.Session().Set("user_id", user.ID)
|
||||
c.Session().Set("username", user.Username)
|
||||
c.Session().Set("nickname", user.Username)
|
||||
c.Redirect("/")
|
||||
})
|
||||
|
||||
c.View(func() h.H {
|
||||
return ui.RegisterView(
|
||||
username.Bind(),
|
||||
password.Bind(),
|
||||
confirm.Bind(),
|
||||
register.OnKeyDown("Enter"),
|
||||
register.OnClick(),
|
||||
errorMsg.String(),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -50,6 +184,7 @@ func main() {
|
||||
v.Page("/game/{game_id}", func(c *via.Context) {
|
||||
gameID := c.GetPathParam("game_id")
|
||||
sessionNickname := c.Session().GetString("nickname")
|
||||
sessionUserID := c.Session().GetString("user_id")
|
||||
|
||||
nickname := c.Signal(sessionNickname)
|
||||
colSignal := c.Signal(0)
|
||||
@@ -69,6 +204,11 @@ func main() {
|
||||
c.Session().Set("player_id", string(playerID))
|
||||
}
|
||||
|
||||
// Use user_id as player_id if logged in
|
||||
if sessionUserID != "" {
|
||||
playerID = game.PlayerID(sessionUserID)
|
||||
}
|
||||
|
||||
setNickname := c.Action(func() {
|
||||
if gi == nil {
|
||||
return
|
||||
@@ -85,6 +225,9 @@ func main() {
|
||||
ID: playerID,
|
||||
Nickname: name,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
gi.Join(&game.PlayerSession{
|
||||
Player: player,
|
||||
Sync: c,
|
||||
@@ -112,6 +255,9 @@ func main() {
|
||||
ID: playerID,
|
||||
Nickname: sessionNickname,
|
||||
}
|
||||
if sessionUserID != "" {
|
||||
player.UserID = &sessionUserID
|
||||
}
|
||||
gi.Join(&game.PlayerSession{
|
||||
Player: player,
|
||||
Sync: c,
|
||||
@@ -310,4 +456,37 @@ const gameCSS = `
|
||||
.copy-btn {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.auth-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--pico-muted-background);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.auth-header button {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.guest-banner {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--pico-muted-background);
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc2626;
|
||||
background: #fef2f2;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
`
|
||||
|
||||
Reference in New Issue
Block a user