Complete phase 4

This commit is contained in:
Eugene Burmakin 2025-09-27 14:04:10 +02:00
parent cc5da3e7e2
commit f0f0f20200
16 changed files with 988 additions and 47 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,100 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">
<%= t('families.edit.title', default: 'Edit Family') %>
</h1>
<%= 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 %>
</div>
<%= form_with model: @family, local: true, class: "space-y-6" do |form| %>
<% if @family.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= t('families.edit.error_title', default: 'There were problems with your submission:') %>
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% @family.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div>
<%= 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') %>
<p class="mt-1 text-sm text-gray-500">
<%= t('families.edit.name_help', default: 'Choose a name that all family members will recognize.') %>
</p>
</div>
<div class="bg-gray-50 p-4 rounded-md">
<h3 class="text-sm font-medium text-gray-900 mb-2">
<%= t('families.edit.family_info', default: 'Family Information') %>
</h3>
<dl class="grid grid-cols-1 gap-x-4 gap-y-2 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('families.edit.creator', default: 'Created by') %>
</dt>
<dd class="text-sm text-gray-900"><%= @family.creator.email %></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('families.edit.created_on', default: 'Created on') %>
</dt>
<dd class="text-sm text-gray-900"><%= @family.created_at.strftime('%B %d, %Y') %></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('families.edit.members_count', default: 'Members') %>
</dt>
<dd class="text-sm text-gray-900">
<%= pluralize(@family.members.count, 'member') %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('families.edit.last_updated', default: 'Last updated') %>
</dt>
<dd class="text-sm text-gray-900"><%= @family.updated_at.strftime('%B %d, %Y') %></dd>
</div>
</dl>
</div>
<div class="flex items-center justify-between pt-4">
<div class="flex space-x-3">
<%= 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 %>
</div>
<% 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 %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,47 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-4">
<%= t('families.index.title', default: 'Family Management') %>
</h1>
<p class="text-gray-600">
<%= t('families.index.description', default: 'Create or join a family to share your location data with loved ones.') %>
</p>
</div>
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-xl font-semibold mb-4">
<%= t('families.index.create_family', default: 'Create Your Family') %>
</h2>
<%= form_with model: Family.new, local: true, class: "space-y-4" do |form| %>
<div>
<%= 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" %>
</div>
<div class="flex justify-end">
<%= 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" %>
</div>
<% end %>
</div>
<div class="mt-8 text-center">
<h3 class="text-lg font-medium text-gray-900 mb-4">
<%= t('families.index.have_invitation', default: 'Have an invitation?') %>
</h3>
<p class="text-gray-600 mb-4">
<%= 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.') %>
</p>
<div class="text-sm text-gray-500">
<%= t('families.index.invitation_help', default: 'Check your email for an invitation link that looks like: ') %>
<code class="bg-gray-100 px-2 py-1 rounded text-xs">
<%= "#{request.base_url}/invitations/..." %>
</code>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,66 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900 mb-4">
<%= t('families.new.title', default: 'Create Your Family') %>
</h1>
<p class="text-gray-600">
<%= t('families.new.description', default: 'Create a family to share your location data with your loved ones.') %>
</p>
</div>
<div class="bg-white shadow rounded-lg p-6">
<%= form_with model: @family, local: true, class: "space-y-6" do |form| %>
<% if @family.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-md p-4">
<div class="flex">
<div class="ml-3">
<h3 class="text-sm font-medium text-red-800">
<%= t('families.new.error_title', default: 'There were problems with your submission:') %>
</h3>
<div class="mt-2 text-sm text-red-700">
<ul class="list-disc pl-5 space-y-1">
<% @family.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
</div>
<% end %>
<div>
<%= 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') %>
<p class="mt-1 text-sm text-gray-500">
<%= t('families.new.name_help', default: 'Choose a name that all family members will recognize, like "The Smith Family" or "Our Travel Group".') %>
</p>
</div>
<div class="bg-blue-50 p-4 rounded-md">
<h3 class="text-sm font-medium text-blue-900 mb-2">
<%= t('families.new.what_happens_title', default: 'What happens next?') %>
</h3>
<ul class="text-sm text-blue-800 space-y-1">
<li>• <%= t('families.new.what_happens_1', default: 'You will become the family owner') %></li>
<li>• <%= t('families.new.what_happens_2', default: 'You can invite others to join your family') %></li>
<li>• <%= t('families.new.what_happens_3', default: 'Family members can view shared location data') %></li>
<li>• <%= t('families.new.what_happens_4', default: 'You can manage family settings and members') %></li>
</ul>
</div>
<div class="flex items-center justify-between">
<%= 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 %>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -0,0 +1,148 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-4xl mx-auto">
<!-- Family Header -->
<div class="bg-white shadow rounded-lg p-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900"><%= @family.name %></h1>
<p class="text-gray-600 mt-1">
<%= 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') %>
</p>
</div>
<div class="flex space-x-3">
<% 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 %>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Family Members -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900">
<%= t('families.show.members_title', default: 'Family Members') %>
<span class="text-sm font-normal text-gray-500">(<%= @members.count %>)</span>
</h2>
<%= 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 %>
</div>
<div class="space-y-3">
<% @members.each do |member| %>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<div class="font-medium text-gray-900"><%= member.email %></div>
<div class="text-sm text-gray-500">
<%= member.family_membership.role.humanize %>
<% if member.family_membership.role == 'owner' %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800 ml-2">
<%= t('families.show.owner_badge', default: 'Owner') %>
</span>
<% end %>
</div>
</div>
<div class="text-sm text-gray-500">
<%= t('families.show.joined_on', default: 'Joined') %>
<%= member.family_membership.created_at.strftime('%b %d, %Y') %>
</div>
</div>
<% end %>
</div>
</div>
<!-- Pending Invitations -->
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900">
<%= t('families.show.invitations_title', default: 'Pending Invitations') %>
<span class="text-sm font-normal text-gray-500">(<%= @pending_invitations.count %>)</span>
</h2>
</div>
<% if @pending_invitations.any? %>
<div class="space-y-3 mb-4">
<% @pending_invitations.each do |invitation| %>
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div>
<div class="font-medium text-gray-900"><%= invitation.email %></div>
<div class="text-sm text-gray-500">
<%= t('families.show.invited_on', default: 'Invited') %>
<%= invitation.created_at.strftime('%b %d, %Y') %>
</div>
<div class="text-xs text-gray-400">
<%= t('families.show.expires_on', default: 'Expires') %>
<%= invitation.expires_at.strftime('%b %d, %Y at %I:%M %p') %>
</div>
</div>
<% 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 %>
</div>
<% end %>
</div>
<% else %>
<p class="text-gray-500 text-center py-4">
<%= t('families.show.no_pending_invitations', default: 'No pending invitations') %>
</p>
<% end %>
<!-- Invite New Member -->
<% if policy(@family).invite? %>
<div class="border-t pt-4">
<h3 class="text-lg font-medium text-gray-900 mb-3">
<%= t('families.show.invite_member', default: 'Invite New Member') %>
</h3>
<%= form_with model: [@family, FamilyInvitation.new], url: family_invitations_path(@family), local: true, class: "space-y-3" do |form| %>
<div>
<%= 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" %>
</div>
<div class="flex justify-end">
<%= 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" %>
</div>
<% end %>
</div>
<% end %>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,141 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<div class="bg-white shadow rounded-lg p-8 text-center">
<!-- Family Invitation Header -->
<div class="mb-8">
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-blue-100 mb-4">
<svg class="h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<h1 class="text-3xl font-bold text-gray-900 mb-2">
<%= t('family_invitations.show.title', default: 'You\'re Invited!') %>
</h1>
<p class="text-xl text-gray-600 mb-4">
<%= t('family_invitations.show.invitation_message',
default: 'You have been invited to join %{family_name}',
family_name: @invitation.family.name) %>
</p>
</div>
<!-- Invitation Details -->
<div class="bg-gray-50 rounded-lg p-6 mb-8">
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_invitations.show.family_name', default: 'Family Name') %>
</dt>
<dd class="text-lg font-semibold text-gray-900"><%= @invitation.family.name %></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_invitations.show.invited_by', default: 'Invited by') %>
</dt>
<dd class="text-lg font-semibold text-gray-900"><%= @invitation.invited_by.email %></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_invitations.show.invited_email', default: 'Invited Email') %>
</dt>
<dd class="text-lg font-semibold text-gray-900"><%= @invitation.email %></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_invitations.show.expires_at', default: 'Expires') %>
</dt>
<dd class="text-lg font-semibold text-gray-900">
<%= @invitation.expires_at.strftime('%B %d, %Y at %I:%M %p') %>
</dd>
</div>
</dl>
</div>
<!-- What This Means -->
<div class="bg-blue-50 rounded-lg p-6 mb-8 text-left">
<h3 class="text-lg font-medium text-blue-900 mb-3">
<%= t('family_invitations.show.what_this_means', default: 'What does joining a family mean?') %>
</h3>
<ul class="text-sm text-blue-800 space-y-2">
<li class="flex items-start">
<svg class="flex-shrink-0 h-5 w-5 text-blue-600 mt-0.5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<%= t('family_invitations.show.benefit_1', default: 'Share your location data with family members') %>
</li>
<li class="flex items-start">
<svg class="flex-shrink-0 h-5 w-5 text-blue-600 mt-0.5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<%= t('family_invitations.show.benefit_2', default: 'View shared maps and statistics') %>
</li>
<li class="flex items-start">
<svg class="flex-shrink-0 h-5 w-5 text-blue-600 mt-0.5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<%= t('family_invitations.show.benefit_3', default: 'Stay connected with loved ones\' travels') %>
</li>
<li class="flex items-start">
<svg class="flex-shrink-0 h-5 w-5 text-blue-600 mt-0.5 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<%= t('family_invitations.show.benefit_4', default: 'You can leave the family at any time') %>
</li>
</ul>
</div>
<!-- Action Buttons -->
<div class="space-y-4">
<% if user_signed_in? %>
<!-- User is logged in, show accept button -->
<%= 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 %>
<p class="text-sm text-gray-500">
<%= 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 %>
</p>
<% else %>
<!-- User is not logged in, show login/register options -->
<div class="space-y-3">
<%= 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 %>
</div>
<p class="text-sm text-gray-500">
<%= t('family_invitations.show.need_account', default: 'You need to be logged in to accept this invitation.') %>
</p>
<% end %>
<!-- Decline Option -->
<div class="pt-4 border-t border-gray-200">
<p class="text-sm text-gray-500 mb-2">
<%= t('family_invitations.show.decline_message', default: 'Don\'t want to join this family?') %>
</p>
<p class="text-xs text-gray-400">
<%= t('family_invitations.show.decline_instructions', default: 'You can simply close this page. The invitation will remain valid until it expires.') %>
</p>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,174 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-4xl mx-auto">
<!-- Header -->
<div class="bg-white shadow rounded-lg p-6 mb-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">
<%= t('family_memberships.index.title', default: 'Family Members') %>
</h1>
<p class="text-gray-600 mt-1">
<%= t('family_memberships.index.subtitle', default: 'Manage members of %{family_name}', family_name: @family.name) %>
</p>
</div>
<%= 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 %>
</div>
</div>
<!-- Members List -->
<div class="bg-white shadow rounded-lg overflow-hidden">
<div class="px-6 py-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">
<%= t('family_memberships.index.members_count', default: 'All Members (%{count})', count: @members.count) %>
</h2>
</div>
<div class="divide-y divide-gray-200">
<% @members.each do |member| %>
<div class="px-6 py-4 hover:bg-gray-50">
<div class="flex items-center justify-between">
<!-- Member Info -->
<div class="flex items-center space-x-4">
<div class="flex-shrink-0">
<div class="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
<span class="text-sm font-medium text-gray-700">
<%= member.email.first.upcase %>
</span>
</div>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium text-gray-900 truncate">
<%= member.email %>
</p>
<% if member.family_membership.role == 'owner' %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
<%= t('family_memberships.index.owner', default: 'Owner') %>
</span>
<% else %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
<%= member.family_membership.role.humanize %>
</span>
<% end %>
<% if member == current_user %>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
<%= t('family_memberships.index.you', default: 'You') %>
</span>
<% end %>
</div>
<div class="flex items-center space-x-4 mt-1">
<p class="text-sm text-gray-500">
<%= t('family_memberships.index.joined', default: 'Joined %{date}', date: member.family_membership.created_at.strftime('%B %d, %Y')) %>
</p>
<% if member.family_membership.role == 'owner' %>
<p class="text-sm text-gray-500">
<%= t('family_memberships.index.created_family', default: 'Created this family') %>
</p>
<% end %>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center space-x-3">
<%= 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 %>
<span class="text-gray-400 text-sm">
<%= t('family_memberships.index.cannot_remove_owner', default: 'Cannot remove owner') %>
</span>
<% end %>
<% end %>
</div>
</div>
</div>
<% end %>
</div>
</div>
<!-- Family Statistics -->
<div class="mt-6 grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
<%= t('family_memberships.index.total_members', default: 'Total Members') %>
</dt>
<dd class="text-lg font-medium text-gray-900">
<%= @members.count %>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
<%= t('family_memberships.index.active_members', default: 'Active Members') %>
</dt>
<dd class="text-lg font-medium text-gray-900">
<%= @members.count %>
</dd>
</dl>
</div>
</div>
</div>
<div class="bg-white shadow rounded-lg p-6">
<div class="flex items-center">
<div class="flex-shrink-0">
<svg class="h-8 w-8 text-yellow-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div class="ml-5 w-0 flex-1">
<dl>
<dt class="text-sm font-medium text-gray-500 truncate">
<%= t('family_memberships.index.family_age', default: 'Family Age') %>
</dt>
<dd class="text-lg font-medium text-gray-900">
<%= time_ago_in_words(@family.created_at) %>
</dd>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,208 @@
<div class="container mx-auto px-4 py-8">
<div class="max-w-3xl mx-auto">
<!-- Header -->
<div class="bg-white shadow rounded-lg p-6 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-4">
<div class="h-16 w-16 rounded-full bg-gray-300 flex items-center justify-center">
<span class="text-xl font-medium text-gray-700">
<%= @membership.user.email.first.upcase %>
</span>
</div>
<div>
<h1 class="text-2xl font-bold text-gray-900">
<%= @membership.user.email %>
</h1>
<div class="flex items-center space-x-2 mt-1">
<% if @membership.role == 'owner' %>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-yellow-100 text-yellow-800">
<%= t('family_memberships.show.owner', default: 'Family Owner') %>
</span>
<% else %>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gray-100 text-gray-800">
<%= @membership.role.humanize %>
</span>
<% end %>
<% if @membership.user == current_user %>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
<%= t('family_memberships.show.you', default: 'You') %>
</span>
<% end %>
</div>
</div>
</div>
<div class="flex space-x-3">
<%= 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 %>
</div>
</div>
</div>
<!-- Member Details -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Basic Information -->
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
<%= t('family_memberships.show.basic_info', default: 'Basic Information') %>
</h2>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_memberships.show.email', default: 'Email Address') %>
</dt>
<dd class="mt-1 text-sm text-gray-900"><%= @membership.user.email %></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_memberships.show.role', default: 'Family Role') %>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<%= @membership.role.humanize %>
<% if @membership.role == 'owner' %>
<span class="text-gray-500">
- <%= t('family_memberships.show.owner_description', default: 'Can manage family settings and members') %>
</span>
<% end %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_memberships.show.joined_date', default: 'Joined Date') %>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<%= @membership.created_at.strftime('%B %d, %Y at %I:%M %p') %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_memberships.show.time_in_family', default: 'Time in Family') %>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<%= time_ago_in_words(@membership.created_at) %>
</dd>
</div>
</dl>
</div>
<!-- Family Information -->
<div class="bg-white shadow rounded-lg p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">
<%= t('family_memberships.show.family_info', default: 'Family Information') %>
</h2>
<dl class="space-y-4">
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_memberships.show.family_name', default: 'Family Name') %>
</dt>
<dd class="mt-1 text-sm text-gray-900"><%= @family.name %></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_memberships.show.family_creator', default: 'Family Creator') %>
</dt>
<dd class="mt-1 text-sm text-gray-900"><%= @family.creator.email %></dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_memberships.show.family_created', default: 'Family Created') %>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<%= @family.created_at.strftime('%B %d, %Y') %>
</dd>
</div>
<div>
<dt class="text-sm font-medium text-gray-500">
<%= t('family_memberships.show.total_members', default: 'Total Members') %>
</dt>
<dd class="mt-1 text-sm text-gray-900">
<%= pluralize(@family.members.count, 'member') %>
</dd>
</div>
</dl>
</div>
</div>
<!-- Actions and Warnings -->
<div class="mt-6 space-y-4">
<!-- Owner-specific Warning -->
<% if @membership.owner? && @family.members.count > 1 %>
<div class="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-yellow-800">
<%= t('family_memberships.show.owner_warning_title', default: 'Family Owner Protection') %>
</h3>
<div class="mt-2 text-sm text-yellow-700">
<p>
<%= 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.') %>
</p>
</div>
</div>
</div>
</div>
<% end %>
<!-- Self-removal Info -->
<% if @membership.user == current_user %>
<div class="bg-blue-50 border border-blue-200 rounded-md p-4">
<div class="flex">
<div class="flex-shrink-0">
<svg class="h-5 w-5 text-blue-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
</svg>
</div>
<div class="ml-3">
<h3 class="text-sm font-medium text-blue-800">
<%= t('family_memberships.show.self_info_title', default: 'Your Membership') %>
</h3>
<div class="mt-2 text-sm text-blue-700">
<p>
<%= 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.') %>
</p>
</div>
<div class="mt-3">
<%= 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 %>
</div>
</div>
</div>
</div>
<% end %>
</div>
</div>
</div>

View file

@ -8,6 +8,15 @@
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
<% if user_signed_in? %>
<li>
<% 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 %>
</li>
<% end %>
<li>
<details>
<summary>My data</summary>
@ -56,6 +65,15 @@
<li><%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %></li>
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %></li>
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
<% if user_signed_in? %>
<li>
<% 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 %>
</li>
<% end %>
<li>
<details>
<summary>My data</summary>

View file

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

View file

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

View file

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

View file

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