using Invercargill; using Invercargill.DataStructures; using Spry; using Spry.Authentication; using Statum; using ClankerSaddle.Services; namespace ClankerSaddle { /** The logged-in user's own profile card on /profile. */ public class ProfileView : Object { public string username { get; set; default = ""; } public string email { get; set; default = ""; } public string name_display { get; set; default = ""; } public bool is_admin { get; set; } public string created_display { get; set; default = ""; } public Statum.Model.ActionDto change_password { get; set; } /** The stored theme slug ("" = the Spry core default). */ public string theme { get; set; default = ""; } /** The stored colour mode (auto|light|dark). */ public string mode { get; set; default = MODE_DEFAULT; } public Statum.Model.ActionDto set_theme { get; set; } /** * The VAPID public key the browser subscribes with ("" when * push is unavailable) — goal 41 task 3: the bell preference * UI moved from the dashboard's opener card to this page's * Notifications card, riding the SAME dashboard push actions * (PushService untouched). */ public string push_vapid_key { get; set; default = ""; } public Statum.Model.ActionDto? push_subscribe { get; set; } public Statum.Model.ActionDto? push_unsubscribe { get; set; } /** Queues a REAL test push to this user (the bell's probe). */ public Statum.Model.ActionDto? push_test { get; set; } } /** * Entrypoint for /profile: the logged-in user's own account (name, * email, username, role), the change-own-password form and the * appearance card (goal 41 task 2 — the Spry theme picker). Open to * every logged-in user; the store row (not the signed login * snapshot) is read so a renamed account shows its current profile, * and the theme/mode fields mirror the stored preferences so the * picker opens on the choice this device will paint. */ public class ProfileEntrypoint : StatumEntrypoint { private UserService users_service = Inversion.inject(); private UserPreferenceService preferences = Inversion.inject(); private PushService push_service = Inversion.inject(); private InvercargillSql.Orm.OrmSession db = Inversion.inject(); public override async DirectiveBuilder handle() throws GLib.Error { var guard = yield SpryAuth.require_login(directives(), request, users_service); if (guard != null) { return (!)guard; } var me = SpryAuth.current(request); var user = yield users_service.get_user(me.user_id); if (user == null) { // The session slot outlives a deleted account; the guard // re-validation above already handled disabled ones, so // this only trips a mid-request delete. Bounce to login. return directives().notify("error", "Your account no longer exists.").navigate("/login"); } var slot = yield build_profile_slot(state_service, action_registry, users_service, preferences, me, (!)user, push_service, db); return directives().set(slot); } /** * The page's PAGE-scope profile slot. Shared with * {@link SetThemeAction} so the save answers with the REFRESHED * card (the same trick as the dashboard actions): the client * re-renders every binding after a directive chain, and without * this the theme radios would snap back against the stale * pre-save values. */ public static async Statum.Slot build_profile_slot(StateService state_service, ActionRegistry action_registry, UserService users_service, UserPreferenceService preferences, CurrentUser me, UserProjection user, PushService? push = null, InvercargillSql.Orm.OrmSession? db = null) throws GLib.Error { // The appearance card's current values: the stored // preferences (missing rows read as the defaults — the // core theme, auto mode). string theme = ""; string mode = MODE_DEFAULT; try { theme = yield preferences.get_theme(me.user_id); mode = yield preferences.get_mode(me.user_id); } catch (Error e) { warning(@"[ClankerSaddle] could not read the appearance preferences: $(e.message)"); } var view = new ProfileView() { username = user.username, email = user.email, name_display = @"$(user.forename) $(user.surname)".strip(), is_admin = SpryAuth.permission_matches(user.permissions.to_array(), SpryAuth.ADMIN_PERMISSION), created_display = format_stamp(user.created), change_password = action_registry.author(), theme = theme, mode = mode, set_theme = action_registry.author() }; // The Notifications card (goal 41 task 3): the same facts // the dashboard's bell strip derives its per-browser verdict // from, so /profile owns the preference UI while the // dashboard keeps only the compact strip. Reuses the // EXISTING dashboard push actions. The push service and db // session arrive as parameters (SetThemeAction's scope may // carry them; a static method cannot Inversion.inject — the // context stack is per-request). The VAPID key comes from // Spry's sender via the service ("" when unkeyed). view.push_vapid_key = push != null ? ((!)push).vapid_public_key() : ""; view.push_subscribe = action_registry.author(); view.push_unsubscribe = action_registry.author(); view.push_test = action_registry.author(); string[] fingerprints = new string[0]; if (push != null && db != null) { try { fingerprints = yield ((!)push) .user_endpoint_fingerprints((!)db, me.user_id); } catch (Error e) { warning(@"[ClankerSaddle] could not read push endpoints: $(e.message)"); } } var props = (PropertyDictionary) GObjectMapping.to_properties(view); // The fingerprint list is array-shaped — GObjectMapping // cannot serialise arrays as properties (the same // constraint as the dashboard's lists), so it rides the // props dictionary under the SAME key the bell engine // reads on the dashboard slot. props["push_endpoints"] = StateJson.strings(fingerprints); return state_service.new_slot(Scope.PAGE, new State() { type_name = "profile", public_data = props, private_data = new PropertyDictionary() }); } } }