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:
Ryan Hamamura
2026-01-14 16:59:40 -10:00
parent 03dcfdbf85
commit b264d8990b
18 changed files with 1121 additions and 5 deletions

179
main.go
View File

@@ -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;
}
`