using Astralis; using Invercargill; using Invercargill.DataStructures; using InvercargillSql; using InvercargillSql.Migrations; using InvercargillSqlInversion; using Inversion; using Spry; using Spry.Authentication; using Spry.Authentication.Components; using Spry.Authorisation; /** * UsersExample.vala - Complete example demonstrating the Spry Authentication system * * This example demonstrates: * 1. Application Migration - Extends UserTableMigration with a specific version * 2. Service Setup - Using Inversion's inject() pattern * 3. User Registration - Creating a new user with username/email/password * 4. User Authentication - Login flow with AuthorisationToken * 5. Permission Management - Setting and checking permissions * 6. Protected Content - Pages that require authentication * 7. Login Form - Using the built-in LoginFormComponent * 8. User Management - Using UserManagementComponent (for users with permission) * 9. Authorisation Context - Using AuthorisationContext for permission checking * * Route structure: * / -> HomePage (public landing page) * /register -> RegisterPage (create new account) * /login -> LoginPage (uses LoginFormComponent) * /logout -> LogoutEndpoint (clears cookie and redirects) * /dashboard -> DashboardPage (protected - requires authentication) * /admin/users -> UserAdminPage (protected - requires "user-management" permission, uses UserManagementComponent) */ // ============================================================================= // APPLICATION PERMISSIONS - Define permissions available in this application // ============================================================================= /** * Creates a Vector of permissions for the UsersExample application. * * These permissions are passed to the authentication components so they can * display appropriate checkboxes. The components themselves do NOT hardcode * any permissions - they must be provided by the application. * * Returns a Vector which is required for proper property binding * in the authentication components. */ public Vector get_application_permissions() { var permissions = new Vector(); permissions.add("user-management"); permissions.add("user.create"); permissions.add("user.read"); permissions.add("user.update"); permissions.add("user.delete"); permissions.add("admin"); return permissions; } // ============================================================================= // STYLESHEETS - CSS content served as FastResources // ============================================================================= private const string MAIN_CSS = """ /* Base Reset & Layout */ * { box-sizing: border-box; margin: 0; padding: 0; } html, body { height: 100%; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; background: #f5f7fa; display: flex; flex-direction: column; min-height: 100vh; } /* Header */ header { background: #2c3e50; color: white; padding: 1rem 2rem; display: flex; justify-content: space-between; align-items: center; flex-shrink: 0; } header nav a { color: white; margin-right: 1.5rem; text-decoration: none; font-weight: 500; transition: opacity 0.2s; } header nav a:hover { opacity: 0.8; } header nav a:last-child { margin-right: 0; } header .user-info { font-size: 0.9rem; } header .user-info a { margin-left: 1rem; color: #3498db; } /* Main Content */ main.container { flex: 1; max-width: 800px; width: 100%; margin: 0 auto; padding: 2rem 1rem; } /* Cards */ .card { background: white; border-radius: 12px; padding: 2rem; margin-bottom: 1.5rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08); } /* Typography */ h1 { color: #2c3e50; margin-bottom: 1rem; } h2 { color: #34495e; margin-bottom: 0.75rem; margin-top: 1.5rem; } p { color: #555; margin-bottom: 1rem; } ul { margin-left: 1.5rem; margin-bottom: 1rem; } li { margin-bottom: 0.5rem; color: #555; } /* Links */ a { color: #3498db; text-decoration: none; transition: color 0.2s; } a:hover { color: #2980b9; text-decoration: underline; } /* Forms */ .form-group { margin-bottom: 1.25rem; } .form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; color: #34495e; } .form-group input { width: 100%; padding: 0.75rem; border: 2px solid #e0e0e0; border-radius: 6px; font-size: 1rem; transition: border-color 0.2s; } .form-group input:focus { outline: none; border-color: #3498db; } .form-group-checkbox { display: flex; align-items: center; gap: 0.5rem; } .form-group-checkbox input { width: auto; } .form-group-checkbox label { margin: 0; font-weight: normal; } /* Buttons */ button, .btn { background: #3498db; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 6px; cursor: pointer; font-size: 1rem; font-weight: 500; transition: all 0.2s; text-decoration: none; display: inline-block; } button:hover, .btn:hover { background: #2980b9; text-decoration: none; } .btn-secondary { background: #6c757d; } .btn-secondary:hover { background: #545b62; } /* Alerts */ .alert { padding: 1rem; border-radius: 6px; margin-bottom: 1rem; } .alert-success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .alert-error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .error-message { color: #dc3545; font-size: 0.9rem; margin-top: 0.5rem; } /* Login Form */ .spry-login-form { max-width: 400px; } .login-btn { width: 100%; margin-top: 1rem; } /* Footer */ footer { background: #2c3e50; color: rgba(255,255,255,0.7); padding: 1.5rem; text-align: center; flex-shrink: 0; } /* Dashboard */ .welcome-section { margin-bottom: 2rem; } .permission-list { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.5rem; } .permission-badge { background: #e9ecef; color: #495057; padding: 0.25rem 0.75rem; border-radius: 20px; font-size: 0.85rem; } .permission-badge.admin { background: #ffc107; color: #856404; } """; // ============================================================================= // PAGE TEMPLATE - Provides consistent layout for all pages // ============================================================================= /** * MainLayoutTemplate - Base template for all pages * * Provides: * - HTML document structure * - Common elements (scripts, styles) * - Site-wide header with navigation (changes based on auth state) * - Site-wide footer * * Uses AuthorisationContext to check authentication status. */ public class MainLayoutTemplate : PageTemplate { private AuthorisationContext _auth_context = inject(); public override string markup { get { return """ Spry Authentication Example

Built with Spry Framework - Authentication Example

"""; }} public override async void prepare() throws Error { // Check authentication using AuthorisationContext if (!_auth_context.is_anonymous() && _auth_context.token != null) { var token = _auth_context.token; this["user-info"].inner_html = @"Logged in as $(token.username) | Logout"; } else { this["user-info"].inner_html = "Login | Register"; } } } // ============================================================================= // PAGE COMPONENTS - Public pages // ============================================================================= /** * HomePage - Public landing page * * This page is accessible to everyone, authenticated or not. * It provides an overview of the Authentication system features. */ public class HomePage : PageComponent { private AuthorisationContext _auth_context = inject(); public const string ROUTE = "/"; public override string markup { get { return """

Welcome to the Spry Authentication Example

This example demonstrates the complete Spry Authentication system including:

Features

  • User Registration - Create new accounts with username/email/password
  • Authentication - Login with AuthorisationToken management
  • Permissions - Granular permission system with wildcard support
  • Protected Pages - Pages that require authentication
  • User Management - Admin interface for managing users
  • Authorisation Context - Request-scoped permission checking

Try It Out

"""; }} public override async void prepare() throws Error { // Check if user is authenticated using AuthorisationContext if (!_auth_context.is_anonymous() && _auth_context.token != null) { var token = _auth_context.token; this["status-message"].text_content = @"You are logged in as $(token.username)."; } else { this["status-message"].text_content = "You are not logged in. Register or login to access protected pages."; } } } /** * RegisterPage - User registration page * * Allows new users to create an account with username, email, and password. * Demonstrates direct use of UserService.register_user(). */ public class RegisterPage : PageComponent { private UserService _user_service = inject(); private HttpContext _http_context = inject(); public string? error_message { get; private set; default = null; } public string? success_message { get; private set; default = null; } public string preserved_username { get; private set; default = ""; } public string preserved_email { get; private set; default = ""; } public const string ROUTE = "/register"; public override string markup { get { return """

Create Account

Already have an account? Login here

"""; }} public override async void prepare() throws Error { // Preserve form values after failed submission if (preserved_username.length > 0) { this["username-input"].set_attribute("value", preserved_username); } if (preserved_email.length > 0) { this["email-input"].set_attribute("value", preserved_email); } } public async override void handle_action(string action) throws Error { if (action == "Register") { yield handle_register_async(); } } private async void handle_register_async() throws Error { var query = _http_context.request.query_params; // Get form values var username = (query.get_any_or_default("username") ?? "").strip(); var email = (query.get_any_or_default("email") ?? "").strip(); var forename = (query.get_any_or_default("forename") ?? "").strip(); var surname = (query.get_any_or_default("surname") ?? "").strip(); var dob_string = query.get_any_or_default("date_of_birth") ?? ""; var password = query.get_any_or_default("password") ?? ""; var confirm_password = query.get_any_or_default("confirm_password") ?? ""; // Preserve values for re-display preserved_username = username; preserved_email = email; // Validate inputs if (username.length < 3) { error_message = "Username must be at least 3 characters"; return; } if (!email.contains("@") || !email.contains(".")) { error_message = "Please enter a valid email address"; return; } if (forename.length == 0) { error_message = "Please enter your first name"; return; } if (surname.length == 0) { error_message = "Please enter your last name"; return; } // Parse date of birth DateTime? date_of_birth = null; if (dob_string.length > 0) { try { date_of_birth = new DateTime.from_iso8601(dob_string, new TimeZone.utc()); } catch (Error e) { error_message = "Please enter a valid date of birth (YYYY-MM-DD)"; return; } } else { error_message = "Please enter your date of birth"; return; } if (password.length < 6) { error_message = "Password must be at least 6 characters"; return; } if (password != confirm_password) { error_message = "Passwords do not match"; return; } // Attempt to create user using the new register_user method try { var user = yield _user_service.register_user( username, email, forename, surname, date_of_birth, password, true // enabled ); // Grant basic permissions to new users yield _user_service.set_user_permission(user.id, "user.read"); success_message = @"Account created successfully! You can now login."; error_message = null; // Clear preserved values on success preserved_username = ""; preserved_email = ""; } catch (Error e) { // Handle duplicate username/email errors if (e.message.contains("UNIQUE constraint failed") || e.message.contains("duplicate")) { if (e.message.contains("username")) { error_message = "Username already exists. Please choose another."; } else if (e.message.contains("email")) { error_message = "Email already registered. Please use another or login."; } else { error_message = "A user with this information already exists."; } } else { error_message = "Registration failed: %s".printf(e.message); } } } } // ============================================================================= // PAGE COMPONENTS - Authentication pages // ============================================================================= /** * LoginPage - Login page using the built-in LoginFormComponent * * This page demonstrates how to use the LoginFormComponent for authentication. * The component handles: * - Form display and validation * - Authentication via UserService.authenticate_user() * - Cookie management via AuthorisationService * - Redirect after successful login */ public class LoginPage : PageComponent { private ComponentFactory _factory = inject(); private LoginFormComponent _login_form; public const string ROUTE = "/login"; public override string markup { get { return """

Login

Don't have an account? Register here

"""; }} public override async void prepare() throws Error { // Create and configure the login form component _login_form = _factory.create(); _login_form.redirect_url = "/dashboard"; // Redirect here after successful login // Add the form to our outlet add_outlet_child("login-form-outlet", _login_form); // Share globals with the login form (required for action handling) add_globals_from(_login_form); } } /** * LogoutEndpoint - Handles logout by clearing the authorisation cookie * * This demonstrates: * - Simply clearing the authorisation cookie * - Returning a redirect response * * Note: In the refined authentication system, there's no server-side session * to delete - authentication is stateless using signed tokens. */ public class LogoutEndpoint : Object, Endpoint { public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error { // Create redirect result with Location header var result = new HttpStringResult("Redirecting to home page...", 302); result.set_header("Location", "/"); // Clear the authorisation cookie by setting it to empty with an expired date result.headers.add("Set-Cookie", "_spry-authorisation=; Secure; Max-Age=0"); return result; } } // ============================================================================= // PAGE COMPONENTS - Protected pages // ============================================================================= /** * DashboardPage - Protected dashboard page * * This page demonstrates: * - Checking authentication using AuthorisationContext * - Redirecting unauthenticated users to login * - Accessing the current user's information from AuthorisationToken * - Checking permissions using AuthorisationContext * - Displaying user-specific content */ public class DashboardPage : PageComponent { private AuthorisationContext _auth_context = inject(); public const string ROUTE = "/dashboard"; public override string markup { get { return """

Authentication Required

You must be logged in to view this page.

Login Register

Dashboard

Welcome to your dashboard! This page demonstrates protected content.

Your Profile

  • Username:
  • Email:
  • User ID:
  • Account Created:

Your Permissions

Admin Access: You have admin privileges. Go to User Management

Back to Home

"""; }} // Track if user is authenticated (for template binding) public bool is_anonymous { get; private set; default = true; } // Track if user has admin permission public bool is_admin { get; private set; default = false; } public override async void prepare() throws Error { // Check authentication using AuthorisationContext if (_auth_context.is_anonymous()) { // Not authenticated - show message and link to login is_anonymous = true; return; } is_anonymous = false; var token = _auth_context.token; if (token == null) { is_anonymous = true; return; } // Check permissions using AuthorisationContext is_admin = _auth_context.has_permission("admin"); // Populate user info from the token this["welcome-heading"].text_content = @"Welcome, $(token.username)!"; this["username"].text_content = token.username; this["user-id"].text_content = token.user_identifier.to_string(); // Get additional user data from the token's data properties var user_data = token.data; string? email_val = null; DateTime? created_val = null; // Try to get email and created from properties try { var email_element = user_data.get("email"); email_val = email_element.as_string_or_null(); } catch (Error e) { // Email not available } try { var created_element = user_data.get("created"); created_val = created_element.as(); } catch (Error e) { // Created not available } this["email"].text_content = email_val ?? "N/A"; this["created-at"].text_content = created_val != null ? ((DateTime)created_val).format("%Y-%m-%d %H:%M:%S UTC") : "N/A"; // Populate permissions from the token var perm_text = ""; var user_permissions = token.permissions; int perm_count = 0; foreach (var perm in user_permissions) { var badge_class = perm == "admin" ? "permission-badge admin" : "permission-badge"; perm_text += @"$perm "; perm_count++; } if (perm_count == 0) { perm_text = "No permissions assigned"; } this["permission-list"].inner_html = perm_text; } } // ============================================================================= // PAGE COMPONENTS - Admin pages // ============================================================================= /** * UserAdminPage - Admin page for user management * * This page demonstrates how to use UserManagementComponent within your own * page layout. Unlike the old UserManagementPage (which was a PageComponent * that generated a full HTML document), UserManagementComponent is a regular * Component that can be placed anywhere. * * Applications now have full control over: * - The page layout and navigation * - CSS styling via their own stylesheets * - Where the user management component appears */ public class UserAdminPage : PageComponent { private ComponentFactory _factory = inject(); private UserManagementComponent _user_management; public const string ROUTE = "/admin/users"; public override string markup { get { return """

User Administration

Manage user accounts, permissions, and access control.

"""; }} public override async void prepare() throws Error { // Create the user management component _user_management = _factory.create(); // Pass the application-defined permissions to the component // This allows the component to display appropriate checkboxes _user_management.available_permissions = get_application_permissions(); // Add it to our outlet add_outlet_child("user-management-outlet", _user_management); // Share globals with the component (required for action handling) add_globals_from(_user_management); } } // ============================================================================= // SEED DATA - Creates initial admin user // ============================================================================= /** * SeedData - Creates initial users for testing * * This demonstrates how to: * - Check if users exist before creating * - Create users programmatically using register_user() * - Grant permissions to users using set_user_permission() */ public class SeedData : Object { public static async void ensure_admin_exists(UserService user_service) throws Error { // Try to get list of users and check if admin exists var users = yield user_service.list_users(0, 100); bool admin_exists = false; bool testuser_exists = false; int64? admin_id = null; int64? testuser_id = null; foreach (var user in users) { if (user.username == "admin") { admin_exists = true; admin_id = user.id; } if (user.username == "testuser") { testuser_exists = true; testuser_id = user.id; } } if (admin_exists) { print("Admin user already exists\n"); } else { print("Creating admin user...\n"); // Create admin user using register_user var admin_user = yield user_service.register_user( "admin", "admin@example.com", "Admin", "User", new DateTime.utc(1990, 1, 1, 0, 0, 0.0), "admin123", true ); admin_id = admin_user.id; // Grant admin permissions yield user_service.set_user_permission(admin_id, "admin"); yield user_service.set_user_permission(admin_id, "user-management"); yield user_service.set_user_permission(admin_id, "user.create"); yield user_service.set_user_permission(admin_id, "user.read"); yield user_service.set_user_permission(admin_id, "user.update"); yield user_service.set_user_permission(admin_id, "user.delete"); print("Admin user created with username 'admin' and password 'admin123'\n"); } if (!testuser_exists) { print("Creating test user...\n"); var test_user = yield user_service.register_user( "testuser", "test@example.com", "Test", "User", new DateTime.utc(1995, 6, 15, 0, 0, 0.0), "test123", true ); testuser_id = test_user.id; yield user_service.set_user_permission(testuser_id, "user.read"); print("Test user created with username 'testuser' and password 'test123'\n"); } else { print("Test user already exists\n"); } } } // ============================================================================= // APPLICATION SETUP // ============================================================================= // ============================================================================= // ASYNC MAIN LOOP - Required for async initialization // ============================================================================= private MainLoop main_loop; public static int main(string[] args) { int port = args.length > 1 ? int.parse(args[1]) : 8080; print("═══════════════════════════════════════════════════════════════\n"); print(" Spry Authentication Example - Complete Demo\n"); print("═══════════════════════════════════════════════════════════════\n"); print(" Port: %d\n", port); print("═══════════════════════════════════════════════════════════════\n"); print(" Public Endpoints:\n"); print(" / - Home page (public)\n"); print(" /register - Create new account\n"); print(" /login - Login page\n"); print("═══════════════════════════════════════════════════════════════\n"); print(" Protected Endpoints (requires login):\n"); print(" /dashboard - User dashboard\n"); print(" /logout - Logout and clear cookie\n"); print("═══════════════════════════════════════════════════════════════\n"); print(" Admin Endpoints (requires 'user-management' permission):\n"); print(" /admin/users - User management page\n"); print("═══════════════════════════════════════════════════════════════\n"); print(" Default Users:\n"); print(" admin / admin123 - Has all permissions\n"); print(" testuser / test123 - Regular user\n"); print("═══════════════════════════════════════════════════════════════\n"); print("\nPress Ctrl+C to stop the server\n\n"); main_loop = new MainLoop(); try { start_application.begin(port, (obj, res) => { try { start_application.end(res); } catch (Error e) { printerr("Application error: %s\n", e.message); main_loop.quit(); } }); main_loop.run(); return 0; } catch (Error e) { printerr("Error: %s\n", e.message); return 1; } } /** * Start the web application. */ private async void start_application(int port) throws Error { var application = new WebApplication(port); // Enable compression application.use_compression(); // Add Spry module for component actions application.add_module(); // Configure the database using InvercargillSqlInversion var db_config = application.container.configure_with(); db_config.register_connection(@"sqlite://./db.sqlite"); db_config.migrate_on_startup(); // Add authentication module to register entity mappings application.add_module(); // Register Authentication system services application.add_scoped(); // Register Authorisation system services application.add_scoped(); // Add the authorisation pipeline component to read tokens from requests // This component creates a scoped AuthorisationContext per request application.add_scoped() .as(); // Seed initial data (admin user, test user) seed_initial_data.begin(application.container); // Register template with route prefix // MainLayoutTemplate applies to ALL routes (empty prefix) var spry_cfg = application.configure_with(); spry_cfg.add_template(""); // Register page components as endpoints application.add_transient(); application.add_endpoint(new EndpointRoute(HomePage.ROUTE)); application.add_transient(); application.add_endpoint(new EndpointRoute(RegisterPage.ROUTE)); application.add_transient(); application.add_endpoint(new EndpointRoute(LoginPage.ROUTE)); application.add_transient(); application.add_endpoint(new EndpointRoute(DashboardPage.ROUTE)); // Register logout endpoint application.add_endpoint(new EndpointRoute("/logout")); // Register UserAdminPage (admin page wrapping UserManagementComponent) application.add_transient(); application.add_endpoint(new EndpointRoute(UserAdminPage.ROUTE)); // Register LoginFormComponent (used by LoginPage) application.add_transient(); // Register new user management components application.add_transient(); application.add_transient(); application.add_transient(); // Register CSS as FastResource application.add_startup_endpoint(new EndpointRoute("/styles/main.css"), () => { try { return new FastResource.from_string(MAIN_CSS) .with_content_type("text/css; charset=utf-8") .with_default_compressors(); } catch (Error e) { error("Failed to create main CSS resource: %s", e.message); } }); print("Starting web server on port %d...\n\n", port); application.run(); } /** * Seed initial data (admin and test users). */ private async void seed_initial_data(Container container) { try { var scope = container.create_scope(); var user_service = scope.resolve(); yield SeedData.ensure_admin_exists(user_service); } catch (Error e) { printerr("Warning: Failed to seed initial data: %s\n", e.message); } }