mirror of
https://github.com/we-promise/sure
synced 2026-04-25 17:15:07 +02:00
First cut of a simplified "intro" UI layout (#265)
* First cut of a simplified "intro" UI layout * Linter * Add guest role and intro-only access * Fix guest role UI defaults (#940) Use enum predicate to avoid missing role helper. * Remove legacy user role mapping (#941) Drop the unused user role references in role normalization and SSO role mapping forms to avoid implying a role that never existed. Refs: #0 * Remove role normalization (#942) Remove role normalization Roles are now stored directly without legacy mappings. * Revert role mapping logic * Remove `normalize_role_settings` * Remove unnecessary migration * Make `member` the default * Broken `.erb` --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } %>
|
||||
<p class="text-xs text-secondary -mt-2"><%= t("admin.sso_providers.form.default_role_help") %></p>
|
||||
|
||||
@@ -231,6 +232,15 @@
|
||||
placeholder="* (all groups)"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-primary mb-1"><%= t("admin.sso_providers.form.guest_groups", default: "Guest Groups") %></label>
|
||||
<input type="text" name="sso_provider[settings][role_mapping][guest]"
|
||||
value="<%= Array(sso_provider.settings&.dig("role_mapping", "guest").presence || sso_provider.settings&.dig("role_mapping", "intro")).join(", ") %>"
|
||||
class="w-full px-3 py-2 border border-primary rounded-lg text-sm"
|
||||
placeholder="Early-Access-Guests"
|
||||
autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
@@ -20,13 +20,14 @@
|
||||
<% if user.id == Current.user.id %>
|
||||
<span class="text-sm text-secondary"><%= t(".you") %></span>
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary">
|
||||
<%= t(".roles.#{user.role}") %>
|
||||
<%= t(".roles.#{user.role}", default: user.role.humanize) %>
|
||||
</span>
|
||||
<% 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 @@
|
||||
<div class="space-y-3 text-sm">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
|
||||
<%= t(".roles.member") %>
|
||||
<%= t(".roles.guest") %>
|
||||
</span>
|
||||
<p class="text-secondary"><%= t(".role_descriptions.member") %></p>
|
||||
<p class="text-secondary"><%= t(".role_descriptions.guest") %></p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
|
||||
<%= t(".roles.member", default: "Member") %>
|
||||
</span>
|
||||
<p class="text-secondary"><%= t(".role_descriptions.member", default: "Basic user access. Can manage their own accounts, transactions, and settings.") %></p>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-surface text-primary shrink-0">
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<p class="text-secondary">
|
||||
<%= 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)) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<%= form.select :role,
|
||||
options_for_select([
|
||||
[t(".role_member"), "member"],
|
||||
[t(".role_guest", default: "Guest"), "guest"],
|
||||
[t(".role_admin"), "admin"]
|
||||
]),
|
||||
{},
|
||||
|
||||
@@ -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 @@
|
||||
<div
|
||||
class="hidden fixed inset-0 bg-surface z-20 h-full w-full pt-[calc(env(safe-area-inset-top)+0.75rem)] pr-3 pb-[calc(env(safe-area-inset-bottom)+0.75rem)] pl-3 overflow-y-auto transition-all duration-300"
|
||||
data-app-layout-target="mobileSidebar">
|
||||
<div class="mb-2">
|
||||
<%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
|
||||
</div>
|
||||
<% unless intro_mode %>
|
||||
<div class="mb-2">
|
||||
<%= icon("x", as_button: true, data: { action: "app-layout#closeMobileSidebar" }) %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= render(
|
||||
"accounts/account_sidebar_tabs",
|
||||
@@ -34,20 +44,23 @@
|
||||
|
||||
<%# MOBILE - Top nav %>
|
||||
<nav class="lg:hidden flex justify-between items-center p-3">
|
||||
<%= 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 %>
|
||||
</nav>
|
||||
|
||||
<%# DESKTOP - Left navbar %>
|
||||
<div class="hidden lg:block">
|
||||
<nav class="h-full flex flex-col shrink-0 w-[84px] py-4 mr-3">
|
||||
<div class="pl-2 mb-3">
|
||||
<%= 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 %>
|
||||
</div>
|
||||
@@ -68,7 +81,7 @@
|
||||
target: "_blank"
|
||||
) %>
|
||||
|
||||
<%= render "users/user_menu", user: Current.user %>
|
||||
<%= render "users/user_menu", user: Current.user, intro_mode: intro_mode %>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -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 %>
|
||||
<div class="hidden lg:flex gap-2 items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= icon("panel-left", as_button: true, data: { action: "app-layout#toggleLeftSidebar" }) %>
|
||||
<% unless intro_mode %>
|
||||
<div class="hidden lg:flex gap-2 items-center justify-between mb-6">
|
||||
<div class="flex items-center gap-2">
|
||||
<%= 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 %>
|
||||
</div>
|
||||
<%= icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %>
|
||||
</div>
|
||||
<%= icon("panel-right", as_button: true, data: { action: "app-layout#toggleRightSidebar" }) %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if content_for?(:page_header) %>
|
||||
<%= yield :page_header %>
|
||||
|
||||
21
app/views/pages/intro.html.erb
Normal file
21
app/views/pages/intro.html.erb
Normal file
@@ -0,0 +1,21 @@
|
||||
<% content_for :page_header do %>
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-2xl font-semibold text-primary">Welcome!</h1>
|
||||
<br/>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<div class="bg-container shadow-border-xs rounded-2xl p-8 text-center space-y-4">
|
||||
<div class="flex justify-center">
|
||||
<%= image_tag "logomark-color.svg", class: "w-16 h-16" %>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-primary">Intro experience coming soon</h2>
|
||||
<p class="text-secondary">
|
||||
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.
|
||||
</p>
|
||||
<div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -12,7 +12,7 @@
|
||||
<p class="text-secondary">
|
||||
<%= 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)) %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
@@ -25,101 +25,103 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".household_title"), subtitle: t(".household_subtitle") do %>
|
||||
<div class="space-y-4">
|
||||
<%= 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 %>
|
||||
<div class="space-y-4">
|
||||
<%= 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 %>
|
||||
<div class="bg-container-inset rounded-xl p-1">
|
||||
<div class="px-4 py-2">
|
||||
<p class="uppercase text-xs text-secondary font-medium"><%= Current.family.name %> · <%= Current.family.users.size %></p>
|
||||
</div>
|
||||
<% @users.each do |user| %>
|
||||
<div class="flex gap-2 mt-2 items-center bg-container p-4 shadow-border-xs rounded-lg">
|
||||
<div class="w-9 h-9 shrink-0">
|
||||
<%= render "settings/user_avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials %>
|
||||
</div>
|
||||
<p class="text-primary font-medium text-sm"><%= user.display_name %></p>
|
||||
<div class="rounded-md bg-surface px-1.5 py-0.5">
|
||||
<p class="uppercase text-secondary font-medium text-xs"><%= user.role %></p>
|
||||
</div>
|
||||
<% if Current.user.admin? && user != Current.user %>
|
||||
<div class="ml-auto">
|
||||
<%= 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)
|
||||
) %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="bg-container-inset rounded-xl p-1">
|
||||
<div class="px-4 py-2">
|
||||
<p class="uppercase text-xs text-secondary font-medium"><%= Current.family.name %> · <%= Current.family.users.size %></p>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if @pending_invitations.any? %>
|
||||
<% @pending_invitations.each do |invitation| %>
|
||||
<div class="flex gap-2 items-center justify-between bg-container p-4 border border-alpha-black-25 rounded-lg">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="w-9 h-9 shrink-0">
|
||||
<div class="fg-inverse w-full h-full bg-surface-inset rounded-full flex items-center justify-center text-lg uppercase"><%= invitation.email[0] %></div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<p class="text-primary font-medium text-sm"><%= invitation.email %></p>
|
||||
<div class="rounded-md bg-surface px-1.5 py-0.5">
|
||||
<p class="uppercase text-secondary font-medium text-xs"><%= t(".pending") %></p>
|
||||
</div>
|
||||
</div>
|
||||
<% @users.each do |user| %>
|
||||
<div class="flex gap-2 mt-2 items-center bg-container p-4 shadow-border-xs rounded-lg">
|
||||
<div class="w-9 h-9 shrink-0">
|
||||
<%= render "settings/user_avatar", avatar_url: user.profile_image&.variant(:small)&.url, initials: user.initials %>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<% if self_hosted? %>
|
||||
<div class="flex items-center gap-2" data-controller="clipboard">
|
||||
<p class="text-secondary text-sm"><%= t(".invitation_link") %></p>
|
||||
<span data-clipboard-target="source" class="hidden"><%= accept_invitation_url(invitation.token) %></span>
|
||||
<input type="text"
|
||||
readonly
|
||||
autocomplete="off"
|
||||
value="<%= accept_invitation_url(invitation.token) %>"
|
||||
class="text-sm bg-gray-50 px-2 py-1 rounded border border-secondary w-72">
|
||||
<button data-action="clipboard#copy" class="text-secondary hover:text-gray-700">
|
||||
<span data-clipboard-target="iconDefault">
|
||||
<%= icon "copy" %>
|
||||
</span>
|
||||
<span class="hidden" data-clipboard-target="iconSuccess">
|
||||
<%= icon "check" %>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if Current.user.admin? %>
|
||||
<p class="text-primary font-medium text-sm"><%= user.display_name %></p>
|
||||
<div class="rounded-md bg-surface px-1.5 py-0.5">
|
||||
<p class="uppercase text-secondary font-medium text-xs"><%= user.role %></p>
|
||||
</div>
|
||||
<% if Current.user.admin? && user != Current.user %>
|
||||
<div class="ml-auto">
|
||||
<%= 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 %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% 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| %>
|
||||
<div class="flex gap-2 items-center justify-between bg-container p-4 border border-alpha-black-25 rounded-lg">
|
||||
<div class="flex gap-2 items-center">
|
||||
<div class="w-9 h-9 shrink-0">
|
||||
<div class="fg-inverse w-full h-full bg-surface-inset rounded-full flex items-center justify-center text-lg uppercase"><%= invitation.email[0] %></div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<p class="text-primary font-medium text-sm"><%= invitation.email %></p>
|
||||
<div class="rounded-md bg-surface px-1.5 py-0.5">
|
||||
<p class="uppercase text-secondary font-medium text-xs"><%= t(".pending") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<% if self_hosted? %>
|
||||
<div class="flex items-center gap-2" data-controller="clipboard">
|
||||
<p class="text-secondary text-sm"><%= t(".invitation_link") %></p>
|
||||
<span data-clipboard-target="source" class="hidden"><%= accept_invitation_url(invitation.token) %></span>
|
||||
<input type="text"
|
||||
readonly
|
||||
autocomplete="off"
|
||||
value="<%= accept_invitation_url(invitation.token) %>"
|
||||
class="text-sm bg-gray-50 px-2 py-1 rounded border border-secondary w-72">
|
||||
<button data-action="clipboard#copy" class="text-secondary hover:text-gray-700">
|
||||
<span data-clipboard-target="iconDefault">
|
||||
<%= icon "copy" %>
|
||||
</span>
|
||||
<span class="hidden" data-clipboard-target="iconSuccess">
|
||||
<%= icon "check" %>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<% 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 %>
|
||||
</div>
|
||||
</div>
|
||||
<% 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 %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= settings_section title: t(".danger_zone_title") do %>
|
||||
|
||||
@@ -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) %>
|
||||
|
||||
<div data-testid="user-menu">
|
||||
<%= 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 %>
|
||||
<div class="px-4 py-3 flex items-center gap-3">
|
||||
<div class="w-9 h-9 shrink-0">
|
||||
@@ -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") %>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
16
db/migrate/20251030140000_add_ui_layout_to_users.rb
Normal file
16
db/migrate/20251030140000_add_ui_layout_to_users.rb
Normal file
@@ -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
|
||||
5
db/schema.rb
generated
5
db/schema.rb
generated
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
6
test/fixtures/chats.yml
vendored
6
test/fixtures/chats.yml
vendored
@@ -4,4 +4,8 @@ one:
|
||||
|
||||
two:
|
||||
title: Second Chat
|
||||
user: family_member
|
||||
user: family_member
|
||||
|
||||
intro:
|
||||
title: Intro Chat
|
||||
user: intro_user
|
||||
|
||||
40
test/fixtures/users.yml
vendored
40
test/fixtures/users.yml
vendored
@@ -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
|
||||
ai_enabled: true
|
||||
|
||||
21
test/models/assistant/configurable_test.rb
Normal file
21
test/models/assistant/configurable_test.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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: {})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user