ProfileEntrypoint.vala 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. using Invercargill;
  2. using Invercargill.DataStructures;
  3. using Spry;
  4. using Spry.Authentication;
  5. using Statum;
  6. using ClankerSaddle.Services;
  7. namespace ClankerSaddle {
  8. /** The logged-in user's own profile card on /profile. */
  9. public class ProfileView : Object {
  10. public string username { get; set; default = ""; }
  11. public string email { get; set; default = ""; }
  12. public string name_display { get; set; default = ""; }
  13. public bool is_admin { get; set; }
  14. public string created_display { get; set; default = ""; }
  15. public Statum.Model.ActionDto change_password { get; set; }
  16. /** The stored theme slug ("" = the Spry core default). */
  17. public string theme { get; set; default = ""; }
  18. /** The stored colour mode (auto|light|dark). */
  19. public string mode { get; set; default = MODE_DEFAULT; }
  20. public Statum.Model.ActionDto set_theme { get; set; }
  21. /**
  22. * The VAPID public key the browser subscribes with ("" when
  23. * push is unavailable) — goal 41 task 3: the bell preference
  24. * UI moved from the dashboard's opener card to this page's
  25. * Notifications card, riding the SAME dashboard push actions
  26. * (PushService untouched).
  27. */
  28. public string push_vapid_key { get; set; default = ""; }
  29. public Statum.Model.ActionDto? push_subscribe { get; set; }
  30. public Statum.Model.ActionDto? push_unsubscribe { get; set; }
  31. /** Queues a REAL test push to this user (the bell's probe). */
  32. public Statum.Model.ActionDto? push_test { get; set; }
  33. }
  34. /**
  35. * Entrypoint for /profile: the logged-in user's own account (name,
  36. * email, username, role), the change-own-password form and the
  37. * appearance card (goal 41 task 2 — the Spry theme picker). Open to
  38. * every logged-in user; the store row (not the signed login
  39. * snapshot) is read so a renamed account shows its current profile,
  40. * and the theme/mode fields mirror the stored preferences so the
  41. * picker opens on the choice this device will paint.
  42. */
  43. public class ProfileEntrypoint : StatumEntrypoint {
  44. private UserService users_service = Inversion.inject<UserService>();
  45. private UserPreferenceService preferences = Inversion.inject<UserPreferenceService>();
  46. private PushService push_service = Inversion.inject<PushService>();
  47. private InvercargillSql.Orm.OrmSession db = Inversion.inject<InvercargillSql.Orm.OrmSession>();
  48. public override async DirectiveBuilder handle() throws GLib.Error {
  49. var guard = yield SpryAuth.require_login(directives(), request, users_service);
  50. if (guard != null) {
  51. return (!)guard;
  52. }
  53. var me = SpryAuth.current(request);
  54. var user = yield users_service.get_user(me.user_id);
  55. if (user == null) {
  56. // The session slot outlives a deleted account; the guard
  57. // re-validation above already handled disabled ones, so
  58. // this only trips a mid-request delete. Bounce to login.
  59. return directives().notify("error", "Your account no longer exists.").navigate("/login");
  60. }
  61. var slot = yield build_profile_slot(state_service, action_registry,
  62. users_service, preferences, me, (!)user, push_service, db);
  63. return directives().set(slot);
  64. }
  65. /**
  66. * The page's PAGE-scope profile slot. Shared with
  67. * {@link SetThemeAction} so the save answers with the REFRESHED
  68. * card (the same trick as the dashboard actions): the client
  69. * re-renders every binding after a directive chain, and without
  70. * this the theme radios would snap back against the stale
  71. * pre-save values.
  72. */
  73. public static async Statum.Slot build_profile_slot(StateService state_service,
  74. ActionRegistry action_registry, UserService users_service,
  75. UserPreferenceService preferences, CurrentUser me, UserProjection user,
  76. PushService? push = null, InvercargillSql.Orm.OrmSession? db = null)
  77. throws GLib.Error {
  78. // The appearance card's current values: the stored
  79. // preferences (missing rows read as the defaults — the
  80. // core theme, auto mode).
  81. string theme = "";
  82. string mode = MODE_DEFAULT;
  83. try {
  84. theme = yield preferences.get_theme(me.user_id);
  85. mode = yield preferences.get_mode(me.user_id);
  86. } catch (Error e) {
  87. warning(@"[ClankerSaddle] could not read the appearance preferences: $(e.message)");
  88. }
  89. var view = new ProfileView() {
  90. username = user.username,
  91. email = user.email,
  92. name_display = @"$(user.forename) $(user.surname)".strip(),
  93. is_admin = SpryAuth.permission_matches(user.permissions.to_array(),
  94. SpryAuth.ADMIN_PERMISSION),
  95. created_display = format_stamp(user.created),
  96. change_password = action_registry.author<ChangeMyPasswordAction>(),
  97. theme = theme,
  98. mode = mode,
  99. set_theme = action_registry.author<SetThemeAction>()
  100. };
  101. // The Notifications card (goal 41 task 3): the same facts
  102. // the dashboard's bell strip derives its per-browser verdict
  103. // from, so /profile owns the preference UI while the
  104. // dashboard keeps only the compact strip. Reuses the
  105. // EXISTING dashboard push actions. The push service and db
  106. // session arrive as parameters (SetThemeAction's scope may
  107. // carry them; a static method cannot Inversion.inject — the
  108. // context stack is per-request). The VAPID key comes from
  109. // Spry's sender via the service ("" when unkeyed).
  110. view.push_vapid_key = push != null
  111. ? ((!)push).vapid_public_key() : "";
  112. view.push_subscribe = action_registry.author<PushSubscribeAction>();
  113. view.push_unsubscribe = action_registry.author<PushUnsubscribeAction>();
  114. view.push_test = action_registry.author<PushTestAction>();
  115. string[] fingerprints = new string[0];
  116. if (push != null && db != null) {
  117. try {
  118. fingerprints = yield ((!)push)
  119. .user_endpoint_fingerprints((!)db, me.user_id);
  120. } catch (Error e) {
  121. warning(@"[ClankerSaddle] could not read push endpoints: $(e.message)");
  122. }
  123. }
  124. var props = (PropertyDictionary) GObjectMapping.to_properties(view);
  125. // The fingerprint list is array-shaped — GObjectMapping
  126. // cannot serialise arrays as properties (the same
  127. // constraint as the dashboard's lists), so it rides the
  128. // props dictionary under the SAME key the bell engine
  129. // reads on the dashboard slot.
  130. props["push_endpoints"] = StateJson.strings(fingerprints);
  131. return state_service.new_slot(Scope.PAGE, new State() {
  132. type_name = "profile",
  133. public_data = props,
  134. private_data = new PropertyDictionary()
  135. });
  136. }
  137. }
  138. }