diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 4657d8a99..7f2aa6785 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -2,8 +2,13 @@ class PagesController < ApplicationController include Periodable skip_authentication only: %i[redis_configuration_error privacy terms] + before_action :ensure_intro_guest!, only: :intro def dashboard + if Current.user&.ui_layout_intro? + redirect_to chats_path and return + end + @balance_sheet = Current.family.balance_sheet @investment_statement = Current.family.investment_statement @accounts = Current.family.accounts.visible.with_attached_logo @@ -22,6 +27,10 @@ class PagesController < ApplicationController @breadcrumbs = [ [ "Home", root_path ], [ "Dashboard", nil ] ] end + def intro + @breadcrumbs = [ [ "Home", chats_path ], [ "Intro", nil ] ] + end + def update_preferences if Current.user.update_dashboard_preferences(preferences_params) head :ok @@ -268,4 +277,10 @@ class PagesController < ApplicationController end end end + + def ensure_intro_guest! + return if Current.user&.guest? + + redirect_to root_path, alert: t("pages.intro.not_authorized", default: "Intro is only available to guest users.") + end end diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 2839b60e0..6e8dbbf0e 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -1,5 +1,5 @@ class Settings::ProfilesController < ApplicationController - layout "settings" + layout :layout_for_settings_profile def show @user = Current.user @@ -36,4 +36,10 @@ class Settings::ProfilesController < ApplicationController redirect_to settings_profile_path end + + private + + def layout_for_settings_profile + Current.user&.ui_layout_intro? ? "application" : "settings" + end end diff --git a/app/models/assistant/configurable.rb b/app/models/assistant/configurable.rb index 2aae1eb06..e0122e38e 100644 --- a/app/models/assistant/configurable.rb +++ b/app/models/assistant/configurable.rb @@ -6,13 +6,51 @@ module Assistant::Configurable preferred_currency = Money::Currency.new(chat.user.family.currency) preferred_date_format = chat.user.family.date_format - { - instructions: default_instructions(preferred_currency, preferred_date_format), - functions: default_functions - } + if chat.user.ui_layout_intro? + { + instructions: intro_instructions(preferred_currency, preferred_date_format), + functions: [] + } + else + { + instructions: default_instructions(preferred_currency, preferred_date_format), + functions: default_functions + } + end end private + def intro_instructions(preferred_currency, preferred_date_format) + <<~PROMPT + ## Your identity + + You are Sure, a warm and curious financial guide welcoming a new household to the Sure personal finance application. + + ## Your purpose + + Host an introductory conversation that helps you understand the user's stage of life, financial responsibilities, and near-term priorities so future guidance feels personal and relevant. + + ## Conversation approach + + - Ask one thoughtful question at a time and tailor follow-ups based on what the user shares. + - Reflect key details back to the user to confirm understanding. + - Keep responses concise, friendly, and free of filler phrases. + - If the user requests detailed analytics, let them know the dashboard experience will cover it soon and guide them back to sharing context. + + ## Information to uncover + + - Household composition and stage of life milestones (education, career, retirement, dependents, caregiving, etc.). + - Primary financial goals, concerns, and timelines. + - Notable upcoming events or obligations. + + ## Formatting guidelines + + - Use markdown for any lists or emphasis. + - When money or timeframes are discussed, format currency with #{preferred_currency.symbol} (#{preferred_currency.iso_code}) and dates using #{preferred_date_format}. + - Do not call external tools or functions. + PROMPT + end + def default_functions [ Assistant::Function::GetTransactions, diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 3ff1051a0..d44b16c83 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -171,7 +171,7 @@ class Demo::Generator onboarded_at: onboarded ? Time.current : nil ) - # Member user + # Family member user family.users.create!( email: "partner_#{email}", first_name: "Eve", diff --git a/app/models/invitation.rb b/app/models/invitation.rb index fcf1d046d..afafd7852 100644 --- a/app/models/invitation.rb +++ b/app/models/invitation.rb @@ -11,7 +11,7 @@ class Invitation < ApplicationRecord end validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP } - validates :role, presence: true, inclusion: { in: %w[admin member] } + validates :role, presence: true, inclusion: { in: %w[admin member guest] } validates :token, presence: true, uniqueness: true validates_uniqueness_of :email, scope: :family_id, message: "has already been invited to this family" validate :inviter_is_admin @@ -32,7 +32,7 @@ class Invitation < ApplicationRecord return false unless emails_match?(user) transaction do - user.update!(family_id: family_id, role: role) + user.update!(family_id: family_id, role: role.to_s) update!(accepted_at: Time.current) end true diff --git a/app/models/sso_provider.rb b/app/models/sso_provider.rb index 8bf8dbc09..5b38d182b 100644 --- a/app/models/sso_provider.rb +++ b/app/models/sso_provider.rb @@ -104,11 +104,12 @@ class SsoProvider < ApplicationRecord end def validate_default_role_setting - default_role = settings&.dig("default_role") + default_role = settings&.dig("default_role") || settings&.dig(:default_role) + default_role = default_role.to_s return if default_role.blank? unless User.roles.key?(default_role) - errors.add(:settings, "default_role must be member, admin, or super_admin") + errors.add(:settings, "default_role must be guest, member, admin, or super_admin") end end diff --git a/app/models/user.rb b/app/models/user.rb index df8f9fc5e..5aef7afeb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -50,7 +50,11 @@ class User < ApplicationRecord normalizes :first_name, :last_name, with: ->(value) { value.strip.presence } - enum :role, { member: "member", admin: "admin", super_admin: "super_admin" }, validate: true + enum :role, { guest: "guest", member: "member", admin: "admin", super_admin: "super_admin" }, validate: true + enum :ui_layout, { dashboard: "dashboard", intro: "intro" }, validate: true, prefix: true + + before_validation :apply_ui_layout_defaults + before_validation :apply_role_based_ui_defaults # Returns the appropriate role for a new user creating a family. # The very first user of an instance becomes super_admin; subsequent users @@ -139,6 +143,11 @@ class User < ApplicationRecord ai_enabled && ai_available? end + def self.default_ui_layout + layout = Rails.application.config.x.ui&.default_layout || "dashboard" + layout.in?(%w[intro dashboard]) ? layout : "dashboard" + end + # SSO-only users have OIDC identities but no local password. # They cannot use password reset or local login. def sso_only? @@ -307,6 +316,39 @@ class User < ApplicationRecord end private + def apply_ui_layout_defaults + self.ui_layout = (ui_layout.presence || self.class.default_ui_layout) + end + + def apply_role_based_ui_defaults + if ui_layout_intro? + if guest? + self.show_sidebar = false + self.show_ai_sidebar = false + self.ai_enabled = true + else + self.ui_layout = "dashboard" + end + elsif guest? + self.ui_layout = "intro" + self.show_sidebar = false + self.show_ai_sidebar = false + self.ai_enabled = true + end + + if leaving_guest_role? + self.show_sidebar = true unless show_sidebar + self.show_ai_sidebar = true unless show_ai_sidebar + end + end + + def leaving_guest_role? + return false unless will_save_change_to_role? + + previous_role, new_role = role_change_to_be_saved + previous_role == "guest" && new_role != "guest" + end + def skip_password_validation? skip_password_validation == true end diff --git a/app/views/admin/sso_providers/_form.html.erb b/app/views/admin/sso_providers/_form.html.erb index 590ba740e..5ccdc3b87 100644 --- a/app/views/admin/sso_providers/_form.html.erb +++ b/app/views/admin/sso_providers/_form.html.erb @@ -192,10 +192,11 @@ <%= form.select "settings[default_role]", options_for_select([ + [t("admin.sso_providers.form.role_guest", default: "Guest"), "guest"], [t("admin.sso_providers.form.role_member"), "member"], [t("admin.sso_providers.form.role_admin"), "admin"], [t("admin.sso_providers.form.role_super_admin"), "super_admin"] - ], sso_provider.settings&.dig("default_role") || "member"), + ], sso_provider.settings&.dig("default_role").to_s.presence || "member"), { label: t("admin.sso_providers.form.default_role_label"), include_blank: false } %>

<%= t("admin.sso_providers.form.default_role_help") %>

@@ -231,6 +232,15 @@ placeholder="* (all groups)" autocomplete="off"> + +
+ + " + class="w-full px-3 py-2 border border-primary rounded-lg text-sm" + placeholder="Early-Access-Guests" + autocomplete="off"> +
diff --git a/app/views/admin/users/index.html.erb b/app/views/admin/users/index.html.erb index 551cd4d10..002e1a06c 100644 --- a/app/views/admin/users/index.html.erb +++ b/app/views/admin/users/index.html.erb @@ -20,13 +20,14 @@ <% if user.id == Current.user.id %> <%= t(".you") %> - <%= t(".roles.#{user.role}") %> + <%= t(".roles.#{user.role}", default: user.role.humanize) %> <% else %> <%= form_with model: [:admin, user], method: :patch, class: "flex items-center gap-2" do |form| %> <%= form.select :role, options_for_select([ - [t(".roles.member"), "member"], + [t(".roles.guest"), "guest"], + [t(".roles.member", default: "Member"), "member"], [t(".roles.admin"), "admin"], [t(".roles.super_admin"), "super_admin"] ], user.role), @@ -52,9 +53,15 @@
- <%= t(".roles.member") %> + <%= t(".roles.guest") %> -

<%= t(".role_descriptions.member") %>

+

<%= t(".role_descriptions.guest") %>

+
+
+ + <%= t(".roles.member", default: "Member") %> + +

<%= t(".role_descriptions.member", default: "Basic user access. Can manage their own accounts, transactions, and settings.") %>

diff --git a/app/views/invitations/accept_choice.html.erb b/app/views/invitations/accept_choice.html.erb index 13d1194f6..f3515aa92 100644 --- a/app/views/invitations/accept_choice.html.erb +++ b/app/views/invitations/accept_choice.html.erb @@ -6,7 +6,7 @@

<%= t(".message", inviter: @invitation.inviter.display_name, - role: t("invitations.new.role_#{@invitation.role}")) %> + role: t("invitations.new.role_#{@invitation.role}", default: @invitation.role.to_s.humanize)) %>

diff --git a/app/views/invitations/new.html.erb b/app/views/invitations/new.html.erb index 31c2a8add..a6f359b40 100644 --- a/app/views/invitations/new.html.erb +++ b/app/views/invitations/new.html.erb @@ -11,6 +11,7 @@ <%= form.select :role, options_for_select([ [t(".role_member"), "member"], + [t(".role_guest", default: "Guest"), "guest"], [t(".role_admin"), "admin"] ]), {}, diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 4346ead77..6acbcf0bb 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,10 +1,18 @@ -<% mobile_nav_items = [ - { name: t(".nav.home"), path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) }, - { name: t(".nav.transactions"), path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) }, - { name: t(".nav.reports"), path: reports_path, icon: "chart-bar", icon_custom: false, active: page_active?(reports_path) }, - { name: t(".nav.budgets"), path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) }, - { name: t(".nav.assistant"), path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true } -] %> +<% intro_mode = Current.user&.ui_layout_intro? %> +<% home_path = intro_mode ? chats_path : root_path %> +<% mobile_nav_items = if intro_mode + [ + { name: "Home", path: chats_path, icon: "home", icon_custom: false, active: page_active?(chats_path) }, + { name: "Intro", path: intro_path, icon: "sparkles", icon_custom: false, active: page_active?(intro_path) } + ] +else + [ + { name: "Home", path: root_path, icon: "pie-chart", icon_custom: false, active: page_active?(root_path) }, + { name: "Transactions", path: transactions_path, icon: "credit-card", icon_custom: false, active: page_active?(transactions_path) }, + { name: "Budgets", path: budgets_path, icon: "map", icon_custom: false, active: page_active?(budgets_path) }, + { name: "Assistant", path: chats_path, icon: "icon-assistant", icon_custom: true, active: page_active?(chats_path), mobile_only: true } + ] +end %> <% desktop_nav_items = mobile_nav_items.reject { |item| item[:mobile_only] } %> <% expanded_sidebar_class = "w-full" %> @@ -20,9 +28,11 @@ @@ -113,18 +126,20 @@ <%# SHARED - Main content %> <%= tag.main class: class_names("grow overflow-y-auto px-3 lg:px-10 py-4 w-full mx-auto max-w-5xl"), data: { app_layout_target: "content" } do %> - <% end %> diff --git a/app/views/settings/profiles/show.html.erb b/app/views/settings/profiles/show.html.erb index ccdeefb2b..8bcc34842 100644 --- a/app/views/settings/profiles/show.html.erb +++ b/app/views/settings/profiles/show.html.erb @@ -25,101 +25,103 @@ <% end %> <% end %> -<%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %> -
- <%= styled_form_with model: Current.user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> - <%= form.fields_for :family do |family_fields| %> - <%= family_fields.text_field :name, - placeholder: t(".household_form_input_placeholder"), - label: t(".household_form_label"), - disabled: !Current.user.admin?, - "data-auto-submit-form-target": "auto" %> +<% unless Current.user.ui_layout_intro? %> + <%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %> +
+ <%= styled_form_with model: Current.user, class: "space-y-4", data: { controller: "auto-submit-form" } do |form| %> + <%= form.fields_for :family do |family_fields| %> + <%= family_fields.text_field :name, + placeholder: t(".household_form_input_placeholder"), + label: t(".household_form_label"), + disabled: !Current.user.admin?, + "data-auto-submit-form-target": "auto" %> + <% end %> <% end %> - <% end %> -
-
-

<%= Current.family.name %> · <%= Current.family.users.size %>

-
- <% @users.each do |user| %> -
-
- <%= render "settings/user_avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials %> -
-

<%= user.display_name %>

-
-

<%= user.role %>

-
- <% if Current.user.admin? && user != Current.user %> -
- <%= render DS::Button.new( - variant: "icon", - icon: "x", - href: settings_profile_path(user_id: user), - method: :delete, - confirm: CustomConfirm.for_resource_deletion(user.display_name, high_severity: true) - ) %> -
- <% end %> +
+
+

<%= Current.family.name %> · <%= Current.family.users.size %>

- <% end %> - <% if @pending_invitations.any? %> - <% @pending_invitations.each do |invitation| %> -
-
-
-
<%= invitation.email[0] %>
-
-
-

<%= invitation.email %>

-
-

<%= t(".pending") %>

-
-
+ <% @users.each do |user| %> +
+
+ <%= render "settings/user_avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials %>
-
- <% if self_hosted? %> -
-

<%= t(".invitation_link") %>

- - - -
- <% end %> - - <% if Current.user.admin? %> +

<%= user.display_name %>

+
+

<%= user.role %>

+
+ <% if Current.user.admin? && user != Current.user %> +
<%= render DS::Button.new( variant: "icon", icon: "x", - href: invitation_path(invitation), + href: settings_profile_path(user_id: user), method: :delete, - confirm: CustomConfirm.for_resource_deletion(invitation.email, high_severity: true) + confirm: CustomConfirm.for_resource_deletion(user.display_name, high_severity: true) ) %> - <% end %> -
+
+ <% end %>
<% end %> - <% end %> - <% if Current.user.admin? %> - <%= link_to new_invitation_path, - class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center", - data: { turbo_frame: :modal } do %> - <%= icon("plus") %> - <%= t(".invite_member") %> + <% if @pending_invitations.any? %> + <% @pending_invitations.each do |invitation| %> +
+
+
+
<%= invitation.email[0] %>
+
+
+

<%= invitation.email %>

+
+

<%= t(".pending") %>

+
+
+
+
+ <% if self_hosted? %> +
+

<%= t(".invitation_link") %>

+ + + +
+ <% end %> + + <% if Current.user.admin? %> + <%= render DS::Button.new( + variant: "icon", + icon: "x", + href: invitation_path(invitation), + method: :delete, + confirm: CustomConfirm.for_resource_deletion(invitation.email, high_severity: true) + ) %> + <% end %> +
+
+ <% end %> <% end %> - <% end %> + <% if Current.user.admin? %> + <%= link_to new_invitation_path, + class: "bg-container-inset flex items-center justify-center gap-2 text-secondary mt-1 hover:bg-container-inset-hover rounded-lg px-4 py-2 w-full text-center", + data: { turbo_frame: :modal } do %> + <%= icon("plus") %> + <%= t(".invite_member") %> + <% end %> + <% end %> +
-
+ <% end %> <% end %> <%= settings_section title: t(".danger_zone_title") do %> diff --git a/app/views/users/_user_menu.html.erb b/app/views/users/_user_menu.html.erb index a37d0f274..b8889c8c6 100644 --- a/app/views/users/_user_menu.html.erb +++ b/app/views/users/_user_menu.html.erb @@ -1,7 +1,20 @@ -<%# locals: (user:, placement: "right-start", offset: 16) %> +<%# locals: (user:, placement: "right-start", offset: 16, intro_mode: false) %> + +<% intro_mode = local_assigns.fetch(:intro_mode, false) %>
- <%= render DS::Menu.new(variant: "avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials, placement: placement, offset: offset) do |menu| %> + <%= render DS::Menu.new( + variant: "avatar", + avatar_url: user.profile_image&.variant(:small)&.url, + initials: user.initials, + placement: placement, + offset: offset + ) do |menu| %> + <% if intro_mode %> + <% menu.with_button do %> + <%= render DS::Button.new(variant: "icon", icon: "settings", data: { DS__menu_target: "button" }) %> + <% end %> + <% end %> <%= menu.with_header do %>
@@ -30,10 +43,15 @@ <% end %> <% end %> - <% menu.with_item(variant: "link", text: "Settings", icon: "settings", href: accounts_path(return_to: request.fullpath)) %> + <% menu.with_item( + variant: "link", + text: "Settings", + icon: "settings", + href: intro_mode ? settings_profile_path : accounts_path(return_to: request.fullpath) + ) %> <% menu.with_item(variant: "link", text: "Changelog", icon: "box", href: changelog_path) %> - <% if self_hosted? %> + <% if self_hosted? && !intro_mode %> <% menu.with_item(variant: "link", text: "Feedback", icon: "megaphone", href: feedback_path) %> <% end %> <% menu.with_item(variant: "link", text: "Contact", icon: "message-square-more", href: "https://discord.gg/36ZGBsxYEK") %> diff --git a/config/application.rb b/config/application.rb index d0ef1361f..3a63072b2 100644 --- a/config/application.rb +++ b/config/application.rb @@ -42,6 +42,9 @@ module Sure # Enable Rack::Attack middleware for API rate limiting config.middleware.use Rack::Attack + config.x.ui = ActiveSupport::OrderedOptions.new + default_layout = ENV.fetch("DEFAULT_UI_LAYOUT", "dashboard") + config.x.ui.default_layout = default_layout.in?(%w[dashboard intro]) ? default_layout : "dashboard" # Handle OmniAuth/OIDC errors gracefully (must be before OmniAuth middleware) require_relative "../app/middleware/omniauth_error_handler" config.middleware.use OmniauthErrorHandler diff --git a/config/locales/views/admin/sso_providers/en.yml b/config/locales/views/admin/sso_providers/en.yml index ff26989aa..9957e56b8 100644 --- a/config/locales/views/admin/sso_providers/en.yml +++ b/config/locales/views/admin/sso_providers/en.yml @@ -76,6 +76,7 @@ en: provisioning_title: "User Provisioning" default_role_label: "Default Role for New Users" default_role_help: "Role assigned to users created via just-in-time (JIT) SSO account provisioning. Defaults to Member." + role_guest: "Guest" role_member: "Member" role_admin: "Admin" role_super_admin: "Super Admin" @@ -83,6 +84,7 @@ en: role_mapping_help: "Map IdP groups/claims to application roles. Users are assigned the highest matching role. Leave blank to use the default role above." super_admin_groups: "Super Admin Groups" admin_groups: "Admin Groups" + guest_groups: "Guest Groups" member_groups: "Member Groups" groups_help: "Comma-separated list of IdP group names. Use * to match all groups." advanced_title: "Advanced OIDC Settings" diff --git a/config/locales/views/admin/users/en.yml b/config/locales/views/admin/users/en.yml index 6e77b7011..13af72c16 100644 --- a/config/locales/views/admin/users/en.yml +++ b/config/locales/views/admin/users/en.yml @@ -10,10 +10,12 @@ en: no_users: "No users found." role_descriptions_title: "Role Descriptions" roles: + guest: "Guest" member: "Member" admin: "Admin" super_admin: "Super Admin" role_descriptions: + guest: "Assistant-first experience with intentionally restricted permissions for intro workflows." member: "Basic user access. Can manage their own accounts, transactions, and settings." admin: "Family administrator. Can access advanced settings like API keys, imports, and AI prompts." super_admin: "Instance administrator. Can manage SSO providers, user roles, and impersonate users for support." diff --git a/config/locales/views/invitations/en.yml b/config/locales/views/invitations/en.yml index 4bfa9fbb7..3a2cde81c 100644 --- a/config/locales/views/invitations/en.yml +++ b/config/locales/views/invitations/en.yml @@ -19,6 +19,7 @@ en: email_label: Email Address email_placeholder: Enter email address role_admin: Administrator + role_guest: Guest role_label: Role role_member: Member submit: Send Invitation diff --git a/config/locales/views/registrations/en.yml b/config/locales/views/registrations/en.yml index 742044a02..d8f86dfea 100644 --- a/config/locales/views/registrations/en.yml +++ b/config/locales/views/registrations/en.yml @@ -17,6 +17,7 @@ en: invitation_message: "%{inviter} has invited you to join as a %{role}" join_family_title: Join %{family} role_admin: administrator + role_guest: guest role_member: member submit: Create account title: Create your account diff --git a/config/routes.rb b/config/routes.rb index 64decceb5..eb1055ba6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -482,6 +482,7 @@ Rails.application.routes.draw do terms_url = ENV["LEGAL_TERMS_URL"].presence get "privacy", to: privacy_url ? redirect(privacy_url) : "pages#privacy" get "terms", to: terms_url ? redirect(terms_url) : "pages#terms" + get "intro", to: "pages#intro" # Admin namespace for super admin functionality namespace :admin do diff --git a/db/migrate/20251030140000_add_ui_layout_to_users.rb b/db/migrate/20251030140000_add_ui_layout_to_users.rb new file mode 100644 index 000000000..236498a62 --- /dev/null +++ b/db/migrate/20251030140000_add_ui_layout_to_users.rb @@ -0,0 +1,16 @@ +class AddUiLayoutToUsers < ActiveRecord::Migration[7.2] + class MigrationUser < ApplicationRecord + self.table_name = "users" + end + + def up + add_column :users, :ui_layout, :string, if_not_exists: true + + MigrationUser.reset_column_information + MigrationUser.where(ui_layout: [ nil, "" ]).update_all(ui_layout: "dashboard") + end + + def down + remove_column :users, :ui_layout + end +end diff --git a/db/schema.rb b/db/schema.rb index 6a5655b1e..257ca09f1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_02_07_231945) do +ActiveRecord::Schema[7.2].define(version: 2026_02_08_110000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -25,7 +25,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_07_231945) do t.uuid "provider_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["account_id", "provider_type"], name: "index_account_providers_on_account_and_provider_type", unique: true + t.index ["account_id", "provider_type"], name: "index_account_providers_on_account_id_and_provider_type", unique: true t.index ["provider_type", "provider_id"], name: "index_account_providers_on_provider_type_and_provider_id", unique: true end @@ -1457,6 +1457,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_02_07_231945) do t.datetime "set_onboarding_preferences_at" t.datetime "set_onboarding_goals_at" t.string "default_account_order", default: "name_asc" + t.string "ui_layout" t.jsonb "preferences", default: {}, null: false t.string "locale" t.index ["email"], name: "index_users_on_email", unique: true diff --git a/test/controllers/invitations_controller_test.rb b/test/controllers/invitations_controller_test.rb index 5fce4964c..e558658e4 100644 --- a/test/controllers/invitations_controller_test.rb +++ b/test/controllers/invitations_controller_test.rb @@ -9,6 +9,9 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest test "should get new" do get new_invitation_url assert_response :success + assert_select "option[value=?]", "member" + assert_select "option[value=?]", "guest" + assert_select "option[value=?]", "admin" end test "should create invitation for member" do @@ -89,6 +92,49 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest assert_equal @admin, invitation.inviter end + test "admin can create guest invitation" do + assert_difference("Invitation.count") do + post invitations_url, params: { + invitation: { + email: "intro-invite@example.com", + role: "guest" + } + } + end + + invitation = Invitation.order(created_at: :desc).first + assert_equal "guest", invitation.role + assert_equal @admin.family, invitation.family + assert_equal @admin, invitation.inviter + end + + test "inviting an existing user as guest applies intro defaults" do + existing_user = users(:empty) + existing_user.update!( + role: :member, + ui_layout: :dashboard, + show_sidebar: true, + show_ai_sidebar: true, + ai_enabled: false + ) + + assert_difference("Invitation.count") do + post invitations_url, params: { + invitation: { + email: existing_user.email, + role: "guest" + } + } + end + + existing_user.reload + assert_equal "guest", existing_user.role + assert existing_user.ui_layout_intro? + assert_not existing_user.show_sidebar? + assert_not existing_user.show_ai_sidebar? + assert existing_user.ai_enabled? + end + test "should handle invalid invitation creation" do assert_no_difference("Invitation.count") do post invitations_url, params: { diff --git a/test/controllers/pages_controller_test.rb b/test/controllers/pages_controller_test.rb index 8aa72c41f..2c636ffd9 100644 --- a/test/controllers/pages_controller_test.rb +++ b/test/controllers/pages_controller_test.rb @@ -5,6 +5,7 @@ class PagesControllerTest < ActionDispatch::IntegrationTest setup do sign_in @user = users(:family_admin) + @intro_user = users(:intro_user) @family = @user.family end @@ -13,6 +14,21 @@ class PagesControllerTest < ActionDispatch::IntegrationTest assert_response :ok end + test "intro page requires guest role" do + get intro_path + + assert_redirected_to root_path + assert_equal "Intro is only available to guest users.", flash[:alert] + end + + test "intro page is accessible for guest users" do + sign_in @intro_user + + get intro_path + + assert_response :ok + end + test "dashboard renders sankey chart with subcategories" do # Create parent category with subcategory parent_category = @family.categories.create!(name: "Shopping", classification: "expense", color: "#FF5733") diff --git a/test/controllers/registrations_controller_test.rb b/test/controllers/registrations_controller_test.rb index 70dcef9ef..88acdcb13 100644 --- a/test/controllers/registrations_controller_test.rb +++ b/test/controllers/registrations_controller_test.rb @@ -67,4 +67,24 @@ class RegistrationsControllerTest < ActionDispatch::IntegrationTest end end end + + test "creating account from guest invitation assigns guest role and intro layout" do + invitation = invitations(:one) + invitation.update!(role: "guest", email: "guest-signup@example.com") + + assert_difference "User.count", +1 do + post registration_url, params: { user: { + email: invitation.email, + password: "Password1!", + invitation: invitation.token + } } + end + + created_user = User.find_by(email: invitation.email) + assert_equal "guest", created_user.role + assert created_user.ui_layout_intro? + assert_not created_user.show_sidebar? + assert_not created_user.show_ai_sidebar? + assert created_user.ai_enabled? + end end diff --git a/test/controllers/settings/profiles_controller_test.rb b/test/controllers/settings/profiles_controller_test.rb index deae066df..8a83923ef 100644 --- a/test/controllers/settings/profiles_controller_test.rb +++ b/test/controllers/settings/profiles_controller_test.rb @@ -4,6 +4,7 @@ class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest setup do @admin = users(:family_admin) @member = users(:family_member) + @intro_user = users(:intro_user) end test "should get show" do @@ -12,6 +13,19 @@ class Settings::ProfilesControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "intro user sees profile without settings navigation" do + sign_in @intro_user + get settings_profile_path + + assert_response :success + assert_select "#mobile-settings-nav", count: 0 + assert_select "h2", text: I18n.t("settings.profiles.show.household_title"), count: 0 + assert_select "[data-action='app-layout#openMobileSidebar']", count: 0 + assert_select "[data-action='app-layout#closeMobileSidebar']", count: 0 + assert_select "[data-action='app-layout#toggleLeftSidebar']", count: 0 + assert_select "[data-action='app-layout#toggleRightSidebar']", count: 0 + end + test "admin can remove a family member" do sign_in @admin assert_difference("User.count", -1) do diff --git a/test/fixtures/chats.yml b/test/fixtures/chats.yml index 6e5c5d386..c7fc03166 100644 --- a/test/fixtures/chats.yml +++ b/test/fixtures/chats.yml @@ -4,4 +4,8 @@ one: two: title: Second Chat - user: family_member \ No newline at end of file + user: family_member + +intro: + title: Intro Chat + user: intro_user diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml index a3694a0ed..dc55cfc0f 100644 --- a/test/fixtures/users.yml +++ b/test/fixtures/users.yml @@ -3,39 +3,52 @@ empty: first_name: User last_name: One email: user1@example.com - password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla onboarded_at: <%= 3.days.ago %> role: admin ai_enabled: true + show_sidebar: true + show_ai_sidebar: true + ui_layout: dashboard sure_support_staff: family: empty first_name: Support last_name: Admin email: support@sure.am - password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla role: super_admin onboarded_at: <%= 3.days.ago %> ai_enabled: true + show_sidebar: true + show_ai_sidebar: true + ui_layout: dashboard family_admin: family: dylan_family first_name: Bob last_name: Dylan email: bob@bobdylan.com - password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla role: admin onboarded_at: <%= 3.days.ago %> ai_enabled: true + show_sidebar: true + show_ai_sidebar: true + ui_layout: dashboard family_member: family: dylan_family first_name: Jakob last_name: Dylan email: jakobdylan@yahoo.com - password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla onboarded_at: <%= 3.days.ago %> + role: member ai_enabled: true + show_sidebar: true + show_ai_sidebar: true + ui_layout: dashboard new_email: family: empty @@ -45,7 +58,24 @@ new_email: unconfirmed_email: new@example.com password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla onboarded_at: <%= Time.current %> + role: member ai_enabled: true + show_sidebar: true + show_ai_sidebar: true + ui_layout: dashboard + +intro_user: + family: empty + first_name: Intro + last_name: User + email: intro@example.com + password_digest: $2a$12$XoNBo/cMCyzpYtvhrPAhsubG21mELX48RAcjSVCRctW8dG8wrDIla + onboarded_at: <%= 1.day.ago %> + role: guest + ai_enabled: true + show_sidebar: false + show_ai_sidebar: false + ui_layout: intro # SSO-only user: created via JIT provisioning, no local password sso_only: @@ -56,4 +86,4 @@ sso_only: password_digest: ~ role: admin onboarded_at: <%= 1.day.ago %> - ai_enabled: true \ No newline at end of file + ai_enabled: true diff --git a/test/models/assistant/configurable_test.rb b/test/models/assistant/configurable_test.rb new file mode 100644 index 000000000..0dc65c728 --- /dev/null +++ b/test/models/assistant/configurable_test.rb @@ -0,0 +1,21 @@ +require "test_helper" + +class AssistantConfigurableTest < ActiveSupport::TestCase + test "returns dashboard configuration by default" do + chat = chats(:one) + + config = Assistant.config_for(chat) + + assert_not_empty config[:functions] + assert_includes config[:instructions], "You help users understand their financial data" + end + + test "returns intro configuration without functions" do + chat = chats(:intro) + + config = Assistant.config_for(chat) + + assert_equal [], config[:functions] + assert_includes config[:instructions], "stage of life" + end +end diff --git a/test/models/invitation_test.rb b/test/models/invitation_test.rb index 6cc3c6519..710b4447e 100644 --- a/test/models/invitation_test.rb +++ b/test/models/invitation_test.rb @@ -61,4 +61,27 @@ class InvitationTest < ActiveSupport::TestCase assert_not result end + + test "accept_for applies guest role defaults" do + user = users(:family_member) + user.update!( + family_id: @family.id, + role: "member", + ui_layout: "dashboard", + show_sidebar: true, + show_ai_sidebar: true, + ai_enabled: false + ) + invitation = @family.invitations.create!(email: user.email, role: "guest", inviter: @inviter) + + result = invitation.accept_for(user) + + assert result + user.reload + assert_equal "guest", user.role + assert user.ui_layout_intro? + assert_not user.show_sidebar? + assert_not user.show_ai_sidebar? + assert user.ai_enabled? + end end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index 53b49ed9a..85501c1d4 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -160,6 +160,47 @@ class UserTest < ActiveSupport::TestCase Setting.openai_access_token = previous end + test "intro layout collapses sidebars and enables ai" do + user = User.new( + family: families(:empty), + email: "intro-new@example.com", + password: "Password1!", + password_confirmation: "Password1!", + role: :guest, + ui_layout: :intro + ) + + assert user.save, user.errors.full_messages.to_sentence + assert user.ui_layout_intro? + assert_not user.show_sidebar? + assert_not user.show_ai_sidebar? + assert user.ai_enabled? + end + + test "non-guest role cannot persist intro layout" do + user = User.new( + family: families(:empty), + email: "dashboard-only@example.com", + password: "Password1!", + password_confirmation: "Password1!", + role: :member, + ui_layout: :intro + ) + + assert user.save, user.errors.full_messages.to_sentence + assert user.ui_layout_dashboard? + end + + test "upgrading guest role restores dashboard layout defaults" do + user = users(:intro_user) + user.update!(role: :member) + user.reload + + assert user.ui_layout_dashboard? + assert user.show_sidebar? + assert user.show_ai_sidebar? + end + test "update_dashboard_preferences handles concurrent updates atomically" do @user.update!(preferences: {})