Users Module Example
Build the live DotApp 2.0 Users module: standalone login, registration,
two-factor confirmation, a protected app page, and AJAX forms powered by
dotapp.js.
Open the live demo.
Overview
The live module lives in /app/modules/Users. DotApp routes requests
directly to public static controller methods such as
Users:Login@page!, Users:Login@save!, and
Users:Register@save!.
The default URL prefix is configured with
Config::module('Users', 'prefix') and defaults to
/documentation/examples/run/users. The tutorial below uses those /users
routes.
Prerequisites
This example assumes the secure forms flow is familiar: DotApp signs forms, validates
submissions with $request->crcCheck(), and returns JSON through
DotApp::DotApp()->ajaxReply($body, $code).
- Secure Forms with dotapp.js
- A MySQL database configured in
/app/config.php. - Framework auth tables prepared with
php dotapper.php --prepare-database.
Module Creation
Create the module, two controllers, and the route gate middleware with DotApper:
php dotapper.php --create-module=Users
php dotapper.php --module=Users --create-controller=Login
php dotapper.php --module=Users --create-controller=Register
php dotapper.php --module=Users --create-middleware=AuthGate
The live module contains Controllers/Login.php,
Controllers/Register.php, Middleware/AuthGate.php,
standalone views in views/, and the frontend script at
assets/js/users.js.
Database Setup
Users and authentication tables are framework tables generated by the database preparation command. They use your configured database prefix.
php dotapper.php --prepare-database
The demo needs MySQL to be configured before registration or login will work. If the
configured database list is empty, calls such as Auth::createUser() and
Auth::login() fail and the controllers return a friendly unavailable
message.
Configuration
Configure your database in /app/config.php. The exact credentials depend
on your local environment:
Config::db('driver', 'pdo');
Config::addDatabase('main', '127.0.0.1', 'Username', 'Password', 'DBNAME', 'UTF8', 'MYSQL', 'pdo');
The module initializes its prefix when it starts:
Config::module('Users', 'prefix') ?? Config::module('Users', 'prefix', '/documentation/examples/run/users');
$p = rtrim((string) Config::module('Users', 'prefix'), '/');
Override Users.prefix in project configuration when you want the same
module mounted somewhere else.
Controllers
DotApp calls public static controller methods. The trailing ! in route
targets marks methods such as page(), save(),
twoFactorPage(), twoFactorSave(), app(), and
logout(). These methods do not use dependency injection.
Views are rendered with Renderer::new()->module('Users')->setView($name),
followed by any setViewVar() calls. If the renderer returns an empty
string, the live controllers return new Response(500, 'Template error').
Request data: original vs protected
Incoming POST is auto-protected. $request->data() is the escaped copy (safe to print).
$request->data(true) is the original array. After channel unwrap, fields are
$request->data(true)['data']. Passwords must use original data —
characters such as ), =, and % are rewritten in the protected copy.
HTTP 400 from crcCheck never reaches form .after() — hook .onError()
and parseReply that body, or the page stays blank.
Login page and save flow
<?php
class Login extends \Dotsystems\App\Parts\Controller
{
public static function page($request)
{
if (Auth::isLogged()) {
return Response::redirect(self::prefix() . '/app', 302);
}
return self::view('login', 'Sign in', self::prefix() . '/login');
}
public static function save($request)
{
if (!$request->crcCheck()) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Bad request'], 400);
}
$answer = $request->form(['POST'], 'loginForm', function ($request) {
$payload = $request->data(true)['data'] ?? [];
$email = trim((string) ($payload['email'] ?? ''));
$password = (string) ($payload['password'] ?? '');
$remember = (($payload['remember'] ?? '') === 'on');
$login = Auth::login(['email' => $email, 'password' => $password, 'stage' => 0], $remember);
if (Auth::loggedStage() === 2) {
return ['code' => 200, 'body' => [
'status' => 1,
'twofactor' => 1,
'redirectTo' => self::prefix() . '/2fa',
]];
}
return ['code' => 200, 'body' => ['status' => 1, 'redirectTo' => self::prefix() . '/app']];
}, function () {
return ['code' => 403, 'body' => ['status' => 0, 'message' => 'Invalid signature']];
}, $request->getPath());
return DotApp::DotApp()->ajaxReply($answer['body'], $answer['code']);
}
}
Registration save flow
<?php
class Register extends \Dotsystems\App\Parts\Controller
{
public static function save($request)
{
if (!$request->crcCheck()) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Bad request'], 400);
}
$answer = $request->form(['POST'], 'registerForm', function ($request) {
$data = $request->data(true)['data'] ?? [];
$email = trim((string) ($data['email'] ?? ''));
$username = trim((string) ($data['username'] ?? ''));
$password = (string) ($data['password'] ?? '');
$r = Auth::createUser($username, $password, $email);
if (($r['error'] ?? 99) === 1) {
return ['code' => 200, 'body' => ['status' => 0, 'message' => 'That account already exists.']];
}
$p = rtrim((string) Config::module('Users', 'prefix'), '/');
return ['code' => 200, 'body' => [
'status' => 1,
'message' => 'Account created.',
'redirectTo' => $p . '/login',
]];
}, function () {
return ['code' => 403, 'body' => ['status' => 0, 'message' => 'Invalid signature']];
}, $request->getPath());
return DotApp::DotApp()->ajaxReply($answer['body'], $answer['code']);
}
}
Two-factor and app methods
<?php
public static function twoFactorPage($request)
{
if (Auth::isLogged()) {
return Response::redirect(self::prefix() . '/app', 302);
}
if (Auth::loggedStage() !== 2) {
return Response::redirect(self::prefix() . '/login', 302);
}
return self::view('twofactor', 'Authenticator code', self::prefix() . '/2fa');
}
public static function twoFactorSave($request)
{
if (!$request->crcCheck()) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Bad request'], 400);
}
$data = $request->data(true)['data'] ?? [];
$code = preg_replace('/\D+/', '', (string) ($data['tfa'] ?? ''));
$r = Auth::confirmTwoFactor(['tfa' => $code]);
if (!is_array($r) || ($r['confirmed'] ?? false) !== true) {
return DotApp::DotApp()->ajaxReply(['status' => 0, 'message' => 'Verification failed'], 200);
}
return DotApp::DotApp()->ajaxReply(['status' => 1, 'redirectTo' => self::prefix() . '/app'], 200);
}
public static function app($request)
{
$attrs = Auth::attributes();
$email = htmlspecialchars((string) ($attrs['email'] ?? ''), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$html = Renderer::new()->module('Users')
->setView('app')
->setViewVar('title', 'Signed in')
->setViewVar('email', $email)
->setViewVar('logoutAction', self::prefix() . '/logout')
->setViewVar('docsUrl', '/documentation/examples/users-module')
->renderView();
return $html === '' ? new Response(500, 'Template error') : $html;
}
The relevant auth calls are Auth::login(), Auth::createUser(),
Auth::confirmTwoFactor(), Auth::isLogged(),
Auth::loggedStage() === 2, Auth::logout(), and
Auth::can($rights).
Middleware
The live middleware is AuthGate. It is attached to protected routes with
->before('#Users:AuthGate@check!'); it is not the route handler itself
and it does not need a separate alias registration.
<?php
namespace Dotsystems\App\Modules\Users\Middleware;
use Dotsystems\App\Parts\Auth;
use Dotsystems\App\Parts\Config;
use Dotsystems\App\Parts\Response;
class AuthGate extends \Dotsystems\App\Parts\ModuleMiddleware
{
public static function check($request, array $rights = [])
{
if (!Auth::isLogged()) {
$p = rtrim((string) Config::module('Users', 'prefix'), '/');
return Response::redirect($p . '/login', 302);
}
if (!empty($rights) && !Auth::can($rights)) {
return new Response(403, 'Forbidden');
}
}
}
Routes
Routes are configured in /app/modules/Users/module.init.php. Each route is
registered as a pair, so both the path and trailing-slash version work.
<?php
Config::module('Users', 'prefix') ?? Config::module('Users', 'prefix', '/documentation/examples/run/users');
$p = rtrim((string) Config::module('Users', 'prefix'), '/');
$pair = function (string $path): array {
$path = rtrim($path, '/');
return [$path, $path . '/'];
};
Router::get($pair($p), 'Users:Login@page!', Router::STATIC_ROUTE);
Router::get($pair($p . '/login'), 'Users:Login@page!', Router::STATIC_ROUTE);
Router::post($pair($p . '/login'), 'Users:Login@save!', Router::STATIC_ROUTE);
Router::get($pair($p . '/2fa'), 'Users:Login@twoFactorPage!', Router::STATIC_ROUTE);
Router::post($pair($p . '/2fa'), 'Users:Login@twoFactorSave!', Router::STATIC_ROUTE);
Router::post($pair($p . '/logout'), 'Users:Login@logout!', Router::STATIC_ROUTE);
Router::get($pair($p . '/register'), 'Users:Register@page!', Router::STATIC_ROUTE);
Router::post($pair($p . '/register'), 'Users:Register@save!', Router::STATIC_ROUTE);
Router::get($pair($p . '/app'), 'Users:Login@app!', Router::STATIC_ROUTE)
->before('#Users:AuthGate@check!');
View
The live views are complete HTML documents. They include the module CSS, the framework
script /assets/dotapp/dotapp.js, and the module script
/assets/modules/Users/js/users.js.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ var: $title }} — DotApp</title>
<link rel="stylesheet" href="/assets/modules/Users/css/users.css" />
</head>
<body class="u-body">
<header class="u-top"><a href="{{ var: $docsUrl }}">Documentation</a><span>Users demo</span></header>
<main class="u-main">
<h1>Sign in</h1>
<div id="error-message" class="u-error" hide="hide"></div>
<div id="loginWrap">
<fo-rm method="POST" id="loginForm">
<label for="email">Email</label>
<input type="text" id="email" name="email" autocomplete="username" required />
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="current-password" required />
{{ formName(loginForm) }}
<button type="submit" id="loginBtn" class="u-btn">Sign in</button>
</fo-rm>
</div>
<p><a href="{{ var: $registerUrl }}">Create an account</a></p>
</main>
<script src="/assets/dotapp/dotapp.js"></script>
<script src="/assets/modules/Users/js/users.js"></script>
</body>
</html>
The important details are the custom <fo-rm> element, the
{{ formName(loginForm) }} template tag between the opening and closing
tags, and the current asset paths.
Layouts
The live demo intentionally does not use nested layouts. Each file in
/app/modules/Users/views/ is a standalone HTML page rendered directly with
setView('login'), setView('register'),
setView('twofactor'), or setView('app').
<fo-rm method="POST" id="registerForm">
<label for="username">Username</label>
<input type="text" id="username" name="username" required />
<label for="email">Email</label>
<input type="text" id="email" name="email" autocomplete="username" required />
<label for="password">Password</label>
<input type="password" id="password" name="password" autocomplete="new-password" required />
{{ formName(registerForm) }}
<button type="submit" id="registerBtn" class="u-btn">Create account</button>
</fo-rm>
Modules can wrap views with {{ layout:name }} and renderer calls such as
setView('x', 'layout'). This demo keeps the pages self-contained so the
example is easy to copy.
Assets
The live views load these assets:
/assets/modules/Users/css/users.cssfor module styling./assets/dotapp/dotapp.jsfor DotApp client helpers and signed form handling./assets/modules/Users/js/users.jsfor login, registration, 2FA, and logout behavior.
<link rel="stylesheet" href="/assets/modules/Users/css/users.css" />
<script src="/assets/dotapp/dotapp.js"></script>
<script src="/assets/modules/Users/js/users.js"></script>
JavaScript
The frontend script is plain JavaScript using $dotapp. It waits for the
dotapp event when the helper is not available yet.
(function () {
var runMe = function ($dotapp) {
if (document.getElementById("loginForm")) {
$dotapp()
.form("#loginForm")
.before(function (data, form) {
if ($dotapp(form).attr("blocked") == 1) return $dotapp().halt();
$dotapp(form).attr("blocked", "1");
$dotapp("#loginBtn").attr("loading", "true").attr("loader", "dots");
$dotapp("#loginWrap").addClass("u_busy");
$dotapp("#error-message").attr("hide", "hide");
})
.after(function (data, response, form) {
var reply = $dotapp().parseReply(response);
if (reply && reply.status == 1 && reply.redirectTo) {
window.location = reply.redirectTo;
return;
}
if (reply && reply.message) $dotapp("#error-message").attr("hide", "false").html(reply.message);
$dotapp(form).attr("blocked", "0");
$dotapp("#loginBtn").removeAttr("loading").removeAttr("loader");
$dotapp("#loginWrap").removeClass("u_busy");
})
.onError(function (data, status, error, form) {
var reply = $dotapp().parseReply(error);
var msg = (reply && typeof reply === "object" && reply.message) ? reply.message : "Sign-in failed";
$dotapp("#error-message").attr("hide", "false").html(msg);
$dotapp(form).attr("blocked", "0");
$dotapp("#loginBtn").removeAttr("loading").removeAttr("loader");
$dotapp("#loginWrap").removeClass("u_busy");
});
}
if (document.querySelector(".two-fa-inputs input")) {
var box = document.querySelector(".two-fa-inputs");
var twoFaUrl = (box && box.getAttribute("data-2fa")) || "/documentation/examples/run/users/2fa";
$dotapp(".two-fa-inputs input").twoFactor(function (code) {
$dotapp().load(twoFaUrl, "POST", { tfa: code }, function (raw) {
var reply = $dotapp().parseReply(raw);
if (reply && reply.status == 1 && reply.redirectTo) window.location = reply.redirectTo;
else if (reply && reply.message) $dotapp("#error-message").attr("hide", "false").html(reply.message);
}, function (status, errorText) {
var reply = $dotapp().parseReply(errorText);
var msg = (reply && typeof reply === "object" && reply.message) ? reply.message : "Verification failed";
$dotapp("#error-message").attr("hide", "false").html(msg);
});
}, { length: 6, allowLetters: false, autoSubmit: true });
}
};
if (window.$dotapp) runMe(window.$dotapp);
else window.addEventListener("dotapp", function () { runMe(window.$dotapp); }, { once: true });
})();
Registration uses the same $dotapp().form("#registerForm") pattern.
Logout posts with $dotapp().load(url, "POST", {}, callback) from the
signed-in page button.
Live Demo
Try the live demo at /documentation/examples/run/users/login or create an account at /documentation/examples/run/users/register.
DOWNLOAD at https://github.com/dotsystems-sk/moduleUsers