UsersExample.vala 38 KB

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