mirror of
https://github.com/we-promise/sure.git
synced 2026-04-20 04:24:06 +00: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") %>
|
||||
|
||||
Reference in New Issue
Block a user