Building a Task Manager with Varel - Authentication & Sessions Tutorial

Tutorial Focus: Testing Varel's authentication and session management capabilities Complexity: Beginner Duration: 30-45 minutes What You'll Build: A simple task manager with user registration, login/logout, and user-specific task lists

📚 Note: This tutorial demonstrates Varel's template system. For comprehensive template best practices, see 07_templates.md which covers the content-only pattern, common pitfalls, and advanced techniques.

Why This Tutorial?

This tutorial is designed to thoroughly test Varel's authentication and session middleware. Unlike complex applications, we focus exclusively on:

  • ✅ User registration with bcrypt password hashing
  • ✅ Login/logout flows with session management
  • ✅ PostgreSQL-backed session storage
  • ✅ Protected routes (require authentication)
  • ✅ User-specific data isolation (each user sees only their tasks)

What You'll Learn

  1. How to use Varel's session middleware
  2. How to implement user registration and authentication
  3. How to protect routes with authentication middleware
  4. How to isolate user data with database queries
  5. How Varel's PostgreSQL sessions work in practice
  6. How to create properly structured VeeMarker templates (content-only pattern)

Part 1: Project Setup

Step 1.1: Create the Project

cd /home/ctusa/varel-apps
varel new taskman
cd taskman

Step 1.2: Configure Database

Edit config/config.toml:

[database]
host = "192.168.168.50"
port = 5432
database = "taskman_dev"
user = "vareltest"
password = "test123"
sslmode = "disable"
connect_timeout = 30

Step 1.3: Create Database

cd taskman
varel db create

Step 1.4: Create Users Table Migration

varel generate migration create_users

This will create two files:

  • db/migrations/YYYYMMDDHHMMSS_create_users.up.sql (apply migration)
  • db/migrations/YYYYMMDDHHMMSS_create_users.down.sql (rollback migration)

Edit db/migrations/YYYYMMDDHHMMSS_create_users.up.sql:

-- Create users table
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    username VARCHAR(50) UNIQUE NOT NULL,
    email VARCHAR(255) UNIQUE NOT NULL,
    password_hash VARCHAR(255) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);

Edit db/migrations/YYYYMMDDHHMMSS_create_users.down.sql:

DROP TABLE IF EXISTS users CASCADE;

Step 1.5: Create Sessions Table

varel generate migration create_sessions

Edit db/migrations/YYYYMMDDHHMMSS_create_sessions.up.sql:

-- Create sessions table for Varel v0.3.0+ (PostgreSQL JSONB format)
CREATE TABLE sessions (
    id VARCHAR(64) PRIMARY KEY,
    data JSONB NOT NULL DEFAULT '{}'::jsonb,
    user_id INTEGER,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_sessions_expires ON sessions(expires_at);
CREATE INDEX idx_sessions_data ON sessions USING GIN (data);
CREATE INDEX idx_sessions_user_id ON sessions(user_id);

Edit db/migrations/YYYYMMDDHHMMSS_create_sessions.down.sql:

DROP TABLE IF EXISTS sessions CASCADE;

Step 1.6: Run Migrations

varel db migrate

Check migration status:

varel db status

You should see all migrations applied with timestamps.


Part 2: User Registration and Login

Step 2.1: Create User Model

Create models/user.v:

module models

import time

pub struct User {
pub:
    id            int       [primary; sql: serial]
    username      string    [unique; sql: 'VARCHAR(50)']
    email         string    [unique; sql: 'VARCHAR(255)']
    password_hash string    [sql: 'VARCHAR(255)']
    created_at    time.Time [sql: 'TIMESTAMP'; default: 'CURRENT_TIMESTAMP']
}

Step 2.2: Create Auth Controller

Create controllers/auth.v:

module controllers

import leafscale.varel.http
import leafscale.varel.vareldb
import crypto.bcrypt

pub struct AuthController {}

// register - Show registration form
pub fn (c AuthController) register(mut ctx http.Context) http.Response {
    return ctx.render('auth/register.vtpl', {
        'page_title': 'Register'
        'error': ''
    })
}

// register_post - Process registration
pub fn (c AuthController) register_post(mut ctx http.Context) http.Response {
    username := ctx.form('username')
    email := ctx.form('email')
    password := ctx.form('password')

    // Basic validation
    if username.len < 3 || password.len < 6 {
        return ctx.render('auth/register.vtpl', {
            'page_title': 'Register'
            'error': 'Username must be 3+ characters, password must be 6+ characters'
        })
    }

    // Hash password
    password_hash := bcrypt.generate_from_password(password.bytes(), 12) or {
        eprintln('[REGISTER] Bcrypt failed: ${err}')
        return ctx.render('auth/register.vtpl', {
            'page_title': 'Register'
            'error': 'Registration failed. Please try again.'
        })
    }

    // Insert user
    mut db := ctx.db!
    db.exec_params('INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3)',
        [username, email, password_hash]) or {
        eprintln('[REGISTER] DB insert failed: ${err}')
        return ctx.render('auth/register.vtpl', {
            'page_title': 'Register'
            'error': 'Username or email already exists'
        })
    }

    // Redirect to login
    return ctx.redirect('/login', 302)
}

// login - Show login form
pub fn (c AuthController) login(mut ctx http.Context) http.Response {
    return ctx.render('auth/login.vtpl', {
        'page_title': 'Login'
        'error': ''
    })
}

// login_post - Process login
pub fn (c AuthController) login_post(mut ctx http.Context) http.Response {
    username := ctx.form('username')
    password := ctx.form('password')

    eprintln('[AUTH] Attempting login for username="${username}"')

    // Find user by username
    mut db := ctx.db!
    rows := db.exec_params('SELECT id, username, password_hash FROM users WHERE username = $1', [username]) or {
        eprintln('[AUTH] DB query failed: ${err}')
        return ctx.render('auth/login.vtpl', {
            'page_title': 'Login'
            'error': 'Invalid username or password'
        })
    }

    if rows.len == 0 {
        eprintln('[AUTH] User not found: ${username}')
        return ctx.render('auth/login.vtpl', {
            'page_title': 'Login'
            'error': 'Invalid username or password'
        })
    }

    // Get user data
    row := rows[0]
    user_id := vareldb.to_int(row.vals[0]) or { 0 }
    db_username := vareldb.to_string(row.vals[1], '')
    password_hash := vareldb.to_string(row.vals[2], '')

    eprintln('[AUTH] Found user: id=${user_id}, username="${db_username}"')
    eprintln('[AUTH] Hash length: ${password_hash.len}')
    eprintln('[AUTH] Comparing password...')

    // Verify password
    bcrypt.compare_hash_and_password(password.bytes(), password_hash.bytes()) or {
        eprintln('[AUTH] Password verification FAILED: ${err}')
        return ctx.render('auth/login.vtpl', {
            'page_title': 'Login'
            'error': 'Invalid username or password'
        })
    }

    eprintln('[AUTH] Password verification SUCCESS!')
    eprintln('[AUTH] Setting session...')

    // Store user in session
    ctx.session_set('user_id', user_id.str())
    ctx.session_set('username', db_username)

    eprintln('[AUTH] Session set, redirecting to /tasks')

    // Redirect to tasks page
    return ctx.redirect('/tasks', 302)
}

// logout - Clear session and redirect
pub fn (c AuthController) logout(mut ctx http.Context) http.Response {
    ctx.session_clear()
    return ctx.redirect('/', 302)
}

Step 2.3: Create Auth Views

⚠️ Template Best Practice: Varel uses a base layout system. Your templates should be content-only (no <!DOCTYPE>, <html>, <head>, or <body> tags). The base layout wraps your content automatically. Including full HTML structure creates nested HTML and breaks rendering. See 07_templates.md for details.

Create views/layouts/base.vtpl (if not already exists):

<!DOCTYPE html>
<html>
<head>
    <title>${page_title!'Varel App'}</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
    ${content}
</body>
</html>

Create views/auth/register.vtpl (content-only):

<style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        min-height: 100vh;
        display: flex;
        align-items: center;
        justify-content: center;
        padding: 20px;
    }
    .container {
        background: white;
        padding: 40px;
        border-radius: 12px;
        box-shadow: 0 10px 40px rgba(0,0,0,0.2);
        width: 100%;
        max-width: 400px;
    }
    h1 {
        color: #333;
        margin-bottom: 10px;
        font-size: 28px;
    }
    .subtitle {
        color: #666;
        margin-bottom: 30px;
        font-size: 14px;
    }
    .error {
        background: #fee;
        color: #c33;
        padding: 12px;
        border-radius: 6px;
        margin-bottom: 20px;
        font-size: 14px;
    }
    .form-group {
        margin-bottom: 20px;
    }
    label {
        display: block;
        margin-bottom: 8px;
        color: #333;
        font-weight: 500;
        font-size: 14px;
    }
    input {
        width: 100%;
        padding: 12px;
        border: 2px solid #ddd;
        border-radius: 6px;
        font-size: 14px;
        transition: border-color 0.3s;
    }
    input:focus {
        outline: none;
        border-color: #667eea;
    }
    button {
        width: 100%;
        padding: 14px;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        color: white;
        border: none;
        border-radius: 6px;
        font-size: 16px;
        font-weight: 600;
        cursor: pointer;
        transition: transform 0.2s;
    }
    button:hover {
        transform: translateY(-2px);
    }
    .links {
        text-align: center;
        margin-top: 20px;
        font-size: 14px;
    }
    .links a {
        color: #667eea;
        text-decoration: none;
    }
    .links a:hover {
        text-decoration: underline;
    }
</style>

<div class="container">
    <h1>Register</h1>
    <p class="subtitle">Create your account to get started</p>

    <#if error?has_content>
    <div class="error">${error}</div>
    </#if>

    <form method="POST" action="/register">
        <div class="form-group">
            <label for="username">Username</label>
            <input type="text" id="username" name="username" required minlength="3">
        </div>

        <div class="form-group">
            <label for="email">Email</label>
            <input type="email" id="email" name="email" required>
        </div>

        <div class="form-group">
            <label for="password">Password</label>
            <input type="password" id="password" name="password" required minlength="6">
        </div>

        <button type="submit">Create Account</button>
    </form>

    <div class="links">
        Already have an account? <a href="/login">Login</a>
    </div>
</div>

Create views/auth/login.vtpl (content-only):

<style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            padding: 20px;
        }
        .container {
            background: white;
            padding: 40px;
            border-radius: 12px;
            box-shadow: 0 10px 40px rgba(0,0,0,0.2);
            width: 100%;
            max-width: 400px;
        }
        h1 {
            color: #333;
            margin-bottom: 10px;
            font-size: 28px;
        }
        .subtitle {
            color: #666;
            margin-bottom: 30px;
            font-size: 14px;
        }
        .error {
            background: #fee;
            color: #c33;
            padding: 12px;
            border-radius: 6px;
            margin-bottom: 20px;
            font-size: 14px;
        }
        .form-group {
            margin-bottom: 20px;
        }
        label {
            display: block;
            margin-bottom: 8px;
            color: #333;
            font-weight: 500;
            font-size: 14px;
        }
        input {
            width: 100%;
            padding: 12px;
            border: 2px solid #ddd;
            border-radius: 6px;
            font-size: 14px;
            transition: border-color 0.3s;
        }
        input:focus {
            outline: none;
            border-color: #667eea;
        }
        button {
            width: 100%;
            padding: 14px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            border-radius: 6px;
            font-size: 16px;
            font-weight: 600;
            cursor: pointer;
            transition: transform 0.2s;
        }
        button:hover {
            transform: translateY(-2px);
        }
        .links {
            text-align: center;
            margin-top: 20px;
            font-size: 14px;
        }
        .links a {
            color: #667eea;
            text-decoration: none;
        }
        .links a:hover {
            text-decoration: underline;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>Login</h1>
        <p class="subtitle">Welcome back! Please login to continue</p>

        <#if error?has_content>
        <div class="error">${error}</div>
        </#if>

        <form method="POST" action="/login">
            <div class="form-group">
                <label for="username">Username</label>
                <input type="text" id="username" name="username" required>
            </div>

            <div class="form-group">
                <label for="password">Password</label>
                <input type="password" id="password" name="password" required>
            </div>

            <button type="submit">Login</button>
        </form>

        <div class="links">
            Don't have an account? <a href="/register">Register</a>
        </div>
    </div>

Step 2.4: Create Home Page

Create views/home/index.vtpl (content-only, Task Manager branded):

<style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
        background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
        min-height: 100vh;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
        padding: 20px;
        color: white;
        text-align: center;
    }
    h1 {
        font-size: 48px;
        margin-bottom: 20px;
        text-shadow: 0 2px 10px rgba(0,0,0,0.2);
    }
    .subtitle {
        font-size: 20px;
        margin-bottom: 40px;
        opacity: 0.9;
    }
    .buttons {
        display: flex;
        gap: 20px;
    }
    .btn {
        padding: 16px 32px;
        background: white;
        color: #667eea;
        text-decoration: none;
        border-radius: 8px;
        font-weight: 600;
        font-size: 16px;
        transition: transform 0.2s, box-shadow 0.2s;
        box-shadow: 0 4px 15px rgba(0,0,0,0.2);
    }
    .btn:hover {
        transform: translateY(-2px);
        box-shadow: 0 6px 20px rgba(0,0,0,0.3);
    }
    .btn-secondary {
        background: rgba(255,255,255,0.2);
        color: white;
        border: 2px solid white;
    }
    .features {
        margin-top: 60px;
        display: grid;
        grid-template-columns: repeat(3, 1fr);
        gap: 30px;
        max-width: 800px;
    }
    .feature {
        background: rgba(255,255,255,0.1);
        padding: 25px;
        border-radius: 12px;
        backdrop-filter: blur(10px);
    }
    .feature h3 {
        margin-bottom: 10px;
        font-size: 20px;
    }
    .feature p {
        font-size: 14px;
        opacity: 0.9;
    }
</style>

<h1>📝 Task Manager</h1>
<p class="subtitle">Keep track of your tasks, simple and secure</p>
<div class="buttons">
    <a href="/register" class="btn">Get Started</a>
    <a href="/login" class="btn btn-secondary">Login</a>
</div>

<div class="features">
    <div class="feature">
        <h3>🔒 Secure</h3>
        <p>Your data is protected with industry-standard encryption</p>
    </div>
    <div class="feature">
        <h3>⚡ Fast</h3>
        <p>Built with V lang for maximum performance</p>
    </div>
    <div class="feature">
        <h3>✨ Simple</h3>
        <p>Clean interface, no distractions, just tasks</p>
    </div>
</div>

Step 2.5: Update Routes and Controllers in main.v

Edit main.v to use views/home/index.vtpl:

module main

import leafscale.varel
import leafscale.varel.middleware
import leafscale.varel.session
import controllers

fn main() {
    mut app := varel.new()

    // Load configuration
    app.load_config('config/config.toml')!

    // Setup templates
    app.templates(root: 'views')!

    // Setup session middleware with PostgreSQL backend
    mut session_mw := middleware.new_session_middleware(session.Config{
        cookie_name: 'taskman_session'
        max_age: 7 * 24 * 3600 // 7 days
        secure: false // Set to true in production with HTTPS
        http_only: true
        same_site: .lax
        db_conn: unsafe { voidptr(&app.db) }
    })!

    app.use_middleware(session_mw)

    // Setup logger
    mut logger := middleware.new_logger_middleware(middleware.LoggerConfig{
        format: 'dev'
    })
    app.use_middleware(logger)

    // Initialize controllers
    auth := controllers.AuthController{}
    home := controllers.HomeController{}

    // Public routes
    app.get('/', home.index)

    // Auth routes
    app.get('/register', auth.register)
    app.post('/register', auth.register_post)
    app.get('/login', auth.login)
    app.post('/login', auth.login_post)
    app.get('/logout', auth.logout)

    // Start server
    println('Task Manager starting on http://localhost:8080')
    app.listen(8080)!
}

Create controllers/home.v:

module controllers

import leafscale.varel.http

pub struct HomeController {}

pub fn (c HomeController) index(mut ctx http.Context) http.Response {
    return ctx.render('home/index.vtpl', {
        'page_title': 'Task Manager'
    })
}

Step 2.6: Test Registration and Login

Build and run the application:

v .
./taskman

Visit http://localhost:8080 in your browser:

  1. Click "Get Started" → fills in registration form
  2. Create account with:
    • Username: testuser
    • Email: test@example.com
    • Password: password123
  3. Should redirect to login page
  4. Login with testuser / password123
  5. Should redirect to /tasks (we'll create this next)

✅ Testing Checkpoint: At this point, we should verify:

  • Registration creates user in database with bcrypt hash
  • Login finds user and verifies password
  • Session is created and stored in PostgreSQL
  • Session cookie is set in browser

Check the database:

# Verify user was created
PGPASSWORD='test123' psql -h 192.168.168.50 -U vareltest -d taskman_dev -c "SELECT id, username, email, LEFT(password_hash, 20) FROM users;"

# Verify session was created after login
PGPASSWORD='test123' psql -h 192.168.168.50 -U vareltest -d taskman_dev -c "SELECT id, user_id, created_at, expires_at FROM sessions;"

Part 3: Protected Routes and Task Management

Step 3.1: Create Tasks Table

varel generate migration create_tasks

Edit db/migrations/YYYYMMDDHHMMSS_create_tasks.up.sql:

-- Create tasks table
CREATE TABLE tasks (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title VARCHAR(255) NOT NULL,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_tasks_user_id ON tasks(user_id);
CREATE INDEX idx_tasks_completed ON tasks(completed);

Edit db/migrations/YYYYMMDDHHMMSS_create_tasks.down.sql:

DROP TABLE IF EXISTS tasks CASCADE;

Run migration:

varel db migrate

Step 3.2: Create Task Model

Create models/task.v:

module models

import time

pub struct Task {
pub:
    id         int       [primary; sql: serial]
    user_id    int       [sql: 'INTEGER']
    title      string    [sql: 'VARCHAR(255)']
    completed  bool      [sql: 'BOOLEAN'; default: 'FALSE']
    created_at time.Time [sql: 'TIMESTAMP'; default: 'CURRENT_TIMESTAMP']
    updated_at time.Time [sql: 'TIMESTAMP'; default: 'CURRENT_TIMESTAMP']
}

Step 3.3: Create Tasks Controller

💡 Database Tip: When working with PostgreSQL timestamps, use vareldb.strip_pg_microseconds() helper to remove microseconds before parsing with time.parse(). PostgreSQL returns timestamps like "2024-01-15 10:30:45.123456" but V's time.parse() expects "2024-01-15 10:30:45".

Create controllers/tasks.v:

module controllers

import leafscale.varel.http
import leafscale.varel.vareldb
import leafscale.veemarker as vm
import time
import models

pub struct TasksController {}

// Helper function to check authentication
fn require_auth(mut ctx http.Context) !int {
    user_id_str := ctx.session_get('user_id') or {
        return error('Not authenticated')
    }

    return user_id_str.int()
}

// index - Show all tasks for logged-in user
pub fn (c TasksController) index(mut ctx http.Context) http.Response {
    user_id := require_auth(mut ctx) or {
        return ctx.redirect('/login', 302)
    }

    username := ctx.session_get('username') or { 'User' }

    // Get all tasks for this user
    mut db := ctx.db!
    rows := db.exec_params('SELECT id, user_id, title, completed, created_at, updated_at FROM tasks WHERE user_id = $1 ORDER BY created_at DESC',
        [user_id.str()]) or {
        eprintln('[TASKS] Failed to fetch tasks: ${err}')
        return ctx.render_data('tasks/index.vtpl', {
            'page_title': vm.Any('My Tasks')
            'username': vm.Any(username)
            'tasks': vm.Any([]vm.Any{})
        })
    }

    // Build tasks array for template using vm.Any conversion
    mut task_maps := []vm.Any{}
    for row in rows {
        // Parse all fields from database
        task_id := vareldb.to_int(row.vals[0]) or { 0 }
        title := vareldb.to_string(row.vals[2], '')
        completed := vareldb.to_bool(row.vals[3]) or { false }

        // Parse timestamps - strip microseconds first!
        created_str := vareldb.strip_pg_microseconds(vareldb.to_string(row.vals[4], ''))
        created_at := time.parse(created_str) or { time.now() }

        // Convert to map for template
        mut task_map := map[string]vm.Any{}
        task_map['id'] = vm.Any(task_id)
        task_map['title'] = vm.Any(title)
        task_map['completed'] = vm.Any(completed)
        task_map['created_at'] = vm.Any(created_at.format_ss())

        task_maps << vm.Any(task_map)
    }

    return ctx.render_data('tasks/index.vtpl', {
        'page_title': vm.Any('My Tasks')
        'username': vm.Any(username)
        'tasks': vm.Any(task_maps)
    })
}

// create - Add new task
pub fn (c TasksController) create(mut ctx http.Context) http.Response {
    user_id := require_auth(mut ctx) or {
        return ctx.redirect('/login', 302)
    }

    title := ctx.form('title')

    if title.len == 0 {
        return ctx.redirect('/tasks', 302)
    }

    // Insert task
    mut db := ctx.db!
    db.exec_params('INSERT INTO tasks (user_id, title) VALUES ($1, $2)',
        [user_id.str(), title]) or {
        eprintln('[TASKS] Failed to create task: ${err}')
    }

    return ctx.redirect('/tasks', 302)
}

// toggle - Toggle task completion status
pub fn (c TasksController) toggle(mut ctx http.Context) http.Response {
    user_id := require_auth(mut ctx) or {
        return ctx.redirect('/login', 302)
    }

    task_id := ctx.params['id'] or { '0' }

    // Toggle task (only if it belongs to this user)
    mut db := ctx.db!
    db.exec_params('UPDATE tasks SET completed = NOT completed WHERE id = $1 AND user_id = $2',
        [task_id, user_id.str()]) or {
        eprintln('[TASKS] Failed to toggle task: ${err}')
    }

    return ctx.redirect('/tasks', 302)
}

// delete - Delete task
pub fn (c TasksController) delete(mut ctx http.Context) http.Response {
    user_id := require_auth(mut ctx) or {
        return ctx.redirect('/login', 302)
    }

    task_id := ctx.params['id'] or { '0' }

    // Delete task (only if it belongs to this user)
    mut db := ctx.db!
    db.exec_params('DELETE FROM tasks WHERE id = $1 AND user_id = $2',
        [task_id, user_id.str()]) or {
        eprintln('[TASKS] Failed to delete task: ${err}')
    }

    return ctx.redirect('/tasks', 302)
}

Step 3.4: Create Tasks View

Create views/tasks/index.vtpl (content-only with modern styling):

<style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 20px;
        }
        .header {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 30px;
            color: white;
        }
        .header h1 {
            font-size: 32px;
        }
        .user-info {
            display: flex;
            align-items: center;
            gap: 15px;
        }
        .logout-btn {
            padding: 10px 20px;
            background: rgba(255,255,255,0.2);
            color: white;
            text-decoration: none;
            border-radius: 6px;
            border: 2px solid white;
            font-weight: 500;
            transition: background 0.3s;
        }
        .logout-btn:hover {
            background: rgba(255,255,255,0.3);
        }
        .container {
            max-width: 600px;
            margin: 0 auto;
        }
        .add-task {
            background: white;
            padding: 20px;
            border-radius: 12px;
            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
            margin-bottom: 20px;
        }
        .add-task form {
            display: flex;
            gap: 10px;
        }
        .add-task input {
            flex: 1;
            padding: 12px;
            border: 2px solid #ddd;
            border-radius: 6px;
            font-size: 14px;
        }
        .add-task input:focus {
            outline: none;
            border-color: #667eea;
        }
        .add-task button {
            padding: 12px 24px;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            border: none;
            border-radius: 6px;
            font-weight: 600;
            cursor: pointer;
            transition: transform 0.2s;
        }
        .add-task button:hover {
            transform: translateY(-2px);
        }
        .tasks {
            background: white;
            border-radius: 12px;
            box-shadow: 0 4px 15px rgba(0,0,0,0.2);
            overflow: hidden;
        }
        .task {
            padding: 20px;
            border-bottom: 1px solid #eee;
            display: flex;
            align-items: center;
            gap: 15px;
            transition: background 0.2s;
        }
        .task:hover {
            background: #f9f9f9;
        }
        .task:last-child {
            border-bottom: none;
        }
        .task.completed .task-title {
            text-decoration: line-through;
            color: #999;
        }
        .task-checkbox {
            width: 24px;
            height: 24px;
            cursor: pointer;
        }
        .task-title {
            flex: 1;
            font-size: 16px;
            color: #333;
        }
        .task-delete {
            padding: 8px 16px;
            background: #fee;
            color: #c33;
            border: none;
            border-radius: 6px;
            cursor: pointer;
            font-size: 14px;
            transition: background 0.2s;
        }
        .task-delete:hover {
            background: #fdd;
        }
        .empty-state {
            padding: 60px 20px;
            text-align: center;
            color: #999;
        }
        .empty-state-icon {
            font-size: 64px;
            margin-bottom: 20px;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>📝 My Tasks</h1>
            <div class="user-info">
                <span>Welcome, ${username}!</span>
                <a href="/logout" class="logout-btn">Logout</a>
            </div>
        </div>

        <div class="add-task">
            <form method="POST" action="/tasks">
                <input type="text" name="title" placeholder="What needs to be done?" required>
                <button type="submit">Add Task</button>
            </form>
        </div>

        <div class="tasks">
            <#if task_count == "0">
            <div class="empty-state">
                <div class="empty-state-icon">✨</div>
                <p>No tasks yet. Add one above to get started!</p>
            </div>
            <#else>
            <#-- Note: This template needs proper iteration once we figure out how to pass arrays -->
            <div class="task">
                <p>Tasks will appear here (Part 3 TODO: Fix template array iteration)</p>
            </div>
            </#if>
        </div>
    </div>

💡 Template Note: Notice how we removed <!DOCTYPE>, <html>, <head>, and <body> tags? The base layout (views/layouts/base.vtpl) handles all that. This prevents the nested HTML problem that breaks page rendering.

Step 3.5: Update Routes in main.v

Update main.v to add task routes and the TasksController:

// ... (previous code remains the same) ...

    // Auth routes
    app.get('/register', auth.register)
    app.post('/register', auth.register_post)
    app.get('/login', auth.login)
    app.post('/login', auth.login_post)
    app.get('/logout', auth.logout)

    // Task routes (protected)
    tasks := controllers.TasksController{}
    app.get('/tasks', tasks.index)
    app.post('/tasks', tasks.create)
    app.get('/tasks/:id/toggle', tasks.toggle)
    app.post('/tasks/:id/delete', tasks.delete)

    // Start server
    println('Task Manager starting on http://localhost:8080')
    app.listen(8080)!
}

Testing the Complete Flow

Manual Testing Steps

  1. Start the application:

    v .
    ./taskman
    
  2. Test Registration:

    • Visit http://localhost:8080
    • Click "Get Started"
    • Fill in registration form
    • Should redirect to login page
  3. Test Login:

    • Enter username and password
    • Should redirect to /tasks page
    • Should see welcome message with username
  4. Test Session Persistence:

    • Refresh the page - should stay logged in
    • Check browser cookies - should see taskman_session cookie
  5. Test Database Sessions:

    PGPASSWORD='test123' psql -h 192.168.168.50 -U vareltest -d taskman_dev -c "SELECT id, user_id, expires_at FROM sessions;"
    

    Should see your session with correct user_id

  6. Test Protected Routes:

    • Logout
    • Try to access http://localhost:8080/tasks directly
    • Should redirect to /login
  7. Test Task Creation (if task view is working):

    • Login
    • Add a task
    • Verify it appears in the list
    • Check database:
      PGPASSWORD='test123' psql -h 192.168.168.50 -U vareltest -d taskman_dev -c "SELECT * FROM tasks;"
      
  8. Test User Isolation:

    • Register a second user
    • Login as second user
    • Add tasks
    • Should only see tasks for current user

What We're Testing

✅ Authentication Features

  1. User Registration:

    • Bcrypt password hashing (12 rounds)
    • Unique username/email validation
    • Database insertion
  2. Login Flow:

    • Username lookup
    • Password verification with bcrypt
    • Session creation in PostgreSQL
    • Session cookie setting
    • Redirect after successful login
  3. Session Management:

    • PostgreSQL JSONB session storage
    • Session persistence across requests
    • Session expiration (7 days)
    • Session-based authentication checks
  4. Protected Routes:

    • Redirect to login if not authenticated
    • Session validation on every request
    • User-specific data isolation
  5. Logout:

    • Session clearing
    • Cookie removal
    • Redirect to home page

🔍 Known Issues to Watch For

Based on previous tutorial attempts, watch for:

  1. Bcrypt hash verification failures - If login consistently fails with correct credentials
  2. Session not persisting - If refreshing page logs you out
  3. Session cookie not being set - Check browser DevTools → Application → Cookies
  4. Database connection issues - Check PostgreSQL logs if queries fail
  5. Form data not being received - Check ctx.form() returns expected values

Troubleshooting

Login Always Returns "Invalid username or password"

Check the debug output (eprintln statements in auth.v):

# Look for these log lines when attempting login:
[AUTH] Attempting login for username="testuser"
[AUTH] Found user: id=1, username="testuser"
[AUTH] Hash length: 60
[AUTH] Comparing password...
[AUTH] Password verification SUCCESS!
[AUTH] Setting session...
[AUTH] Session set, redirecting to /tasks

If you see Password verification FAILED, this indicates a bcrypt issue.

Session Not Persisting

Check the sessions table:

PGPASSWORD='test123' psql -h 192.168.168.50 -U vareltest -d taskman_dev -c "SELECT id, user_id, created_at, expires_at, data FROM sessions;"

Verify:

  • Session exists after login
  • expires_at is in the future (7 days from creation)
  • user_id matches your user
  • data JSONB contains username

Can't Access Protected Routes After Login

Check browser cookies:

  1. Open DevTools → Application → Cookies
  2. Look for taskman_session cookie
  3. Verify it has a value
  4. Check it's not expired

Next Steps

This tutorial focuses on authentication and sessions. If everything works:

  1. Auth is working - User registration, login, logout all functional
  2. Sessions are working - PostgreSQL-backed sessions persist across requests
  3. Protected routes work - Redirects to login when not authenticated

If you encounter issues, document them carefully:

  • What step failed?
  • What error messages appear in logs?
  • What's in the database (users, sessions tables)?
  • What do browser DevTools show (cookies, network requests)?

This information will help identify and fix framework issues before building more complex tutorials.