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:
Juan José Mata
2026-02-09 11:09:25 +01:00
committed by GitHub
parent ba442d5f26
commit 705b5a8b26
33 changed files with 556 additions and 138 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -11,6 +11,7 @@
<%= form.select :role,
options_for_select([
[t(".role_member"), "member"],
[t(".role_guest", default: "Guest"), "guest"],
[t(".role_admin"), "admin"]
]),
{},

View File

@@ -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 %>

View 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>

View File

@@ -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 %>

View File

@@ -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 %> &middot; <%= 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 %> &middot; <%= 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 %>

View File

@@ -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") %>