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">
+
+
+ <%= t("admin.sso_providers.form.guest_groups", default: "Guest Groups") %>
+ "
+ 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 @@
-
- <%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
-
+ <% unless intro_mode %>
+
+ <%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
+
+ <% end %>
<%= render(
"accounts/account_sidebar_tabs",
@@ -34,20 +44,23 @@
<%# MOBILE - Top nav %>
- <%= icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
+ <% if intro_mode %>
+ <% else %>
+ <%= icon("panel-left", as_button: true, data: { action: "app-layout#openMobileSidebar"}) %>
+ <% end %>
- <%= link_to root_path, class: "block" do %>
+ <%= link_to home_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
- <%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12 %>
+ <%= render "users/user_menu", user: Current.user, placement: "bottom-end", offset: 12, intro_mode: intro_mode %>
<%# DESKTOP - Left navbar %>
- <%= link_to root_path, class: "block" do %>
+ <%= link_to home_path, class: "block" do %>
<%= image_tag "logomark-color.svg", class: "w-9 h-9 mx-auto" %>
<% end %>
@@ -68,7 +81,7 @@
target: "_blank"
) %>
- <%= render "users/user_menu", user: Current.user %>
+ <%= render "users/user_menu", user: Current.user, intro_mode: intro_mode %>
@@ -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 %>
-
-
- <%= icon("panel-left", as_button: true, data: { action: "app-layout#toggleLeftSidebar" }) %>
+ <% unless intro_mode %>
+
+
+ <%= icon("panel-left", as_button: true, data: { action: "app-layout#toggleLeftSidebar" }) %>
- <% if content_for?(:breadcrumbs) %>
- <%= yield :breadcrumbs %>
- <% else %>
- <%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %>
- <% end %>
+ <% if content_for?(:breadcrumbs) %>
+ <%= yield :breadcrumbs %>
+ <% else %>
+ <%= render "layouts/shared/breadcrumbs", breadcrumbs: @breadcrumbs %>
+ <% end %>
+
+ <%= icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %>
- <%= icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %>
-
+ <% end %>
<% if content_for?(:page_header) %>
<%= yield :page_header %>
diff --git a/app/views/pages/intro.html.erb b/app/views/pages/intro.html.erb
new file mode 100644
index 000000000..072a76068
--- /dev/null
+++ b/app/views/pages/intro.html.erb
@@ -0,0 +1,21 @@
+<% content_for :page_header do %>
+
+
Welcome!
+
+
+<% end %>
+
+
+
+
+ <%= image_tag "logomark-color.svg", class: "w-16 h-16" %>
+
+
Intro experience coming soon
+
+ We're building a richer onboarding journey to learn about your goals, milestones, and day-to-day needs. For now, head over to the chat sidebar to start a conversation with Sure and let us know where you are in your financial journey.
+
+
+ <%= link_to "Start chatting", chats_path, class: "inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white font-medium" %>
+
+
+
diff --git a/app/views/registrations/new.html.erb b/app/views/registrations/new.html.erb
index 9c811087c..daebaaabd 100644
--- a/app/views/registrations/new.html.erb
+++ b/app/views/registrations/new.html.erb
@@ -12,7 +12,7 @@
<%= t(".invitation_message",
inviter: @invitation.inviter.display_name,
- role: t(".role_#{@invitation.role}")) %>
+ role: t(".role_#{@invitation.role}", default: @invitation.role.to_s.humanize.downcase)) %>
<% 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 %>
-
- <% 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 %>
-
-
+ <% @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") %>
-
<%= accept_invitation_url(invitation.token) %>
-
-
-
- <%= icon "copy" %>
-
-
- <%= icon "check" %>
-
-
-
- <% end %>
-
- <% if Current.user.admin? %>
+
<%= user.display_name %>
+
+ <% 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 %>
+
+
+
+
+ <% if self_hosted? %>
+
+
<%= t(".invitation_link") %>
+
<%= accept_invitation_url(invitation.token) %>
+
+
+
+ <%= icon "copy" %>
+
+
+ <%= icon "check" %>
+
+
+
+ <% 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: {})