UsersExample.vala 38 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073
  1. using Astralis;
  2. using Invercargill;
  3. using Invercargill.DataStructures;
  4. using InvercargillSql;
  5. using InvercargillSql.Migrations;
  6. using InvercargillSqlInversion;
  7. using Inversion;
  8. using Spry;
  9. using Spry.Authentication;
  10. using Spry.Authentication.Components;
  11. using Spry.Authorisation;
  12. /**
  13. * UsersExample.vala - Complete example demonstrating the Spry Authentication system
  14. *
  15. * This example demonstrates:
  16. * 1. Application Migration - Extends UserTableMigration with a specific version
  17. * 2. Service Setup - Using Inversion's inject<T>() pattern
  18. * 3. User Registration - Creating a new user with username/email/password
  19. * 4. User Authentication - Login flow with AuthorisationToken
  20. * 5. Permission Management - Setting and checking permissions
  21. * 6. Protected Content - Pages that require authentication
  22. * 7. Login Form - Using the built-in LoginFormComponent
  23. * 8. User Management - Using UserManagementComponent (for users with permission)
  24. * 9. Authorisation Context - Using AuthorisationContext for permission checking
  25. *
  26. * Route structure:
  27. * / -> HomePage (public landing page)
  28. * /register -> RegisterPage (create new account)
  29. * /login -> LoginPage (uses LoginFormComponent)
  30. * /logout -> LogoutEndpoint (clears cookie and redirects)
  31. * /dashboard -> DashboardPage (protected - requires authentication)
  32. * /admin/users -> UserAdminPage (protected - requires "user-management" permission, uses UserManagementComponent)
  33. */
  34. // =============================================================================
  35. // APPLICATION PERMISSIONS - Define permissions available in this application
  36. // =============================================================================
  37. /**
  38. * Creates a Vector of permissions for the UsersExample application.
  39. *
  40. * These permissions are passed to the authentication components so they can
  41. * display appropriate checkboxes. The components themselves do NOT hardcode
  42. * any permissions - they must be provided by the application.
  43. *
  44. * Returns a Vector<string> which is required for proper property binding
  45. * in the authentication components.
  46. */
  47. public Vector<string> get_application_permissions() {
  48. var permissions = new Vector<string>();
  49. permissions.add("user-management");
  50. permissions.add("user.create");
  51. permissions.add("user.read");
  52. permissions.add("user.update");
  53. permissions.add("user.delete");
  54. permissions.add("admin");
  55. return permissions;
  56. }
  57. // =============================================================================
  58. // STYLESHEETS - CSS content served as FastResources
  59. // =============================================================================
  60. private const string MAIN_CSS = """
  61. /* Base Reset & Layout */
  62. * { box-sizing: border-box; margin: 0; padding: 0; }
  63. html, body {
  64. height: 100%;
  65. }
  66. body {
  67. font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  68. line-height: 1.6;
  69. background: #f5f7fa;
  70. display: flex;
  71. flex-direction: column;
  72. min-height: 100vh;
  73. }
  74. /* Header */
  75. header {
  76. background: #2c3e50;
  77. color: white;
  78. padding: 1rem 2rem;
  79. display: flex;
  80. justify-content: space-between;
  81. align-items: center;
  82. flex-shrink: 0;
  83. }
  84. header nav a {
  85. color: white;
  86. margin-right: 1.5rem;
  87. text-decoration: none;
  88. font-weight: 500;
  89. transition: opacity 0.2s;
  90. }
  91. header nav a:hover { opacity: 0.8; }
  92. header nav a:last-child { margin-right: 0; }
  93. header .user-info {
  94. font-size: 0.9rem;
  95. }
  96. header .user-info a {
  97. margin-left: 1rem;
  98. color: #3498db;
  99. }
  100. /* Main Content */
  101. main.container {
  102. flex: 1;
  103. max-width: 800px;
  104. width: 100%;
  105. margin: 0 auto;
  106. padding: 2rem 1rem;
  107. }
  108. /* Cards */
  109. .card {
  110. background: white;
  111. border-radius: 12px;
  112. padding: 2rem;
  113. margin-bottom: 1.5rem;
  114. box-shadow: 0 2px 8px rgba(0,0,0,0.08);
  115. }
  116. /* Typography */
  117. h1 { color: #2c3e50; margin-bottom: 1rem; }
  118. h2 { color: #34495e; margin-bottom: 0.75rem; margin-top: 1.5rem; }
  119. p { color: #555; margin-bottom: 1rem; }
  120. ul { margin-left: 1.5rem; margin-bottom: 1rem; }
  121. li { margin-bottom: 0.5rem; color: #555; }
  122. /* Links */
  123. a { color: #3498db; text-decoration: none; transition: color 0.2s; }
  124. a:hover { color: #2980b9; text-decoration: underline; }
  125. /* Forms */
  126. .form-group {
  127. margin-bottom: 1.25rem;
  128. }
  129. .form-group label {
  130. display: block;
  131. margin-bottom: 0.5rem;
  132. font-weight: 500;
  133. color: #34495e;
  134. }
  135. .form-group input {
  136. width: 100%;
  137. padding: 0.75rem;
  138. border: 2px solid #e0e0e0;
  139. border-radius: 6px;
  140. font-size: 1rem;
  141. transition: border-color 0.2s;
  142. }
  143. .form-group input:focus {
  144. outline: none;
  145. border-color: #3498db;
  146. }
  147. .form-group-checkbox {
  148. display: flex;
  149. align-items: center;
  150. gap: 0.5rem;
  151. }
  152. .form-group-checkbox input {
  153. width: auto;
  154. }
  155. .form-group-checkbox label {
  156. margin: 0;
  157. font-weight: normal;
  158. }
  159. /* Buttons */
  160. button, .btn {
  161. background: #3498db;
  162. color: white;
  163. border: none;
  164. padding: 0.75rem 1.5rem;
  165. border-radius: 6px;
  166. cursor: pointer;
  167. font-size: 1rem;
  168. font-weight: 500;
  169. transition: all 0.2s;
  170. text-decoration: none;
  171. display: inline-block;
  172. }
  173. button:hover, .btn:hover {
  174. background: #2980b9;
  175. text-decoration: none;
  176. }
  177. .btn-secondary {
  178. background: #6c757d;
  179. }
  180. .btn-secondary:hover {
  181. background: #545b62;
  182. }
  183. /* Alerts */
  184. .alert {
  185. padding: 1rem;
  186. border-radius: 6px;
  187. margin-bottom: 1rem;
  188. }
  189. .alert-success {
  190. background: #d4edda;
  191. color: #155724;
  192. border: 1px solid #c3e6cb;
  193. }
  194. .alert-error {
  195. background: #f8d7da;
  196. color: #721c24;
  197. border: 1px solid #f5c6cb;
  198. }
  199. .error-message {
  200. color: #dc3545;
  201. font-size: 0.9rem;
  202. margin-top: 0.5rem;
  203. }
  204. /* Login Form */
  205. .spry-login-form {
  206. max-width: 400px;
  207. }
  208. .login-btn {
  209. width: 100%;
  210. margin-top: 1rem;
  211. }
  212. /* Footer */
  213. footer {
  214. background: #2c3e50;
  215. color: rgba(255,255,255,0.7);
  216. padding: 1.5rem;
  217. text-align: center;
  218. flex-shrink: 0;
  219. }
  220. /* Dashboard */
  221. .welcome-section {
  222. margin-bottom: 2rem;
  223. }
  224. .permission-list {
  225. display: flex;
  226. flex-wrap: wrap;
  227. gap: 0.5rem;
  228. margin-top: 0.5rem;
  229. }
  230. .permission-badge {
  231. background: #e9ecef;
  232. color: #495057;
  233. padding: 0.25rem 0.75rem;
  234. border-radius: 20px;
  235. font-size: 0.85rem;
  236. }
  237. .permission-badge.admin {
  238. background: #ffc107;
  239. color: #856404;
  240. }
  241. """;
  242. // =============================================================================
  243. // PAGE TEMPLATE - Provides consistent layout for all pages
  244. // =============================================================================
  245. /**
  246. * MainLayoutTemplate - Base template for all pages
  247. *
  248. * Provides:
  249. * - HTML document structure
  250. * - Common <head> elements (scripts, styles)
  251. * - Site-wide header with navigation (changes based on auth state)
  252. * - Site-wide footer
  253. *
  254. * Uses AuthorisationContext to check authentication status.
  255. */
  256. public class MainLayoutTemplate : PageTemplate {
  257. private AuthorisationContext _auth_context = inject<AuthorisationContext>();
  258. public override string markup { get {
  259. return """
  260. <!DOCTYPE html>
  261. <html lang="en">
  262. <head>
  263. <meta charset="UTF-8">
  264. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  265. <title>Spry Authentication Example</title>
  266. <link rel="stylesheet" href="/styles/main.css">
  267. <script spry-res="htmx.js"></script>
  268. </head>
  269. <body>
  270. <header>
  271. <nav>
  272. <a href="/">Home</a>
  273. <a href="/dashboard">Dashboard</a>
  274. <a href="/admin/users">User Admin</a>
  275. </nav>
  276. <div class="user-info" sid="user-info">
  277. <!-- Will be populated based on auth state -->
  278. </div>
  279. </header>
  280. <main class="container">
  281. <spry-template-outlet />
  282. </main>
  283. <footer>
  284. <p>Built with Spry Framework - Authentication Example</p>
  285. </footer>
  286. </body>
  287. </html>
  288. """;
  289. }}
  290. public override async void prepare() throws Error {
  291. // Check authentication using AuthorisationContext
  292. if (!_auth_context.is_anonymous() && _auth_context.token != null) {
  293. var token = _auth_context.token;
  294. this["user-info"].inner_html = @"Logged in as $(token.username) | <a href=\"/logout\">Logout</a>";
  295. } else {
  296. this["user-info"].inner_html = "<a href=\"/login\">Login</a> | <a href=\"/register\">Register</a>";
  297. }
  298. }
  299. }
  300. // =============================================================================
  301. // PAGE COMPONENTS - Public pages
  302. // =============================================================================
  303. /**
  304. * HomePage - Public landing page
  305. *
  306. * This page is accessible to everyone, authenticated or not.
  307. * It provides an overview of the Authentication system features.
  308. */
  309. public class HomePage : PageComponent {
  310. private AuthorisationContext _auth_context = inject<AuthorisationContext>();
  311. public const string ROUTE = "/";
  312. public override string markup { get {
  313. return """
  314. <div class="card">
  315. <h1>Welcome to the Spry Authentication Example</h1>
  316. <p>This example demonstrates the complete Spry Authentication system including:</p>
  317. <h2>Features</h2>
  318. <ul>
  319. <li><strong>User Registration</strong> - Create new accounts with username/email/password</li>
  320. <li><strong>Authentication</strong> - Login with AuthorisationToken management</li>
  321. <li><strong>Permissions</strong> - Granular permission system with wildcard support</li>
  322. <li><strong>Protected Pages</strong> - Pages that require authentication</li>
  323. <li><strong>User Management</strong> - Admin interface for managing users</li>
  324. <li><strong>Authorisation Context</strong> - Request-scoped permission checking</li>
  325. </ul>
  326. <h2>Try It Out</h2>
  327. <p sid="status-message"></p>
  328. <ul>
  329. <li><a href="/register">Register</a> - Create a new account</li>
  330. <li><a href="/login">Login</a> - Sign in to your account</li>
  331. <li><a href="/dashboard">Dashboard</a> - Protected page (requires login)</li>
  332. <li><a href="/admin/users">User Admin</a> - Admin page (requires permission)</li>
  333. </ul>
  334. </div>
  335. """;
  336. }}
  337. public override async void prepare() throws Error {
  338. // Check if user is authenticated using AuthorisationContext
  339. if (!_auth_context.is_anonymous() && _auth_context.token != null) {
  340. var token = _auth_context.token;
  341. this["status-message"].text_content = @"You are logged in as <strong>$(token.username)</strong>.";
  342. } else {
  343. this["status-message"].text_content = "You are not logged in. Register or login to access protected pages.";
  344. }
  345. }
  346. }
  347. /**
  348. * RegisterPage - User registration page
  349. *
  350. * Allows new users to create an account with username, email, and password.
  351. * Demonstrates direct use of UserService.register_user().
  352. */
  353. public class RegisterPage : PageComponent {
  354. private UserService _user_service = inject<UserService>();
  355. private HttpContext _http_context = inject<HttpContext>();
  356. public string? error_message { get; private set; default = null; }
  357. public string? success_message { get; private set; default = null; }
  358. public string preserved_username { get; private set; default = ""; }
  359. public string preserved_email { get; private set; default = ""; }
  360. public const string ROUTE = "/register";
  361. public override string markup { get {
  362. return """
  363. <div class="card" sid="register-card" hx-swap="outerHTML">
  364. <h1>Create Account</h1>
  365. <div spry-if="this.success_message != null" class="alert alert-success" sid="success-alert">
  366. <span content-expr="this.success_message"></span>
  367. </div>
  368. <form sid="register-form" spry-action=":Register" spry-target="register-card">
  369. <div class="form-group">
  370. <label for="username">Username</label>
  371. <input type="text" name="username" sid="username-input" required
  372. autocomplete="username" placeholder="Choose a username"/>
  373. </div>
  374. <div class="form-group">
  375. <label for="email">Email</label>
  376. <input type="email" name="email" sid="email-input" required
  377. autocomplete="email" placeholder="Enter your email"/>
  378. </div>
  379. <div class="form-group">
  380. <label for="forename">First Name</label>
  381. <input type="text" name="forename" sid="forename-input" required
  382. autocomplete="given-name" placeholder="Enter your first name"/>
  383. </div>
  384. <div class="form-group">
  385. <label for="surname">Last Name</label>
  386. <input type="text" name="surname" sid="surname-input" required
  387. autocomplete="family-name" placeholder="Enter your last name"/>
  388. </div>
  389. <div class="form-group">
  390. <label for="date-of-birth">Date of Birth</label>
  391. <input type="date" name="date_of_birth" sid="dob-input" required
  392. autocomplete="bday" placeholder="YYYY-MM-DD"/>
  393. </div>
  394. <div class="form-group">
  395. <label for="password">Password</label>
  396. <input type="password" name="password" sid="password-input" required
  397. autocomplete="new-password" placeholder="Choose a password"/>
  398. </div>
  399. <div class="form-group">
  400. <label for="confirm-password">Confirm Password</label>
  401. <input type="password" name="confirm_password" sid="confirm-password-input" required
  402. autocomplete="new-password" placeholder="Confirm your password"/>
  403. </div>
  404. <div spry-if="this.error_message != null" class="error-message" sid="error-container">
  405. <span content-expr="this.error_message"></span>
  406. </div>
  407. <button type="submit">Create Account</button>
  408. </form>
  409. <p style="margin-top: 1.5rem;">
  410. Already have an account? <a href="/login">Login here</a>
  411. </p>
  412. </div>
  413. """;
  414. }}
  415. public override async void prepare() throws Error {
  416. // Preserve form values after failed submission
  417. if (preserved_username.length > 0) {
  418. this["username-input"].set_attribute("value", preserved_username);
  419. }
  420. if (preserved_email.length > 0) {
  421. this["email-input"].set_attribute("value", preserved_email);
  422. }
  423. }
  424. public async override void handle_action(string action) throws Error {
  425. if (action == "Register") {
  426. yield handle_register_async();
  427. }
  428. }
  429. private async void handle_register_async() throws Error {
  430. var query = _http_context.request.query_params;
  431. // Get form values
  432. var username = (query.get_any_or_default("username") ?? "").strip();
  433. var email = (query.get_any_or_default("email") ?? "").strip();
  434. var forename = (query.get_any_or_default("forename") ?? "").strip();
  435. var surname = (query.get_any_or_default("surname") ?? "").strip();
  436. var dob_string = query.get_any_or_default("date_of_birth") ?? "";
  437. var password = query.get_any_or_default("password") ?? "";
  438. var confirm_password = query.get_any_or_default("confirm_password") ?? "";
  439. // Preserve values for re-display
  440. preserved_username = username;
  441. preserved_email = email;
  442. // Validate inputs
  443. if (username.length < 3) {
  444. error_message = "Username must be at least 3 characters";
  445. return;
  446. }
  447. if (!email.contains("@") || !email.contains(".")) {
  448. error_message = "Please enter a valid email address";
  449. return;
  450. }
  451. if (forename.length == 0) {
  452. error_message = "Please enter your first name";
  453. return;
  454. }
  455. if (surname.length == 0) {
  456. error_message = "Please enter your last name";
  457. return;
  458. }
  459. // Parse date of birth
  460. DateTime? date_of_birth = null;
  461. if (dob_string.length > 0) {
  462. try {
  463. date_of_birth = new DateTime.from_iso8601(dob_string, new TimeZone.utc());
  464. } catch (Error e) {
  465. error_message = "Please enter a valid date of birth (YYYY-MM-DD)";
  466. return;
  467. }
  468. } else {
  469. error_message = "Please enter your date of birth";
  470. return;
  471. }
  472. if (password.length < 6) {
  473. error_message = "Password must be at least 6 characters";
  474. return;
  475. }
  476. if (password != confirm_password) {
  477. error_message = "Passwords do not match";
  478. return;
  479. }
  480. // Attempt to create user using the new register_user method
  481. try {
  482. var user = yield _user_service.register_user(
  483. username,
  484. email,
  485. forename,
  486. surname,
  487. date_of_birth,
  488. password,
  489. true // enabled
  490. );
  491. // Grant basic permissions to new users
  492. yield _user_service.set_user_permission(user.id, "user.read");
  493. success_message = @"Account created successfully! You can now <a href=\"/login\">login</a>.";
  494. error_message = null;
  495. // Clear preserved values on success
  496. preserved_username = "";
  497. preserved_email = "";
  498. } catch (Error e) {
  499. // Handle duplicate username/email errors
  500. if (e.message.contains("UNIQUE constraint failed") || e.message.contains("duplicate")) {
  501. if (e.message.contains("username")) {
  502. error_message = "Username already exists. Please choose another.";
  503. } else if (e.message.contains("email")) {
  504. error_message = "Email already registered. Please use another or login.";
  505. } else {
  506. error_message = "A user with this information already exists.";
  507. }
  508. } else {
  509. error_message = "Registration failed: %s".printf(e.message);
  510. }
  511. }
  512. }
  513. }
  514. // =============================================================================
  515. // PAGE COMPONENTS - Authentication pages
  516. // =============================================================================
  517. /**
  518. * LoginPage - Login page using the built-in LoginFormComponent
  519. *
  520. * This page demonstrates how to use the LoginFormComponent for authentication.
  521. * The component handles:
  522. * - Form display and validation
  523. * - Authentication via UserService.authenticate_user()
  524. * - Cookie management via AuthorisationService
  525. * - Redirect after successful login
  526. */
  527. public class LoginPage : PageComponent {
  528. private ComponentFactory _factory = inject<ComponentFactory>();
  529. private LoginFormComponent _login_form;
  530. public const string ROUTE = "/login";
  531. public override string markup { get {
  532. return """
  533. <div class="card">
  534. <h1>Login</h1>
  535. <spry-outlet sid="login-form-outlet"/>
  536. <p style="margin-top: 1.5rem;">
  537. Don't have an account? <a href="/register">Register here</a>
  538. </p>
  539. </div>
  540. """;
  541. }}
  542. public override async void prepare() throws Error {
  543. // Create and configure the login form component
  544. _login_form = _factory.create<LoginFormComponent>();
  545. _login_form.redirect_url = "/dashboard"; // Redirect here after successful login
  546. // Add the form to our outlet
  547. add_outlet_child("login-form-outlet", _login_form);
  548. // Share globals with the login form (required for action handling)
  549. add_globals_from(_login_form);
  550. }
  551. }
  552. /**
  553. * LogoutEndpoint - Handles logout by clearing the authorisation cookie
  554. *
  555. * This demonstrates:
  556. * - Simply clearing the authorisation cookie
  557. * - Returning a redirect response
  558. *
  559. * Note: In the refined authentication system, there's no server-side session
  560. * to delete - authentication is stateless using signed tokens.
  561. */
  562. public class LogoutEndpoint : Object, Endpoint {
  563. public async HttpResult handle_request(HttpContext http_context, RouteContext route_context) throws Error {
  564. // Create redirect result with Location header
  565. var result = new HttpStringResult("Redirecting to home page...", 302);
  566. result.set_header("Location", "/");
  567. // Clear the authorisation cookie by setting it to empty with an expired date
  568. result.headers.add("Set-Cookie", "_spry-authorisation=; Secure; Max-Age=0");
  569. return result;
  570. }
  571. }
  572. // =============================================================================
  573. // PAGE COMPONENTS - Protected pages
  574. // =============================================================================
  575. /**
  576. * DashboardPage - Protected dashboard page
  577. *
  578. * This page demonstrates:
  579. * - Checking authentication using AuthorisationContext
  580. * - Redirecting unauthenticated users to login
  581. * - Accessing the current user's information from AuthorisationToken
  582. * - Checking permissions using AuthorisationContext
  583. * - Displaying user-specific content
  584. */
  585. public class DashboardPage : PageComponent {
  586. private AuthorisationContext _auth_context = inject<AuthorisationContext>();
  587. public const string ROUTE = "/dashboard";
  588. public override string markup { get {
  589. return """
  590. <div class="card">
  591. <!-- Not authenticated message -->
  592. <div spry-if="this.is_anonymous" class="alert alert-error">
  593. <h2>Authentication Required</h2>
  594. <p>You must be logged in to view this page.</p>
  595. <p style="margin-top: 1rem;">
  596. <a href="/login" class="btn">Login</a>
  597. <a href="/register" class="btn btn-secondary" style="margin-left: 0.5rem;">Register</a>
  598. </p>
  599. </div>
  600. <!-- Authenticated content -->
  601. <div spry-if="!this.is_anonymous">
  602. <div class="welcome-section">
  603. <h1 sid="welcome-heading">Dashboard</h1>
  604. <p>Welcome to your dashboard! This page demonstrates protected content.</p>
  605. </div>
  606. <h2>Your Profile</h2>
  607. <ul>
  608. <li><strong>Username:</strong> <span sid="username"></span></li>
  609. <li><strong>Email:</strong> <span sid="email"></span></li>
  610. <li><strong>User ID:</strong> <span sid="user-id"></span></li>
  611. <li><strong>Account Created:</strong> <span sid="created-at"></span></li>
  612. </ul>
  613. <h2>Your Permissions</h2>
  614. <div class="permission-list" sid="permission-list">
  615. <!-- Permissions will be listed here -->
  616. </div>
  617. <div spry-if="this.is_admin" class="alert alert-success" style="margin-top: 1.5rem;">
  618. <strong>Admin Access:</strong> You have admin privileges.
  619. <a href="/admin/users" style="color: #155724;">Go to User Management</a>
  620. </div>
  621. <p style="margin-top: 1.5rem;">
  622. <a href="/" class="btn btn-secondary">Back to Home</a>
  623. </p>
  624. </div>
  625. </div>
  626. """;
  627. }}
  628. // Track if user is authenticated (for template binding)
  629. public bool is_anonymous { get; private set; default = true; }
  630. // Track if user has admin permission
  631. public bool is_admin { get; private set; default = false; }
  632. public override async void prepare() throws Error {
  633. // Check authentication using AuthorisationContext
  634. if (_auth_context.is_anonymous()) {
  635. // Not authenticated - show message and link to login
  636. is_anonymous = true;
  637. return;
  638. }
  639. is_anonymous = false;
  640. var token = _auth_context.token;
  641. if (token == null) {
  642. is_anonymous = true;
  643. return;
  644. }
  645. // Check permissions using AuthorisationContext
  646. is_admin = _auth_context.has_permission("admin");
  647. // Populate user info from the token
  648. this["welcome-heading"].text_content = @"Welcome, $(token.username)!";
  649. this["username"].text_content = token.username;
  650. this["user-id"].text_content = token.user_identifier.to_string();
  651. // Get additional user data from the token's data properties
  652. var user_data = token.data;
  653. string? email_val = null;
  654. DateTime? created_val = null;
  655. // Try to get email and created from properties
  656. try {
  657. var email_element = user_data.get("email");
  658. email_val = email_element.as_string_or_null();
  659. } catch (Error e) {
  660. // Email not available
  661. }
  662. try {
  663. var created_element = user_data.get("created");
  664. created_val = created_element.as<DateTime>();
  665. } catch (Error e) {
  666. // Created not available
  667. }
  668. this["email"].text_content = email_val ?? "N/A";
  669. this["created-at"].text_content = created_val != null ?
  670. ((DateTime)created_val).format("%Y-%m-%d %H:%M:%S UTC") : "N/A";
  671. // Populate permissions from the token
  672. var perm_text = "";
  673. var user_permissions = token.permissions;
  674. int perm_count = 0;
  675. foreach (var perm in user_permissions) {
  676. var badge_class = perm == "admin" ? "permission-badge admin" : "permission-badge";
  677. perm_text += @"<span class=\"$badge_class\">$perm</span> ";
  678. perm_count++;
  679. }
  680. if (perm_count == 0) {
  681. perm_text = "<span class=\"permission-badge\">No permissions assigned</span>";
  682. }
  683. this["permission-list"].inner_html = perm_text;
  684. }
  685. }
  686. // =============================================================================
  687. // PAGE COMPONENTS - Admin pages
  688. // =============================================================================
  689. /**
  690. * UserAdminPage - Admin page for user management
  691. *
  692. * This page demonstrates how to use UserManagementComponent within your own
  693. * page layout. Unlike the old UserManagementPage (which was a PageComponent
  694. * that generated a full HTML document), UserManagementComponent is a regular
  695. * Component that can be placed anywhere.
  696. *
  697. * Applications now have full control over:
  698. * - The page layout and navigation
  699. * - CSS styling via their own stylesheets
  700. * - Where the user management component appears
  701. */
  702. public class UserAdminPage : PageComponent {
  703. private ComponentFactory _factory = inject<ComponentFactory>();
  704. private UserManagementComponent _user_management;
  705. public const string ROUTE = "/admin/users";
  706. public override string markup { get {
  707. return """
  708. <div class="card">
  709. <h1>User Administration</h1>
  710. <p>Manage user accounts, permissions, and access control.</p>
  711. <spry-outlet sid="user-management-outlet"/>
  712. </div>
  713. """;
  714. }}
  715. public override async void prepare() throws Error {
  716. // Create the user management component
  717. _user_management = _factory.create<UserManagementComponent>();
  718. // Pass the application-defined permissions to the component
  719. // This allows the component to display appropriate checkboxes
  720. _user_management.available_permissions = get_application_permissions();
  721. // Add it to our outlet
  722. add_outlet_child("user-management-outlet", _user_management);
  723. // Share globals with the component (required for action handling)
  724. add_globals_from(_user_management);
  725. }
  726. }
  727. // =============================================================================
  728. // SEED DATA - Creates initial admin user
  729. // =============================================================================
  730. /**
  731. * SeedData - Creates initial users for testing
  732. *
  733. * This demonstrates how to:
  734. * - Check if users exist before creating
  735. * - Create users programmatically using register_user()
  736. * - Grant permissions to users using set_user_permission()
  737. */
  738. public class SeedData : Object {
  739. public static async void ensure_admin_exists(UserService user_service) throws Error {
  740. // Try to get list of users and check if admin exists
  741. var users = yield user_service.list_users(0, 100);
  742. bool admin_exists = false;
  743. bool testuser_exists = false;
  744. int64? admin_id = null;
  745. int64? testuser_id = null;
  746. foreach (var user in users) {
  747. if (user.username == "admin") {
  748. admin_exists = true;
  749. admin_id = user.id;
  750. }
  751. if (user.username == "testuser") {
  752. testuser_exists = true;
  753. testuser_id = user.id;
  754. }
  755. }
  756. if (admin_exists) {
  757. print("Admin user already exists\n");
  758. } else {
  759. print("Creating admin user...\n");
  760. // Create admin user using register_user
  761. var admin_user = yield user_service.register_user(
  762. "admin",
  763. "admin@example.com",
  764. "Admin",
  765. "User",
  766. new DateTime.utc(1990, 1, 1, 0, 0, 0.0),
  767. "admin123",
  768. true
  769. );
  770. admin_id = admin_user.id;
  771. // Grant admin permissions
  772. yield user_service.set_user_permission(admin_id, "admin");
  773. yield user_service.set_user_permission(admin_id, "user-management");
  774. yield user_service.set_user_permission(admin_id, "user.create");
  775. yield user_service.set_user_permission(admin_id, "user.read");
  776. yield user_service.set_user_permission(admin_id, "user.update");
  777. yield user_service.set_user_permission(admin_id, "user.delete");
  778. print("Admin user created with username 'admin' and password 'admin123'\n");
  779. }
  780. if (!testuser_exists) {
  781. print("Creating test user...\n");
  782. var test_user = yield user_service.register_user(
  783. "testuser",
  784. "test@example.com",
  785. "Test",
  786. "User",
  787. new DateTime.utc(1995, 6, 15, 0, 0, 0.0),
  788. "test123",
  789. true
  790. );
  791. testuser_id = test_user.id;
  792. yield user_service.set_user_permission(testuser_id, "user.read");
  793. print("Test user created with username 'testuser' and password 'test123'\n");
  794. } else {
  795. print("Test user already exists\n");
  796. }
  797. }
  798. }
  799. // =============================================================================
  800. // APPLICATION SETUP
  801. // =============================================================================
  802. // =============================================================================
  803. // ASYNC MAIN LOOP - Required for async initialization
  804. // =============================================================================
  805. private MainLoop main_loop;
  806. public static int main(string[] args) {
  807. int port = args.length > 1 ? int.parse(args[1]) : 8080;
  808. print("═══════════════════════════════════════════════════════════════\n");
  809. print(" Spry Authentication Example - Complete Demo\n");
  810. print("═══════════════════════════════════════════════════════════════\n");
  811. print(" Port: %d\n", port);
  812. print("═══════════════════════════════════════════════════════════════\n");
  813. print(" Public Endpoints:\n");
  814. print(" / - Home page (public)\n");
  815. print(" /register - Create new account\n");
  816. print(" /login - Login page\n");
  817. print("═══════════════════════════════════════════════════════════════\n");
  818. print(" Protected Endpoints (requires login):\n");
  819. print(" /dashboard - User dashboard\n");
  820. print(" /logout - Logout and clear cookie\n");
  821. print("═══════════════════════════════════════════════════════════════\n");
  822. print(" Admin Endpoints (requires 'user-management' permission):\n");
  823. print(" /admin/users - User management page\n");
  824. print("═══════════════════════════════════════════════════════════════\n");
  825. print(" Default Users:\n");
  826. print(" admin / admin123 - Has all permissions\n");
  827. print(" testuser / test123 - Regular user\n");
  828. print("═══════════════════════════════════════════════════════════════\n");
  829. print("\nPress Ctrl+C to stop the server\n\n");
  830. main_loop = new MainLoop();
  831. try {
  832. start_application.begin(port, (obj, res) => {
  833. try {
  834. start_application.end(res);
  835. } catch (Error e) {
  836. printerr("Application error: %s\n", e.message);
  837. main_loop.quit();
  838. }
  839. });
  840. main_loop.run();
  841. return 0;
  842. } catch (Error e) {
  843. printerr("Error: %s\n", e.message);
  844. return 1;
  845. }
  846. }
  847. /**
  848. * Start the web application.
  849. */
  850. private async void start_application(int port) throws Error {
  851. var application = new WebApplication(port);
  852. // Enable compression
  853. application.use_compression();
  854. // Add Spry module for component actions
  855. application.add_module<SpryModule>();
  856. // Configure the database using InvercargillSqlInversion
  857. var db_config = application.container.configure_with<DatabaseConfigurator>();
  858. db_config.register_connection(@"sqlite://./db.sqlite");
  859. db_config.migrate_on_startup();
  860. // Add authentication module to register entity mappings
  861. application.add_module<AuthenticationModule>();
  862. // Register Authentication system services
  863. application.add_scoped<UserService>();
  864. // Register Authorisation system services
  865. application.add_scoped<AuthorisationService>();
  866. // Add the authorisation pipeline component to read tokens from requests
  867. // This component creates a scoped AuthorisationContext per request
  868. application.add_scoped<AuthorisationPipelineComponent>()
  869. .as<Astralis.PipelineComponent>();
  870. // Register template with route prefix
  871. // MainLayoutTemplate applies to ALL routes (empty prefix)
  872. var spry_cfg = application.configure_with<SpryConfigurator>();
  873. spry_cfg.add_template<MainLayoutTemplate>("");
  874. // Register page components as endpoints
  875. application.add_transient<HomePage>();
  876. application.add_endpoint<HomePage>(new EndpointRoute(HomePage.ROUTE));
  877. application.add_transient<RegisterPage>();
  878. application.add_endpoint<RegisterPage>(new EndpointRoute(RegisterPage.ROUTE));
  879. application.add_transient<LoginPage>();
  880. application.add_endpoint<LoginPage>(new EndpointRoute(LoginPage.ROUTE));
  881. application.add_transient<DashboardPage>();
  882. application.add_endpoint<DashboardPage>(new EndpointRoute(DashboardPage.ROUTE));
  883. // Register logout endpoint
  884. application.add_endpoint<LogoutEndpoint>(new EndpointRoute("/logout"));
  885. // Register UserAdminPage (admin page wrapping UserManagementComponent)
  886. application.add_transient<UserAdminPage>();
  887. application.add_endpoint<UserAdminPage>(new EndpointRoute(UserAdminPage.ROUTE));
  888. // Register LoginFormComponent (used by LoginPage)
  889. application.add_transient<LoginFormComponent>();
  890. // Register new user management components
  891. application.add_transient<UserManagementComponent>();
  892. application.add_transient<UserDetailsComponent>();
  893. application.add_transient<NewUserComponent>();
  894. // Register CSS as FastResource
  895. application.add_startup_endpoint<FastResource>(new EndpointRoute("/styles/main.css"), () => {
  896. try {
  897. return new FastResource.from_string(MAIN_CSS)
  898. .with_content_type("text/css; charset=utf-8")
  899. .with_default_compressors();
  900. } catch (Error e) {
  901. error("Failed to create main CSS resource: %s", e.message);
  902. }
  903. });
  904. print("Starting web server on port %d...\n\n", port);
  905. // Seed initial data after migrations have run (migrations execute during application.run())
  906. // Use Idle.add() to ensure this runs after the main loop starts
  907. GLib.Idle.add(() => {
  908. seed_initial_data.begin(application.container);
  909. return false; // Don't repeat
  910. });
  911. application.run();
  912. }
  913. /**
  914. * Seed initial data (admin and test users).
  915. */
  916. private async void seed_initial_data(Container container) {
  917. try {
  918. var scope = container.create_scope();
  919. var user_service = scope.resolve<UserService>();
  920. yield SeedData.ensure_admin_exists(user_service);
  921. } catch (Error e) {
  922. printerr("Warning: Failed to seed initial data: %s\n", e.message);
  923. }
  924. }