LoginFormComponent.vala 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. using Spry;
  2. using Inversion;
  3. using Astralis;
  4. using Invercargill.DataStructures;
  5. namespace Spry.Authentication.Components {
  6. /**
  7. * LoginFormComponent - A reusable login form component with HTMX-based submission.
  8. *
  9. * This component provides a complete login flow:
  10. * - Displays login form with username/email and password fields
  11. * - Handles form submission via HTMX
  12. * - Authenticates users via UserService
  13. * - Creates sessions and sets cookies via SessionService
  14. * - Displays error messages on failed authentication
  15. * - Redirects to configurable URL on success
  16. *
  17. * Usage:
  18. * // In a PageComponent or another component's markup:
  19. * <spry-component name="LoginFormComponent" sid="login-form"/>
  20. *
  21. * // Or create via factory and configure:
  22. * var login_form = factory.create<LoginFormComponent>();
  23. * login_form.redirect_url = "/dashboard";
  24. *
  25. * Customization:
  26. * - redirect_url: URL to redirect after successful login (default: "/")
  27. * - login_action_name: Custom action name (default: "login")
  28. * - Override markup property for custom styling
  29. *
  30. * This component uses the inject<> pattern for dependency injection.
  31. */
  32. public class LoginFormComponent : Component {
  33. private UserService _user_service = inject<UserService>();
  34. private SessionService _session_service = inject<SessionService>();
  35. private PermissionService? _permission_service = inject<PermissionService>();
  36. private HttpContext _http_context = inject<HttpContext>();
  37. // =========================================================================
  38. // Configuration Properties
  39. // =========================================================================
  40. /**
  41. * URL to redirect to after successful login.
  42. * Default: "/"
  43. */
  44. public string redirect_url { get; set; default = "/"; }
  45. /**
  46. * Duration for "remember me" sessions in hours.
  47. * When "remember_me" is checked, session duration is extended.
  48. * Default: 168 (7 days)
  49. */
  50. public int remember_me_duration_hours { get; set; default = 168; }
  51. // =========================================================================
  52. // State Properties
  53. // =========================================================================
  54. /**
  55. * Error message to display (set after failed authentication).
  56. */
  57. public string? error_message { get; private set; default = null; }
  58. /**
  59. * Username value to preserve after failed login.
  60. */
  61. public string preserved_username { get; private set; default = ""; }
  62. /**
  63. * Whether login was successful (triggers redirect).
  64. */
  65. public bool login_successful { get; private set; default = false; }
  66. // =========================================================================
  67. // Component Implementation
  68. // =========================================================================
  69. public override string markup { get {
  70. return """
  71. <spry-context property="redirect_url"/>
  72. <script spry-res="htmx.js"></script>
  73. <div class="spry-login-form" sid="login-form" hx-swap="outerHTML">
  74. <form sid="form" spry-action=":login" spry-target="login-form" hx-disabled-elt="find button">
  75. <div class="form-group">
  76. <label for="username">Username or Email</label>
  77. <input type="text"
  78. name="username"
  79. sid="username-input"
  80. required
  81. autocomplete="username"
  82. autofocus
  83. placeholder="Enter your username or email"/>
  84. </div>
  85. <div class="form-group">
  86. <label for="password">Password</label>
  87. <input type="password"
  88. name="password"
  89. sid="password-input"
  90. required
  91. autocomplete="current-password"
  92. placeholder="Enter your password"/>
  93. </div>
  94. <div class="form-group form-group-checkbox">
  95. <label class="checkbox-label">
  96. <input type="checkbox" name="remember_me" sid="remember-me-input"/>
  97. <span>Remember me</span>
  98. </label>
  99. </div>
  100. <div spry-if="this.error_message != null" class="error-message" sid="error-container">
  101. <span content-expr="this.error_message"></span>
  102. </div>
  103. <button type="submit" sid="submit-btn" class="login-btn">Log In</button>
  104. </form>
  105. </div>
  106. """;
  107. }}
  108. public override async void prepare() throws Error {
  109. // Preserve username in the input field after failed login
  110. if (preserved_username.length > 0) {
  111. this["username-input"].set_attribute("value", preserved_username);
  112. }
  113. }
  114. public async override void handle_action(string action) throws Error {
  115. // Normalize action name comparison
  116. if (action == "login") {
  117. yield handle_login_async();
  118. }
  119. }
  120. // =========================================================================
  121. // Login Handler
  122. // =========================================================================
  123. private async void handle_login_async() throws Error {
  124. stdout.printf("LOGIN DEBUG: handle_login_async() triggered\n");
  125. var query = _http_context.request.query_params;
  126. // Get form values
  127. var username_raw = query.get_any_or_default("username");
  128. var password_raw = query.get_any_or_default("password");
  129. var remember_me_raw = query.get_any_or_default("remember_me");
  130. var username = username_raw != null ? ((!)username_raw).strip() : "";
  131. var password = password_raw ?? "";
  132. var remember_me = remember_me_raw == "on";
  133. stdout.printf("LOGIN DEBUG: Username/email received: '%s'\n", username);
  134. stdout.printf("LOGIN DEBUG: Password length: %d\n", password.length);
  135. stdout.printf("LOGIN DEBUG: Remember me: %s\n", remember_me ? "true" : "false");
  136. // Preserve username for re-display on error
  137. preserved_username = username;
  138. // Validate inputs
  139. if (username.length == 0) {
  140. stdout.printf("LOGIN DEBUG: Validation failed - empty username\n");
  141. error_message = "Please enter your username or email";
  142. return;
  143. }
  144. if (password.length == 0) {
  145. stdout.printf("LOGIN DEBUG: Validation failed - empty password\n");
  146. error_message = "Please enter your password";
  147. return;
  148. }
  149. // Attempt authentication
  150. stdout.printf("LOGIN DEBUG: Calling authenticate_async()...\n");
  151. var user = yield _user_service.authenticate_async(username, password);
  152. stdout.printf("LOGIN DEBUG: authenticate_async() returned user: %s\n", user != null ? user.id : "null");
  153. if (user == null) {
  154. // Authentication failed - show generic error message
  155. // (Don't reveal whether username or password was wrong for security)
  156. stdout.printf("LOGIN DEBUG: Authentication failed - invalid credentials\n");
  157. error_message = "Invalid username or password";
  158. return;
  159. }
  160. stdout.printf("LOGIN DEBUG: Authentication successful for user: %s (username: %s, email: %s)\n",
  161. user.id, user.username, user.email);
  162. // Get client info for session tracking
  163. var ip_address = _http_context.request.remote_address;
  164. var user_agent = _http_context.request.headers.get_any_or_default("User-Agent");
  165. stdout.printf("LOGIN DEBUG: Client IP: %s, User-Agent: %s\n",
  166. ip_address ?? "null", user_agent ?? "null");
  167. // Create session
  168. stdout.printf("LOGIN DEBUG: Creating session...\n");
  169. Session? session;
  170. if (remember_me) {
  171. // Create session with extended duration for "remember me"
  172. // Note: Current SessionService uses configured duration;
  173. // for extended sessions, we'd need to modify SessionService
  174. // For now, create a regular session
  175. session = yield _session_service.create_session_async(user.id, ip_address, user_agent);
  176. } else {
  177. session = yield _session_service.create_session_async(user.id, ip_address, user_agent);
  178. }
  179. stdout.printf("LOGIN DEBUG: Session creation result: %s\n", session != null ? session.id : "null");
  180. if (session == null) {
  181. stdout.printf("LOGIN DEBUG: ERROR - Failed to create session\n");
  182. error_message = "Failed to create session. Please try again.";
  183. return;
  184. }
  185. // Generate session token
  186. stdout.printf("LOGIN DEBUG: Generating session token...\n");
  187. var token = _session_service.generate_session_token(session);
  188. stdout.printf("LOGIN DEBUG: Token generated (length: %d)\n", token.length);
  189. // Set session cookie using ResponseState
  190. // This accumulates the cookie header to be applied when to_result() is called
  191. stdout.printf("LOGIN DEBUG: Setting session cookie via ResponseState...\n");
  192. response.set_cookie("spry_session", token, 86400, "/", true, true, "Strict");
  193. stdout.printf("LOGIN DEBUG: Session cookie set\n");
  194. // Set up HTMX redirect using ResponseState
  195. // This sets the HX-Redirect header for client-side redirect
  196. response.redirect(redirect_url);
  197. login_successful = true;
  198. error_message = null;
  199. stdout.printf("LOGIN DEBUG: Login successful! Redirect to: %s\n", redirect_url);
  200. // Optional: Check permissions if permission_service is available
  201. // This can be used for post-login permission checks
  202. if (_permission_service != null) {
  203. // Subclasses can override to add permission checks
  204. // e.g., require certain permissions to access specific areas
  205. }
  206. }
  207. // =========================================================================
  208. // Public API
  209. // =========================================================================
  210. /**
  211. * Clears the error message and resets the form state.
  212. */
  213. public void clear_error() {
  214. error_message = null;
  215. }
  216. /**
  217. * Sets a custom error message.
  218. * Useful for external validation or custom error handling.
  219. *
  220. * @param message The error message to display
  221. */
  222. public void set_error(string message) {
  223. error_message = message;
  224. }
  225. }
  226. }