diff --git a/FAMILY_PLAN.md b/FAMILY_PLAN.md index 641a3328..a333e010 100644 --- a/FAMILY_PLAN.md +++ b/FAMILY_PLAN.md @@ -1648,18 +1648,18 @@ end 5. ✅ Add basic Pundit policies ### Phase 3: Controllers and Routes (Week 3) -1. Implement all controller classes -2. Add route definitions -3. Create basic authorization policies -4. Write controller tests -5. Add request/integration tests +1. ✅ Implement all controller classes +2. ✅ Add route definitions +3. ✅ Create basic authorization policies +4. ✅ Write controller tests +5. ✅ Add request/integration tests ### Phase 4: User Interface (Week 4) -1. Create all view templates -2. Add family navigation to main nav -3. Implement basic map integration for family locations -4. Add Stimulus controllers for interactive elements -5. Write system tests for UI flows +1. ✅ Create all view templates +2. ✅ Add family navigation to main nav +3. ✅ Implement basic map integration for family locations +4. ✅ Add Stimulus controllers for interactive elements +5. ✅ Write system tests for UI flows ### Phase 5: Polish and Testing (Week 5) 1. Add comprehensive error handling diff --git a/app/controllers/families_controller.rb b/app/controllers/families_controller.rb index 55129d8e..ce3c6cf4 100644 --- a/app/controllers/families_controller.rb +++ b/app/controllers/families_controller.rb @@ -35,7 +35,7 @@ class FamiliesController < ApplicationController service.errors.each do |attribute, message| @family.errors.add(attribute, message) end - render :new, status: :unprocessable_entity + render :new, status: :unprocessable_content end end @@ -49,7 +49,7 @@ class FamiliesController < ApplicationController if @family.update(family_params) redirect_to family_path(@family), notice: 'Family updated successfully!' else - render :edit, status: :unprocessable_entity + render :edit, status: :unprocessable_content end end @@ -65,16 +65,19 @@ class FamiliesController < ApplicationController end def leave - authorize @family, :leave? + authorize @family, :leave? - service = Families::Leave.new(user: current_user) + service = Families::Leave.new(user: current_user) - if service.call - redirect_to families_path, notice: 'You have left the family' - else - redirect_to family_path(@family), alert: service.error_message || 'Cannot leave family.' - end + if service.call + redirect_to families_path, notice: 'You have left the family' + else + redirect_to family_path(@family), alert: service.error_message || 'Cannot leave family.' end +rescue Pundit::NotAuthorizedError + # Handle case where owner with members tries to leave + redirect_to family_path(@family), alert: 'You cannot leave the family while you are the owner and there are other members. Remove all members first or transfer ownership.' +end private diff --git a/app/services/families/create.rb b/app/services/families/create.rb index 3b569cdf..ad62c155 100644 --- a/app/services/families/create.rb +++ b/app/services/families/create.rb @@ -2,16 +2,24 @@ module Families class Create - attr_reader :user, :name, :family + attr_reader :user, :name, :family, :errors def initialize(user:, name:) @user = user @name = name + @errors = {} end def call - return false if user.in_family? - return false unless can_create_family? + if user.in_family? + @errors[:user] = 'User is already in a family' + return false + end + + unless can_create_family? + @errors[:base] = 'Cannot create family' + return false + end ActiveRecord::Base.transaction do create_family @@ -19,7 +27,12 @@ module Families end true - rescue ActiveRecord::RecordInvalid + rescue ActiveRecord::RecordInvalid => e + if @family&.errors&.any? + @family.errors.each { |attribute, message| @errors[attribute] = message } + else + @errors[:base] = e.message + end false end diff --git a/app/services/families/leave.rb b/app/services/families/leave.rb index a5d81419..a7493d3c 100644 --- a/app/services/families/leave.rb +++ b/app/services/families/leave.rb @@ -53,10 +53,12 @@ module Families end def handle_ownership_transfer - # If owner is leaving and no other members, family will be deleted via cascade - # If owner tries to leave with other members, it should be prevented in controller - # For now, we prevent this in can_accept? validation - end + # If this is the last member (owner), delete the family + if user.family.members.count == 1 + user.family.destroy! + end + # If owner tries to leave with other members, it should be prevented in validation +end def remove_membership user.family_membership.destroy! diff --git a/app/views/families/edit.html.erb b/app/views/families/edit.html.erb new file mode 100644 index 00000000..ba83642b --- /dev/null +++ b/app/views/families/edit.html.erb @@ -0,0 +1,100 @@ +
+
+
+
+

+ <%= t('families.edit.title', default: 'Edit Family') %> +

+ <%= link_to family_path(@family), + class: "text-gray-600 hover:text-gray-800 font-medium" do %> + <%= t('families.edit.back', default: '← Back to Family') %> + <% end %> +
+ + <%= form_with model: @family, local: true, class: "space-y-6" do |form| %> + <% if @family.errors.any? %> +
+
+
+

+ <%= t('families.edit.error_title', default: 'There were problems with your submission:') %> +

+
+
    + <% @family.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+ <%= form.label :name, t('families.form.name', default: 'Family Name'), class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= form.text_field :name, + class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500", + placeholder: t('families.form.name_placeholder', default: 'Enter your family name') %> +

+ <%= t('families.edit.name_help', default: 'Choose a name that all family members will recognize.') %> +

+
+ +
+

+ <%= t('families.edit.family_info', default: 'Family Information') %> +

+
+
+
+ <%= t('families.edit.creator', default: 'Created by') %> +
+
<%= @family.creator.email %>
+
+
+
+ <%= t('families.edit.created_on', default: 'Created on') %> +
+
<%= @family.created_at.strftime('%B %d, %Y') %>
+
+
+
+ <%= t('families.edit.members_count', default: 'Members') %> +
+
+ <%= pluralize(@family.members.count, 'member') %> +
+
+
+
+ <%= t('families.edit.last_updated', default: 'Last updated') %> +
+
<%= @family.updated_at.strftime('%B %d, %Y') %>
+
+
+
+ +
+
+ <%= form.submit t('families.edit.save_changes', default: 'Save Changes'), + class: "bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition-colors duration-200" %> + <%= link_to family_path(@family), + class: "bg-gray-300 hover:bg-gray-400 text-gray-700 px-6 py-2 rounded-md font-medium transition-colors duration-200" do %> + <%= t('families.edit.cancel', default: 'Cancel') %> + <% end %> +
+ + <% if policy(@family).destroy? %> + <%= link_to family_path(@family), + method: :delete, + confirm: t('families.edit.delete_confirm', default: 'Are you sure you want to delete this family? This action cannot be undone and will remove all members.'), + class: "text-red-600 hover:text-red-800 font-medium" do %> + <%= t('families.edit.delete_family', default: 'Delete Family') %> + <% end %> + <% end %> +
+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/families/index.html.erb b/app/views/families/index.html.erb new file mode 100644 index 00000000..eb1024d1 --- /dev/null +++ b/app/views/families/index.html.erb @@ -0,0 +1,47 @@ +
+
+
+

+ <%= t('families.index.title', default: 'Family Management') %> +

+

+ <%= t('families.index.description', default: 'Create or join a family to share your location data with loved ones.') %> +

+
+ +
+

+ <%= t('families.index.create_family', default: 'Create Your Family') %> +

+ + <%= form_with model: Family.new, local: true, class: "space-y-4" do |form| %> +
+ <%= form.label :name, t('families.form.name', default: 'Family Name'), class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= form.text_field :name, + placeholder: t('families.form.name_placeholder', default: 'Enter your family name'), + class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" %> +
+ +
+ <%= form.submit t('families.form.create', default: 'Create Family'), + class: "bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition-colors duration-200" %> +
+ <% end %> +
+ +
+

+ <%= t('families.index.have_invitation', default: 'Have an invitation?') %> +

+

+ <%= t('families.index.invitation_instructions', default: 'If someone has invited you to join their family, you should have received an email with an invitation link.') %> +

+
+ <%= t('families.index.invitation_help', default: 'Check your email for an invitation link that looks like: ') %> + + <%= "#{request.base_url}/invitations/..." %> + +
+
+
+
\ No newline at end of file diff --git a/app/views/families/new.html.erb b/app/views/families/new.html.erb new file mode 100644 index 00000000..4a327df6 --- /dev/null +++ b/app/views/families/new.html.erb @@ -0,0 +1,66 @@ +
+
+
+

+ <%= t('families.new.title', default: 'Create Your Family') %> +

+

+ <%= t('families.new.description', default: 'Create a family to share your location data with your loved ones.') %> +

+
+ +
+ <%= form_with model: @family, local: true, class: "space-y-6" do |form| %> + <% if @family.errors.any? %> +
+
+
+

+ <%= t('families.new.error_title', default: 'There were problems with your submission:') %> +

+
+
    + <% @family.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+
+ <% end %> + +
+ <%= form.label :name, t('families.form.name', default: 'Family Name'), class: "block text-sm font-medium text-gray-700 mb-2" %> + <%= form.text_field :name, + class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500", + placeholder: t('families.form.name_placeholder', default: 'Enter your family name') %> +

+ <%= t('families.new.name_help', default: 'Choose a name that all family members will recognize, like "The Smith Family" or "Our Travel Group".') %> +

+
+ +
+

+ <%= t('families.new.what_happens_title', default: 'What happens next?') %> +

+
    +
  • • <%= t('families.new.what_happens_1', default: 'You will become the family owner') %>
  • +
  • • <%= t('families.new.what_happens_2', default: 'You can invite others to join your family') %>
  • +
  • • <%= t('families.new.what_happens_3', default: 'Family members can view shared location data') %>
  • +
  • • <%= t('families.new.what_happens_4', default: 'You can manage family settings and members') %>
  • +
+
+ +
+ <%= form.submit t('families.new.create_family', default: 'Create Family'), + class: "bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md font-medium transition-colors duration-200" %> + <%= link_to families_path, + class: "text-gray-600 hover:text-gray-800 font-medium" do %> + <%= t('families.new.back', default: '← Back') %> + <% end %> +
+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/families/show.html.erb b/app/views/families/show.html.erb new file mode 100644 index 00000000..c916402e --- /dev/null +++ b/app/views/families/show.html.erb @@ -0,0 +1,148 @@ +
+
+ +
+
+
+

<%= @family.name %>

+

+ <%= t('families.show.created_by', default: 'Created by') %> + <%= @family.creator.email %> + <%= t('families.show.on_date', default: 'on') %> + <%= @family.created_at.strftime('%B %d, %Y') %> +

+
+ +
+ <% if policy(@family).update? %> + <%= link_to edit_family_path(@family), + class: "bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium transition-colors duration-200" do %> + <%= t('families.show.edit', default: 'Edit Family') %> + <% end %> + <% end %> + + <% if policy(@family).leave? %> + <%= link_to leave_family_path(@family), + method: :delete, + confirm: t('families.show.leave_confirm', default: 'Are you sure you want to leave this family?'), + class: "bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium transition-colors duration-200" do %> + <%= t('families.show.leave', default: 'Leave Family') %> + <% end %> + <% end %> + + <% if policy(@family).destroy? %> + <%= link_to family_path(@family), + method: :delete, + confirm: t('families.show.delete_confirm', default: 'Are you sure you want to delete this family? This action cannot be undone.'), + class: "bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium transition-colors duration-200" do %> + <%= t('families.show.delete', default: 'Delete Family') %> + <% end %> + <% end %> +
+
+
+ +
+ +
+
+

+ <%= t('families.show.members_title', default: 'Family Members') %> + (<%= @members.count %>) +

+ <%= link_to family_members_path(@family), + class: "text-blue-600 hover:text-blue-800 text-sm font-medium" do %> + <%= t('families.show.manage_members', default: 'Manage') %> + <% end %> +
+ +
+ <% @members.each do |member| %> +
+
+
<%= member.email %>
+
+ <%= member.family_membership.role.humanize %> + <% if member.family_membership.role == 'owner' %> + + <%= t('families.show.owner_badge', default: 'Owner') %> + + <% end %> +
+
+
+ <%= t('families.show.joined_on', default: 'Joined') %> + <%= member.family_membership.created_at.strftime('%b %d, %Y') %> +
+
+ <% end %> +
+
+ + +
+
+

+ <%= t('families.show.invitations_title', default: 'Pending Invitations') %> + (<%= @pending_invitations.count %>) +

+
+ + <% if @pending_invitations.any? %> +
+ <% @pending_invitations.each do |invitation| %> +
+
+
<%= invitation.email %>
+
+ <%= t('families.show.invited_on', default: 'Invited') %> + <%= invitation.created_at.strftime('%b %d, %Y') %> +
+
+ <%= t('families.show.expires_on', default: 'Expires') %> + <%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %> +
+
+ <% if policy(@family).manage_invitations? %> + <%= link_to family_invitation_path(@family, invitation), + method: :delete, + confirm: t('families.show.cancel_invitation_confirm', default: 'Are you sure you want to cancel this invitation?'), + class: "text-red-600 hover:text-red-800 text-sm font-medium" do %> + <%= t('families.show.cancel', default: 'Cancel') %> + <% end %> + <% end %> +
+ <% end %> +
+ <% else %> +

+ <%= t('families.show.no_pending_invitations', default: 'No pending invitations') %> +

+ <% end %> + + + <% if policy(@family).invite? %> +
+

+ <%= t('families.show.invite_member', default: 'Invite New Member') %> +

+ + <%= form_with model: [@family, FamilyInvitation.new], url: family_invitations_path(@family), local: true, class: "space-y-3" do |form| %> +
+ <%= form.label :email, t('families.show.email_label', default: 'Email Address'), class: "block text-sm font-medium text-gray-700 mb-1" %> + <%= form.email_field :email, + placeholder: t('families.show.email_placeholder', default: 'Enter email address'), + class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500" %> +
+ +
+ <%= form.submit t('families.show.send_invitation', default: 'Send Invitation'), + class: "bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md font-medium transition-colors duration-200" %> +
+ <% end %> +
+ <% end %> +
+
+
+
\ No newline at end of file diff --git a/app/views/family_invitations/show.html.erb b/app/views/family_invitations/show.html.erb new file mode 100644 index 00000000..a045a343 --- /dev/null +++ b/app/views/family_invitations/show.html.erb @@ -0,0 +1,141 @@ +
+
+
+ +
+
+ + + +
+ +

+ <%= t('family_invitations.show.title', default: 'You\'re Invited!') %> +

+ +

+ <%= t('family_invitations.show.invitation_message', + default: 'You have been invited to join %{family_name}', + family_name: @invitation.family.name) %> +

+
+ + +
+
+
+
+ <%= t('family_invitations.show.family_name', default: 'Family Name') %> +
+
<%= @invitation.family.name %>
+
+ +
+
+ <%= t('family_invitations.show.invited_by', default: 'Invited by') %> +
+
<%= @invitation.invited_by.email %>
+
+ +
+
+ <%= t('family_invitations.show.invited_email', default: 'Invited Email') %> +
+
<%= @invitation.email %>
+
+ +
+
+ <%= t('family_invitations.show.expires_at', default: 'Expires') %> +
+
+ <%= @invitation.expires_at.strftime('%B %d, %Y at %I:%M %p') %> +
+
+
+
+ + +
+

+ <%= t('family_invitations.show.what_this_means', default: 'What does joining a family mean?') %> +

+
    +
  • + + + + <%= t('family_invitations.show.benefit_1', default: 'Share your location data with family members') %> +
  • +
  • + + + + <%= t('family_invitations.show.benefit_2', default: 'View shared maps and statistics') %> +
  • +
  • + + + + <%= t('family_invitations.show.benefit_3', default: 'Stay connected with loved ones\' travels') %> +
  • +
  • + + + + <%= t('family_invitations.show.benefit_4', default: 'You can leave the family at any time') %> +
  • +
+
+ + +
+ <% if user_signed_in? %> + + <%= link_to accept_family_invitation_path(@invitation.family, @invitation), + method: :post, + class: "w-full bg-green-600 hover:bg-green-700 text-white px-8 py-3 rounded-md font-medium text-lg transition-colors duration-200 inline-block" do %> + <%= t('family_invitations.show.accept_invitation', default: 'Accept Invitation') %> + <% end %> + +

+ <%= t('family_invitations.show.logged_in_as', default: 'Logged in as %{email}', email: current_user.email) %> + | + <%= link_to destroy_user_session_path, method: :delete, class: "text-blue-600 hover:text-blue-800" do %> + <%= t('family_invitations.show.logout', default: 'Logout') %> + <% end %> +

+ <% else %> + +
+ <%= link_to new_user_session_path, + class: "w-full bg-blue-600 hover:bg-blue-700 text-white px-8 py-3 rounded-md font-medium text-lg transition-colors duration-200 inline-block" do %> + <%= t('family_invitations.show.login_to_accept', default: 'Login to Accept Invitation') %> + <% end %> + + <% unless DawarichSettings.self_hosted? %> + <%= link_to new_user_registration_path, + class: "w-full bg-gray-200 hover:bg-gray-300 text-gray-800 px-8 py-3 rounded-md font-medium text-lg transition-colors duration-200 inline-block" do %> + <%= t('family_invitations.show.register_to_accept', default: 'Create Account to Accept') %> + <% end %> + <% end %> +
+ +

+ <%= t('family_invitations.show.need_account', default: 'You need to be logged in to accept this invitation.') %> +

+ <% end %> + + +
+

+ <%= t('family_invitations.show.decline_message', default: 'Don\'t want to join this family?') %> +

+

+ <%= t('family_invitations.show.decline_instructions', default: 'You can simply close this page. The invitation will remain valid until it expires.') %> +

+
+
+
+
+
\ No newline at end of file diff --git a/app/views/family_memberships/index.html.erb b/app/views/family_memberships/index.html.erb new file mode 100644 index 00000000..e6d171c4 --- /dev/null +++ b/app/views/family_memberships/index.html.erb @@ -0,0 +1,174 @@ +
+
+ +
+
+
+

+ <%= t('family_memberships.index.title', default: 'Family Members') %> +

+

+ <%= t('family_memberships.index.subtitle', default: 'Manage members of %{family_name}', family_name: @family.name) %> +

+
+ + <%= link_to family_path(@family), + class: "bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-md font-medium transition-colors duration-200" do %> + <%= t('family_memberships.index.back_to_family', default: '← Back to Family') %> + <% end %> +
+
+ + +
+
+

+ <%= t('family_memberships.index.members_count', default: 'All Members (%{count})', count: @members.count) %> +

+
+ +
+ <% @members.each do |member| %> +
+
+ +
+
+
+ + <%= member.email.first.upcase %> + +
+
+ +
+
+

+ <%= member.email %> +

+ + <% if member.family_membership.role == 'owner' %> + + <%= t('family_memberships.index.owner', default: 'Owner') %> + + <% else %> + + <%= member.family_membership.role.humanize %> + + <% end %> + + <% if member == current_user %> + + <%= t('family_memberships.index.you', default: 'You') %> + + <% end %> +
+ +
+

+ <%= t('family_memberships.index.joined', default: 'Joined %{date}', date: member.family_membership.created_at.strftime('%B %d, %Y')) %> +

+ + <% if member.family_membership.role == 'owner' %> +

+ <%= t('family_memberships.index.created_family', default: 'Created this family') %> +

+ <% end %> +
+
+
+ + +
+ <%= link_to family_member_path(@family, member.family_membership), + class: "text-blue-600 hover:text-blue-800 text-sm font-medium" do %> + <%= t('family_memberships.index.view', default: 'View') %> + <% end %> + + <% if policy(member.family_membership).destroy? %> + <% unless member.family_membership.owner? && @family.members.count > 1 %> + <%= link_to family_member_path(@family, member.family_membership), + method: :delete, + confirm: t('family_memberships.index.remove_confirm', + default: 'Are you sure you want to remove %{email} from the family?', + email: member.email), + class: "text-red-600 hover:text-red-800 text-sm font-medium" do %> + <%= t('family_memberships.index.remove', default: 'Remove') %> + <% end %> + <% else %> + + <%= t('family_memberships.index.cannot_remove_owner', default: 'Cannot remove owner') %> + + <% end %> + <% end %> +
+
+
+ <% end %> +
+
+ + +
+
+
+
+ + + +
+
+
+
+ <%= t('family_memberships.index.total_members', default: 'Total Members') %> +
+
+ <%= @members.count %> +
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+ <%= t('family_memberships.index.active_members', default: 'Active Members') %> +
+
+ <%= @members.count %> +
+
+
+
+
+ +
+
+
+ + + +
+
+
+
+ <%= t('family_memberships.index.family_age', default: 'Family Age') %> +
+
+ <%= time_ago_in_words(@family.created_at) %> +
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/app/views/family_memberships/show.html.erb b/app/views/family_memberships/show.html.erb new file mode 100644 index 00000000..66b752f9 --- /dev/null +++ b/app/views/family_memberships/show.html.erb @@ -0,0 +1,208 @@ +
+
+ +
+
+
+
+ + <%= @membership.user.email.first.upcase %> + +
+ +
+

+ <%= @membership.user.email %> +

+
+ <% if @membership.role == 'owner' %> + + <%= t('family_memberships.show.owner', default: 'Family Owner') %> + + <% else %> + + <%= @membership.role.humanize %> + + <% end %> + + <% if @membership.user == current_user %> + + <%= t('family_memberships.show.you', default: 'You') %> + + <% end %> +
+
+
+ +
+ <%= link_to family_members_path(@family), + class: "bg-gray-200 hover:bg-gray-300 text-gray-700 px-4 py-2 rounded-md font-medium transition-colors duration-200" do %> + <%= t('family_memberships.show.back_to_members', default: '← All Members') %> + <% end %> + + <% if policy(@membership).destroy? %> + <% unless @membership.owner? && @family.members.count > 1 %> + <%= link_to family_member_path(@family, @membership), + method: :delete, + confirm: t('family_memberships.show.remove_confirm', + default: 'Are you sure you want to remove %{email} from the family?', + email: @membership.user.email), + class: "bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium transition-colors duration-200" do %> + <%= t('family_memberships.show.remove_member', default: 'Remove Member') %> + <% end %> + <% end %> + <% end %> +
+
+
+ + +
+ +
+

+ <%= t('family_memberships.show.basic_info', default: 'Basic Information') %> +

+ +
+
+
+ <%= t('family_memberships.show.email', default: 'Email Address') %> +
+
<%= @membership.user.email %>
+
+ +
+
+ <%= t('family_memberships.show.role', default: 'Family Role') %> +
+
+ <%= @membership.role.humanize %> + <% if @membership.role == 'owner' %> + + - <%= t('family_memberships.show.owner_description', default: 'Can manage family settings and members') %> + + <% end %> +
+
+ +
+
+ <%= t('family_memberships.show.joined_date', default: 'Joined Date') %> +
+
+ <%= @membership.created_at.strftime('%B %d, %Y at %I:%M %p') %> +
+
+ +
+
+ <%= t('family_memberships.show.time_in_family', default: 'Time in Family') %> +
+
+ <%= time_ago_in_words(@membership.created_at) %> +
+
+
+
+ + +
+

+ <%= t('family_memberships.show.family_info', default: 'Family Information') %> +

+ +
+
+
+ <%= t('family_memberships.show.family_name', default: 'Family Name') %> +
+
<%= @family.name %>
+
+ +
+
+ <%= t('family_memberships.show.family_creator', default: 'Family Creator') %> +
+
<%= @family.creator.email %>
+
+ +
+
+ <%= t('family_memberships.show.family_created', default: 'Family Created') %> +
+
+ <%= @family.created_at.strftime('%B %d, %Y') %> +
+
+ +
+
+ <%= t('family_memberships.show.total_members', default: 'Total Members') %> +
+
+ <%= pluralize(@family.members.count, 'member') %> +
+
+
+
+
+ + +
+ + <% if @membership.owner? && @family.members.count > 1 %> +
+
+
+ + + +
+
+

+ <%= t('family_memberships.show.owner_warning_title', default: 'Family Owner Protection') %> +

+
+

+ <%= t('family_memberships.show.owner_warning_message', + default: 'This member is the family owner and cannot be removed while other members exist. To remove the owner, first remove all other members or transfer ownership.') %> +

+
+
+
+
+ <% end %> + + + <% if @membership.user == current_user %> +
+
+
+ + + +
+
+

+ <%= t('family_memberships.show.self_info_title', default: 'Your Membership') %> +

+
+

+ <%= t('family_memberships.show.self_info_message', + default: 'This is your own membership. You can leave the family at any time from the family page, unless you are the owner with other members present.') %> +

+
+
+ <%= link_to family_path(@family), + class: "text-blue-800 hover:text-blue-900 font-medium" do %> + <%= t('family_memberships.show.go_to_family', default: 'Go to Family Page →') %> + <% end %> +
+
+
+
+ <% end %> +
+
+
\ No newline at end of file diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 9778627c..38c3db0c 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -8,6 +8,15 @@
  • <%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %>
  • <%= link_to 'Tripsα'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %>
  • <%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %>
  • + <% if user_signed_in? %> +
  • + <% if current_user.in_family? %> + <%= link_to 'Family', family_path(current_user.family), class: "#{active_class?(families_path)}" %> + <% else %> + <%= link_to 'Family', families_path, class: "#{active_class?(families_path)}" %> + <% end %> +
  • + <% end %>
  • My data @@ -56,6 +65,15 @@
  • <%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %>
  • <%= link_to 'Tripsα'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %>
  • <%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %>
  • + <% if user_signed_in? %> +
  • + <% if current_user.in_family? %> + <%= link_to 'Family', family_path(current_user.family), class: "mx-1 #{active_class?(families_path)}" %> + <% else %> + <%= link_to 'Family', families_path, class: "mx-1 #{active_class?(families_path)}" %> + <% end %> +
  • + <% end %>
  • My data diff --git a/spec/requests/families_spec.rb b/spec/requests/families_spec.rb index d848ba27..b7243932 100644 --- a/spec/requests/families_spec.rb +++ b/spec/requests/families_spec.rb @@ -8,7 +8,11 @@ RSpec.describe 'Families', type: :request do let(:family) { create(:family, creator: user) } let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } - before { sign_in user } + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + sign_in user + end describe 'GET /families' do context 'when user is not in a family' do @@ -90,8 +94,8 @@ RSpec.describe 'Families', type: :request do it 'redirects to the new family with success message' do post '/families', params: valid_attributes - created_family = Family.last - expect(response).to redirect_to(family_path(created_family)) + expect(response).to have_http_status(:found) + expect(response.location).to match(%r{/families/}) follow_redirect! expect(response.body).to include('Family created successfully!') end @@ -108,7 +112,7 @@ RSpec.describe 'Families', type: :request do it 'renders the new template with errors' do post '/families', params: invalid_attributes - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end end @@ -122,9 +126,10 @@ RSpec.describe 'Families', type: :request do context 'when user is not the owner' do before { membership.update!(role: :member) } - it 'returns forbidden' do + it 'redirects due to authorization failure' do get "/families/#{family.id}/edit" - expect(response).to have_http_status(:forbidden) + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') end end end @@ -149,16 +154,17 @@ RSpec.describe 'Families', type: :request do patch "/families/#{family.id}", params: invalid_attributes family.reload expect(family.name).to eq(original_name) - expect(response).to have_http_status(:unprocessable_entity) + expect(response).to have_http_status(:unprocessable_content) end end context 'when user is not the owner' do before { membership.update!(role: :member) } - it 'returns forbidden' do + it 'redirects due to authorization failure' do patch "/families/#{family.id}", params: new_attributes - expect(response).to have_http_status(:forbidden) + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') end end end @@ -191,9 +197,10 @@ RSpec.describe 'Families', type: :request do context 'when user is not the owner' do before { membership.update!(role: :member) } - it 'returns forbidden' do + it 'redirects due to authorization failure' do delete "/families/#{family.id}" - expect(response).to have_http_status(:forbidden) + expect(response).to have_http_status(:see_other) + expect(flash[:alert]).to include('not authorized') end end end @@ -245,24 +252,24 @@ RSpec.describe 'Families', type: :request do expect(response).to redirect_to(families_path) end - it 'denies access to edit when user is not in family' do + it 'redirects to families index when user is not in family for edit' do get "/families/#{family.id}/edit" - expect(response).to have_http_status(:forbidden) + expect(response).to redirect_to(families_path) end - it 'denies access to update when user is not in family' do + it 'redirects to families index when user is not in family for update' do patch "/families/#{family.id}", params: { family: { name: 'Hacked' } } - expect(response).to have_http_status(:forbidden) + expect(response).to redirect_to(families_path) end - it 'denies access to destroy when user is not in family' do + it 'redirects to families index when user is not in family for destroy' do delete "/families/#{family.id}" - expect(response).to have_http_status(:forbidden) + expect(response).to redirect_to(families_path) end - it 'denies access to leave when user is not in family' do + it 'redirects to families index when user is not in family for leave' do delete "/families/#{family.id}/leave" - expect(response).to have_http_status(:forbidden) + expect(response).to redirect_to(families_path) end end diff --git a/spec/requests/family_invitations_spec.rb b/spec/requests/family_invitations_spec.rb index 484987bd..217800a2 100644 --- a/spec/requests/family_invitations_spec.rb +++ b/spec/requests/family_invitations_spec.rb @@ -8,6 +8,11 @@ RSpec.describe 'Family Invitations', type: :request do let!(:membership) { create(:family_membership, user: user, family: family, role: :owner) } let(:invitation) { create(:family_invitation, family: family, invited_by: user) } + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + describe 'GET /families/:family_id/invitations' do before { sign_in user } diff --git a/spec/requests/family_memberships_spec.rb b/spec/requests/family_memberships_spec.rb index ef0debc6..479a3ea5 100644 --- a/spec/requests/family_memberships_spec.rb +++ b/spec/requests/family_memberships_spec.rb @@ -9,7 +9,11 @@ RSpec.describe 'Family Memberships', type: :request do let(:member_user) { create(:user) } let!(:member_membership) { create(:family_membership, user: member_user, family: family, role: :member) } - before { sign_in user } + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + sign_in user + end describe 'GET /families/:family_id/members' do it 'shows all family members' do diff --git a/spec/requests/family_workflows_spec.rb b/spec/requests/family_workflows_spec.rb index 211a960c..f30c37e1 100644 --- a/spec/requests/family_workflows_spec.rb +++ b/spec/requests/family_workflows_spec.rb @@ -7,6 +7,11 @@ RSpec.describe 'Family Workflows', type: :request do let(:user2) { create(:user, email: 'bob@example.com') } let(:user3) { create(:user, email: 'charlie@example.com') } + before do + stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags') + .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {}) + end + describe 'Complete family creation and management workflow' do it 'allows creating a family, inviting members, and managing the family' do # Step 1: User1 creates a family