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
- How to use Varel's session middleware
- How to implement user registration and authentication
- How to protect routes with authentication middleware
- How to isolate user data with database queries
- How Varel's PostgreSQL sessions work in practice
- 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:
- Click "Get Started" → fills in registration form
- Create account with:
- Username:
testuser - Email:
test@example.com - Password:
password123
- Username:
- Should redirect to login page
- Login with
testuser/password123 - 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 withtime.parse(). PostgreSQL returns timestamps like "2024-01-15 10:30:45.123456" but V'stime.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
Start the application:
v . ./taskmanTest Registration:
- Visit
http://localhost:8080 - Click "Get Started"
- Fill in registration form
- Should redirect to login page
- Visit
Test Login:
- Enter username and password
- Should redirect to
/taskspage - Should see welcome message with username
Test Session Persistence:
- Refresh the page - should stay logged in
- Check browser cookies - should see
taskman_sessioncookie
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
Test Protected Routes:
- Logout
- Try to access
http://localhost:8080/tasksdirectly - Should redirect to
/login
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;"
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
User Registration:
- Bcrypt password hashing (12 rounds)
- Unique username/email validation
- Database insertion
Login Flow:
- Username lookup
- Password verification with bcrypt
- Session creation in PostgreSQL
- Session cookie setting
- Redirect after successful login
Session Management:
- PostgreSQL JSONB session storage
- Session persistence across requests
- Session expiration (7 days)
- Session-based authentication checks
Protected Routes:
- Redirect to login if not authenticated
- Session validation on every request
- User-specific data isolation
Logout:
- Session clearing
- Cookie removal
- Redirect to home page
🔍 Known Issues to Watch For
Based on previous tutorial attempts, watch for:
- Bcrypt hash verification failures - If login consistently fails with correct credentials
- Session not persisting - If refreshing page logs you out
- Session cookie not being set - Check browser DevTools → Application → Cookies
- Database connection issues - Check PostgreSQL logs if queries fail
- 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_atis in the future (7 days from creation)user_idmatches your userdataJSONB contains username
Can't Access Protected Routes After Login
Check browser cookies:
- Open DevTools → Application → Cookies
- Look for
taskman_sessioncookie - Verify it has a value
- Check it's not expired
Next Steps
This tutorial focuses on authentication and sessions. If everything works:
- ✅ Auth is working - User registration, login, logout all functional
- ✅ Sessions are working - PostgreSQL-backed sessions persist across requests
- ✅ 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.