From 4aa6edc3cc9443ffec8da4eae8a9df82c7478f87 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Mon, 24 Nov 2025 19:33:51 +0100 Subject: [PATCH] Update OIDC auto-registration and email/password registration settings --- CHANGELOG.md | 20 +- .../users/omniauth_callbacks_controller.rb | 18 +- .../users/registrations_controller.rb | 8 + app/helpers/application_helper.rb | 15 ++ .../controllers/place_creation_controller.js | 83 +++++++-- app/javascript/maps/places.js | 175 ++++++++++++++---- .../area_visits_calculation_scheduling_job.rb | 5 +- app/jobs/place_visits_calculating_job.rb | 13 ++ app/models/concerns/omniauthable.rb | 14 +- app/services/places/visits/create.rb | 89 +++++++++ app/views/devise/shared/_links.html.erb | 4 +- .../shared/_place_creation_modal.html.erb | 4 +- config/initializers/01_constants.rb | 13 +- docker/.env.example | 21 +++ spec/helpers/application_helper_spec.rb | 88 +++++++++ .../requests/users/omniauth_callbacks_spec.rb | 39 ++++ spec/requests/users/registrations_spec.rb | 37 +++- .../devise/shared/_links.html.erb_spec.rb | 50 +++++ 18 files changed, 619 insertions(+), 77 deletions(-) create mode 100644 app/jobs/place_visits_calculating_job.rb create mode 100644 app/services/places/visits/create.rb create mode 100644 spec/helpers/application_helper_spec.rb create mode 100644 spec/views/devise/shared/_links.html.erb_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index eb6dc107..1f7b4807 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased -## TEST - -- [ ] KML upload -- [ ] OIDC login with Authentik - - [ ] How does it work with existing settings to disable registrations? -- [x] Place creation -- [x] Tag creation + management -- [x] Place privacy zones -- [x] Settings panel is scrollable -- [ ] Family members can enable their location sharing and see each other on the map -- [x] Home location - # OIDC and KML support release To configure your OIDC provider, set the following environment variables: @@ -27,14 +15,22 @@ OIDC_CLIENT_ID=client_id_example OIDC_CLIENT_SECRET=client_secret_example OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/ OIDC_REDIRECT_URI=https://your-dawarich-url.com/users/auth/openid_connect/callback +OIDC_AUTO_REGISTER=true # optional, default is false +OIDC_PROVIDER_NAME=YourProviderName # optional, default is OpenID Connect +ALLOW_EMAIL_PASSWORD_REGISTRATION=false # optional, default is true ``` +So, you want to configure your OIDC provider. If not — skip to the actual changelog. You're going to need to provide at least 4 environment variables: `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_ISSUER`, and `OIDC_REDIRECT_URI`. Then, if you want to rename the provider from "OpenID Connect" to something else (e.g. "Authentik"), set `OIDC_PROVIDER_NAME` variable as well. If you want to disable email/password registration and allow only OIDC login, set `ALLOW_EMAIL_PASSWORD_REGISTRATION` to `false`. After just 7 brand new environment variables, you'll never have to deal with passwords in Dawarich again! + +Jokes aside, even though I'm not a fan of bloating the environment with too many variables, this is a nice addition and it will be reused in the cloud version of Dawarich as well. Thanks for waiting more than a year for this feature! + ## Added - Support for KML file uploads. #350 - Added a commented line in the `docker-compose.yml` file to use an alternative PostGIS image for ARM architecture. - User can now create a place directly from the map and add tags and notes to it. If reverse geocoding is enabled, list of nearby places will be shown as suggestions. - User can create and manage tags for places. +- Visits for manually created places are being suggested automatically, just like for areas. - User can enable or disable places layers on the map to show/hide all or just some of their visited places based on tags. - User can define privacy zones around places with specific tags to hide map data within a certain radius. - If user has a place tagged with a tag named "Home" (case insensitive), and this place doesn't have a privacy zone defined, this place will be used as home location for days with no tracked data. #1659 #1575 diff --git a/app/controllers/users/omniauth_callbacks_controller.rb b/app/controllers/users/omniauth_callbacks_controller.rb index 10fd37ac..d24f4ea3 100644 --- a/app/controllers/users/omniauth_callbacks_controller.rb +++ b/app/controllers/users/omniauth_callbacks_controller.rb @@ -44,11 +44,27 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController def handle_auth(provider) @user = User.from_omniauth(request.env['omniauth.auth']) - if @user.persisted? + if @user&.persisted? flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: provider sign_in_and_redirect @user, event: :authentication + elsif @user.nil? + # User creation was rejected (e.g., OIDC auto-register disabled) + error_message = if provider == 'OpenID Connect' && !oidc_auto_register_enabled? + 'Your account must be created by an administrator before you can sign in with OIDC. ' \ + 'Please contact your administrator.' + else + 'Unable to create your account. Please try again or contact support.' + end + redirect_to root_path, alert: error_message else redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n") end end + + def oidc_auto_register_enabled? + env_value = ENV['OIDC_AUTO_REGISTER'] + return true if env_value.nil? + + ActiveModel::Type::Boolean.new.cast(env_value) + end end diff --git a/app/controllers/users/registrations_controller.rb b/app/controllers/users/registrations_controller.rb index f01254a8..518d4c4d 100644 --- a/app/controllers/users/registrations_controller.rb +++ b/app/controllers/users/registrations_controller.rb @@ -45,6 +45,7 @@ class Users::RegistrationsController < Devise::RegistrationsController def check_registration_allowed return unless self_hosted_mode? return if valid_invitation_token? + return if email_password_registration_allowed? redirect_to root_path, alert: 'Registration is not available. Please contact your administrator for access.' @@ -96,4 +97,11 @@ class Users::RegistrationsController < Devise::RegistrationsController def sign_up_params super end + + def email_password_registration_allowed? + env_value = ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION'] + return false if env_value.nil? + + ActiveModel::Type::Boolean.new.cast(env_value) + end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 391b6e30..268bb714 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -130,4 +130,19 @@ module ApplicationHelper 'btn-success' end end + + def oauth_provider_name(provider) + return OIDC_PROVIDER_NAME if provider == :openid_connect + + OmniAuth::Utils.camelize(provider) + end + + def email_password_registration_enabled? + return true unless DawarichSettings.self_hosted? + + env_value = ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION'] + return false if env_value.nil? + + ActiveModel::Type::Boolean.new.cast(env_value) + end end diff --git a/app/javascript/controllers/place_creation_controller.js b/app/javascript/controllers/place_creation_controller.js index cb531063..e4bc5ba1 100644 --- a/app/javascript/controllers/place_creation_controller.js +++ b/app/javascript/controllers/place_creation_controller.js @@ -1,8 +1,9 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["modal", "form", "nameInput", "latitudeInput", "longitudeInput", - "nearbyList", "loadingSpinner", "tagCheckboxes", "loadMoreContainer", "loadMoreButton"] + static targets = ["modal", "form", "nameInput", "latitudeInput", "longitudeInput", "noteInput", + "nearbyList", "loadingSpinner", "tagCheckboxes", "loadMoreContainer", "loadMoreButton", + "modalTitle", "submitButton", "placeIdInput"] static values = { apiKey: String } @@ -12,12 +13,17 @@ export default class extends Controller { this.currentRadius = 0.5 // Start with 500m (0.5km) this.maxRadius = 1.5 // Max 1500m (1.5km) this.setupTagListeners() + this.editingPlaceId = null } setupEventListeners() { document.addEventListener('place:create', (e) => { this.open(e.detail.latitude, e.detail.longitude) }) + + document.addEventListener('place:edit', (e) => { + this.openForEdit(e.detail.place) + }) } setupTagListeners() { @@ -47,22 +53,71 @@ export default class extends Controller { } async open(latitude, longitude) { + this.editingPlaceId = null this.latitudeInputTarget.value = latitude this.longitudeInputTarget.value = longitude this.currentRadius = 0.5 // Reset radius when opening modal + // Update modal for creation mode + if (this.hasModalTitleTarget) { + this.modalTitleTarget.textContent = 'Create New Place' + } + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.textContent = 'Create Place' + } + this.modalTarget.classList.add('modal-open') this.nameInputTarget.focus() await this.loadNearbyPlaces(latitude, longitude) } + async openForEdit(place) { + this.editingPlaceId = place.id + this.currentRadius = 0.5 + + // Fill in form with place data + this.nameInputTarget.value = place.name + this.latitudeInputTarget.value = place.latitude + this.longitudeInputTarget.value = place.longitude + + if (this.hasNoteInputTarget && place.note) { + this.noteInputTarget.value = place.note + } + + // Update modal for edit mode + if (this.hasModalTitleTarget) { + this.modalTitleTarget.textContent = 'Edit Place' + } + if (this.hasSubmitButtonTarget) { + this.submitButtonTarget.textContent = 'Update Place' + } + + // Check the appropriate tag checkboxes + const tagCheckboxes = this.formTarget.querySelectorAll('input[name="tag_ids[]"]') + tagCheckboxes.forEach(checkbox => { + const isSelected = place.tags.some(tag => tag.id === parseInt(checkbox.value)) + checkbox.checked = isSelected + + // Trigger change event to update badge styling + const event = new Event('change', { bubbles: true }) + checkbox.dispatchEvent(event) + }) + + this.modalTarget.classList.add('modal-open') + this.nameInputTarget.focus() + + // Load nearby places for suggestions + await this.loadNearbyPlaces(place.latitude, place.longitude) + } + close() { this.modalTarget.classList.remove('modal-open') this.formTarget.reset() this.nearbyListTarget.innerHTML = '' this.loadMoreContainerTarget.classList.add('hidden') this.currentRadius = 0.5 + this.editingPlaceId = null const event = new CustomEvent('place:create:cancelled') document.dispatchEvent(event) @@ -180,14 +235,19 @@ export default class extends Controller { name: formData.get('name'), latitude: parseFloat(formData.get('latitude')), longitude: parseFloat(formData.get('longitude')), + note: formData.get('note') || null, source: 'manual', tag_ids: tagIds } } try { - const response = await fetch('/api/v1/places', { - method: 'POST', + const isEdit = this.editingPlaceId !== null + const url = isEdit ? `/api/v1/places/${this.editingPlaceId}` : '/api/v1/places' + const method = isEdit ? 'PATCH' : 'POST' + + const response = await fetch(url, { + method: method, headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${this.apiKeyValue}` @@ -197,18 +257,19 @@ export default class extends Controller { if (!response.ok) { const error = await response.json() - throw new Error(error.errors?.join(', ') || 'Failed to create place') + throw new Error(error.errors?.join(', ') || `Failed to ${isEdit ? 'update' : 'create'} place`) } const place = await response.json() - + this.close() - this.showNotification('Place created successfully!', 'success') - - const event = new CustomEvent('place:created', { detail: { place } }) - document.dispatchEvent(event) + this.showNotification(`Place ${isEdit ? 'updated' : 'created'} successfully!`, 'success') + + const eventName = isEdit ? 'place:updated' : 'place:created' + const customEvent = new CustomEvent(eventName, { detail: { place } }) + document.dispatchEvent(customEvent) } catch (error) { - console.error('Error creating place:', error) + console.error(`Error ${this.editingPlaceId ? 'updating' : 'creating'} place:`, error) this.showNotification(error.message, 'error') } } diff --git a/app/javascript/maps/places.js b/app/javascript/maps/places.js index 2fbecafa..eaf0498c 100644 --- a/app/javascript/maps/places.js +++ b/app/javascript/maps/places.js @@ -38,19 +38,89 @@ export class PlacesManager { // Show success message showFlashMessage('success', `Place "${place.name}" created successfully!`); - // Add the new place to the main places layer - await this.refreshPlaces(); + // Add the place to our local array + this.places.push(place); - // Refresh all filtered layers that are currently on the map - this.map.eachLayer((layer) => { - if (layer._tagIds !== undefined) { - // This is a filtered layer, reload it - this.loadPlacesIntoLayer(layer, layer._tagIds); - } - }); + // Create marker for the new place and add to main layer + const marker = this.createPlaceMarker(place); + if (marker) { + this.markers[place.id] = marker; + marker.addTo(this.placesLayer); + } // Ensure the main Places layer is visible this.ensurePlacesLayerVisible(); + + // Also add to any filtered layers that match this place's tags + this.map.eachLayer((layer) => { + if (layer._tagIds !== undefined) { + // Check if this place's tags match this filtered layer + const placeTagIds = place.tags.map(tag => tag.id); + const layerTagIds = layer._tagIds; + + // If it's an untagged layer (empty array) and place has no tags + if (layerTagIds.length === 0 && placeTagIds.length === 0) { + const marker = this.createPlaceMarker(place); + if (marker) layer.addLayer(marker); + } + // If place has any tags that match this layer's tags + else if (placeTagIds.some(tagId => layerTagIds.includes(tagId))) { + const marker = this.createPlaceMarker(place); + if (marker) layer.addLayer(marker); + } + } + }); + }); + + // Refresh places when a place is updated + document.addEventListener('place:updated', async (event) => { + const { place } = event.detail; + + // Show success message + showFlashMessage('success', `Place "${place.name}" updated successfully!`); + + // Update the place in our local array + const index = this.places.findIndex(p => p.id === place.id); + if (index !== -1) { + this.places[index] = place; + } + + // Remove old marker and add updated one to main layer + if (this.markers[place.id]) { + this.placesLayer.removeLayer(this.markers[place.id]); + } + const marker = this.createPlaceMarker(place); + if (marker) { + this.markers[place.id] = marker; + marker.addTo(this.placesLayer); + } + + // Update in all filtered layers + this.map.eachLayer((layer) => { + if (layer._tagIds !== undefined) { + // Remove old marker from this layer + layer.eachLayer((layerMarker) => { + if (layerMarker.options && layerMarker.options.placeId === place.id) { + layer.removeLayer(layerMarker); + } + }); + + // Check if updated place should be in this layer + const placeTagIds = place.tags.map(tag => tag.id); + const layerTagIds = layer._tagIds; + + // If it's an untagged layer (empty array) and place has no tags + if (layerTagIds.length === 0 && placeTagIds.length === 0) { + const marker = this.createPlaceMarker(place); + if (marker) layer.addLayer(marker); + } + // If place has any tags that match this layer's tags + else if (placeTagIds.some(tagId => layerTagIds.includes(tagId))) { + const marker = this.createPlaceMarker(place); + if (marker) layer.addLayer(marker); + } + } + }); }); } @@ -150,6 +220,9 @@ export class PlacesManager { ${place.note ? `

${this.escapeHtml(place.note)}

` : ''} ${place.visits_count ? `

Visits: ${place.visits_count}

` : ''}
+ @@ -171,10 +244,21 @@ export class PlacesManager { } }); - // Delegate event handling for delete buttons + // Delegate event handling for edit and delete buttons this.map.on('popupopen', (e) => { const popup = e.popup; - const deleteBtn = popup.getElement()?.querySelector('[data-action="delete-place"]'); + const popupElement = popup.getElement(); + + const editBtn = popupElement?.querySelector('[data-action="edit-place"]'); + const deleteBtn = popupElement?.querySelector('[data-action="delete-place"]'); + + if (editBtn) { + editBtn.addEventListener('click', () => { + const placeId = editBtn.dataset.placeId; + this.editPlace(placeId); + popup.remove(); + }); + } if (deleteBtn) { deleteBtn.addEventListener('click', async () => { @@ -211,6 +295,20 @@ export class PlacesManager { document.dispatchEvent(event); } + editPlace(placeId) { + const place = this.places.find(p => p.id === parseInt(placeId)); + if (!place) { + console.error('Place not found:', placeId); + return; + } + + const event = new CustomEvent('place:edit', { + detail: { place }, + bubbles: true + }); + document.dispatchEvent(event); + } + async deletePlace(placeId) { if (!confirm('Are you sure you want to delete this place?')) return; @@ -332,38 +430,37 @@ export class PlacesManager { return; } - // Try to find and enable the Places checkbox in the tree control + // Directly add the layer to the map first for immediate visibility + this.map.addLayer(this.placesLayer); + + // Then try to sync the checkbox in the layer control if it exists const layerControl = document.querySelector('.leaflet-control-layers'); - if (!layerControl) { - this.map.addLayer(this.placesLayer); - return; - } - - // Find the Places checkbox and enable it - setTimeout(() => { - const inputs = layerControl.querySelectorAll('input[type="checkbox"]'); - inputs.forEach(input => { - const label = input.closest('label') || input.nextElementSibling; - if (label && label.textContent.trim() === 'Places') { - if (!input.checked) { - // Set a flag to prevent saving during programmatic layer addition - if (window.mapsController) { - window.mapsController.isRestoringLayers = true; - } - - input.checked = true; - input.dispatchEvent(new Event('change', { bubbles: true })); - - // Reset the flag after a short delay to allow the event to process - setTimeout(() => { + if (layerControl) { + setTimeout(() => { + const inputs = layerControl.querySelectorAll('input[type="checkbox"]'); + inputs.forEach(input => { + const label = input.closest('label') || input.nextElementSibling; + if (label && label.textContent.trim() === 'Places') { + if (!input.checked) { + // Set a flag to prevent saving during programmatic layer addition if (window.mapsController) { - window.mapsController.isRestoringLayers = false; + window.mapsController.isRestoringLayers = true; } - }, 50); + + input.checked = true; + // Don't dispatch change event since we already added the layer + + // Reset the flag after a short delay + setTimeout(() => { + if (window.mapsController) { + window.mapsController.isRestoringLayers = false; + } + }, 50); + } } - } - }); - }, 100); + }); + }, 100); + } } show() { diff --git a/app/jobs/area_visits_calculation_scheduling_job.rb b/app/jobs/area_visits_calculation_scheduling_job.rb index 5725cb1c..35010d50 100644 --- a/app/jobs/area_visits_calculation_scheduling_job.rb +++ b/app/jobs/area_visits_calculation_scheduling_job.rb @@ -5,6 +5,9 @@ class AreaVisitsCalculationSchedulingJob < ApplicationJob sidekiq_options retry: false def perform - User.find_each { AreaVisitsCalculatingJob.perform_later(_1.id) } + User.find_each do |user| + AreaVisitsCalculatingJob.perform_later(user.id) + PlaceVisitsCalculatingJob.perform_later(user.id) + end end end diff --git a/app/jobs/place_visits_calculating_job.rb b/app/jobs/place_visits_calculating_job.rb new file mode 100644 index 00000000..3658c2f6 --- /dev/null +++ b/app/jobs/place_visits_calculating_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class PlaceVisitsCalculatingJob < ApplicationJob + queue_as :visit_suggesting + sidekiq_options retry: false + + def perform(user_id) + user = User.find(user_id) + places = user.places # Only user-owned places (with user_id) + + Places::Visits::Create.new(user, places).call + end +end diff --git a/app/models/concerns/omniauthable.rb b/app/models/concerns/omniauthable.rb index a9a9a325..c94aea5a 100644 --- a/app/models/concerns/omniauthable.rb +++ b/app/models/concerns/omniauthable.rb @@ -24,8 +24,10 @@ module Omniauthable return user end - return nil unless data['email'].present? + # Check if auto-registration is allowed for OIDC + return nil if provider == 'openid_connect' && !oidc_auto_register_enabled? + # Attempt to create user (will fail validation if email is blank) create( email: data['email'], password: Devise.friendly_token[0, 20], @@ -33,5 +35,15 @@ module Omniauthable uid: uid ) end + + private + + def oidc_auto_register_enabled? + # Default to true for backward compatibility + env_value = ENV['OIDC_AUTO_REGISTER'] + return true if env_value.nil? + + ActiveModel::Type::Boolean.new.cast(env_value) + end end end diff --git a/app/services/places/visits/create.rb b/app/services/places/visits/create.rb new file mode 100644 index 00000000..34398b60 --- /dev/null +++ b/app/services/places/visits/create.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +class Places::Visits::Create + attr_reader :user, :places + + # Default radius for place visit detection (in meters) + DEFAULT_PLACE_RADIUS = 100 + + def initialize(user, places) + @user = user + @places = places + @time_threshold_minutes = 30 || user.safe_settings.time_threshold_minutes + @merge_threshold_minutes = 15 || user.safe_settings.merge_threshold_minutes + end + + def call + places.map { place_visits(_1) } + end + + private + + def place_visits(place) + points_grouped_by_month = place_points(place) + visits_by_month = group_points_by_month(points_grouped_by_month) + + visits_by_month.each do |month, visits| + Rails.logger.info("Month: #{month}, Total visits: #{visits.size}") + + visits.each do |time_range, visit_points| + create_or_update_visit(place, time_range, visit_points) + end + end + end + + def place_points(place) + place_radius = + if user.safe_settings.distance_unit == :km + DEFAULT_PLACE_RADIUS / ::DISTANCE_UNITS[:km] + else + DEFAULT_PLACE_RADIUS / ::DISTANCE_UNITS[user.safe_settings.distance_unit.to_sym] + end + + points = Point.where(user_id: user.id) + .near([place.latitude, place.longitude], place_radius, user.safe_settings.distance_unit) + .order(timestamp: :asc) + + points.group_by { |point| Time.zone.at(point.timestamp).strftime('%Y-%m') } + end + + def group_points_by_month(points) + visits_by_month = {} + + points.each do |month, points_in_month| + visits_by_month[month] = Visits::Group.new( + time_threshold_minutes: @time_threshold_minutes, + merge_threshold_minutes: @merge_threshold_minutes + ).call(points_in_month) + end + + visits_by_month + end + + def create_or_update_visit(place, time_range, visit_points) + Rails.logger.info("Visit from #{time_range}, Points: #{visit_points.size}") + + ActiveRecord::Base.transaction do + visit = find_or_initialize_visit(place.id, visit_points.first.timestamp) + + visit.tap do |v| + v.name = "#{place.name}, #{time_range}" + v.ended_at = Time.zone.at(visit_points.last.timestamp) + v.duration = (visit_points.last.timestamp - visit_points.first.timestamp) / 60 + v.status = :suggested + end + + visit.save! + + visit_points.each { _1.update!(visit_id: visit.id) } + end + end + + def find_or_initialize_visit(place_id, timestamp) + Visit.find_or_initialize_by( + place_id:, + user_id: user.id, + started_at: Time.zone.at(timestamp) + ) + end +end diff --git a/app/views/devise/shared/_links.html.erb b/app/views/devise/shared/_links.html.erb index 857c7d41..f0efe8c0 100644 --- a/app/views/devise/shared/_links.html.erb +++ b/app/views/devise/shared/_links.html.erb @@ -5,7 +5,7 @@
<% end %> - <% if !SELF_HOSTED && defined?(devise_mapping) && devise_mapping&.registerable? && controller_name != 'registrations' %> + <% if email_password_registration_enabled? && defined?(devise_mapping) && devise_mapping&.registerable? && controller_name != 'registrations' %>
<%= link_to "Register", new_registration_path(resource_name) %>
@@ -31,7 +31,7 @@ <% if devise_mapping.omniauthable? %> <% resource_class.omniauth_providers.each do |provider| %> - <%= button_to "Sign in with #{OmniAuth::Utils.camelize(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } %>
+ <%= button_to "Sign in with #{oauth_provider_name(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } %>
<% end %> <% end %> diff --git a/app/views/shared/_place_creation_modal.html.erb b/app/views/shared/_place_creation_modal.html.erb index 18c9351a..d66f4fda 100644 --- a/app/views/shared/_place_creation_modal.html.erb +++ b/app/views/shared/_place_creation_modal.html.erb @@ -1,7 +1,7 @@