mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 08:47:11 -05:00
Update OIDC auto-registration and email/password registration settings
This commit is contained in:
parent
f447039bbe
commit
4aa6edc3cc
18 changed files with 619 additions and 77 deletions
20
CHANGELOG.md
20
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? `<p class="text-sm text-gray-600 mb-2 italic">${this.escapeHtml(place.note)}</p>` : ''}
|
||||
${place.visits_count ? `<p class="text-sm">Visits: ${place.visits_count}</p>` : ''}
|
||||
<div class="mt-2 flex gap-2">
|
||||
<button class="btn btn-xs btn-primary" data-place-id="${place.id}" data-action="edit-place">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn btn-xs btn-error" data-place-id="${place.id}" data-action="delete-place">
|
||||
Delete
|
||||
</button>
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
13
app/jobs/place_visits_calculating_job.rb
Normal file
13
app/jobs/place_visits_calculating_job.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
89
app/services/places/visits/create.rb
Normal file
89
app/services/places/visits/create.rb
Normal file
|
|
@ -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
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
</div>
|
||||
<% 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' %>
|
||||
<div class='my-2'>
|
||||
<%= link_to "Register", new_registration_path(resource_name) %>
|
||||
</div>
|
||||
|
|
@ -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 } %><br />
|
||||
<%= button_to "Sign in with #{oauth_provider_name(provider)}", omniauth_authorize_path(resource_name, provider), data: { turbo: false } %><br />
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<div data-controller="place-creation" data-place-creation-api-key-value="<%= current_user.api_key %>">
|
||||
<div class="modal" data-place-creation-target="modal">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="font-bold text-lg mb-4">Create New Place</h3>
|
||||
<h3 class="font-bold text-lg mb-4" data-place-creation-target="modalTitle">Create New Place</h3>
|
||||
|
||||
<form data-place-creation-target="form" data-action="submit->place-creation#submit">
|
||||
<input type="hidden" name="latitude" data-place-creation-target="latitudeInput">
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" data-action="click->place-creation#close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Create Place</button>
|
||||
<button type="submit" class="btn btn-primary" data-place-creation-target="submitButton">Create Place</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,16 +42,15 @@ METRICS_PASSWORD = ENV.fetch('METRICS_PASSWORD', 'prometheus')
|
|||
OMNIAUTH_PROVIDERS =
|
||||
if SELF_HOSTED
|
||||
# Self-hosted: only OpenID Connect
|
||||
(ENV['OIDC_CLIENT_ID'].present? && ENV['OIDC_CLIENT_SECRET'].present?) ? %i[openid_connect] : []
|
||||
ENV['OIDC_CLIENT_ID'].present? && ENV['OIDC_CLIENT_SECRET'].present? ? %i[openid_connect] : []
|
||||
else
|
||||
# Cloud: only GitHub and Google
|
||||
providers = []
|
||||
|
||||
if ENV['GITHUB_OAUTH_CLIENT_ID'].present? && ENV['GITHUB_OAUTH_CLIENT_SECRET'].present?
|
||||
providers << :github
|
||||
end
|
||||
providers << :github if ENV['GITHUB_OAUTH_CLIENT_ID'].present? && ENV['GITHUB_OAUTH_CLIENT_SECRET'].present?
|
||||
|
||||
if ENV['GOOGLE_OAUTH_CLIENT_ID'].present? && ENV['GOOGLE_OAUTH_CLIENT_SECRET'].present?
|
||||
providers << :google_oauth2
|
||||
end
|
||||
providers << :google_oauth2 if ENV['GOOGLE_OAUTH_CLIENT_ID'].present? && ENV['GOOGLE_OAUTH_CLIENT_SECRET'].present?
|
||||
end
|
||||
|
||||
# Custom OIDC provider display name
|
||||
OIDC_PROVIDER_NAME = ENV.fetch('OIDC_PROVIDER_NAME', 'Openid Connect').freeze
|
||||
|
|
|
|||
|
|
@ -154,6 +154,27 @@ 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 Provider Name
|
||||
# Custom display name for your OIDC provider shown on the sign-in page
|
||||
# Default: "Openid Connect" (if not specified)
|
||||
# Examples: "Authelia", "Authentik", "Keycloak", "Company SSO"
|
||||
OIDC_PROVIDER_NAME=Authentik
|
||||
|
||||
# OIDC Auto-Registration
|
||||
# Controls whether new users are automatically created when signing in with OIDC
|
||||
# Set to 'false' to require administrators to pre-create user accounts
|
||||
# When disabled, OIDC users must have an existing account (matching email) to sign in
|
||||
# Default: true (automatically create new users)
|
||||
OIDC_AUTO_REGISTER=true
|
||||
|
||||
# Authentication Methods Control
|
||||
# Control which authentication methods are available in self-hosted mode
|
||||
#
|
||||
# ALLOW_EMAIL_PASSWORD_REGISTRATION - Allow users to register with email/password
|
||||
# Default: false (disabled in self-hosted mode, only family invitations allowed)
|
||||
# Set to 'true' to allow public email/password registration alongside OIDC
|
||||
ALLOW_EMAIL_PASSWORD_REGISTRATION=false
|
||||
|
||||
# Option 2: Manual Endpoint Configuration (if discovery is not supported)
|
||||
# Use this if your provider doesn't support OIDC discovery
|
||||
# OIDC_CLIENT_ID=
|
||||
|
|
|
|||
88
spec/helpers/application_helper_spec.rb
Normal file
88
spec/helpers/application_helper_spec.rb
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe ApplicationHelper, type: :helper do
|
||||
describe '#oauth_provider_name' do
|
||||
context 'when provider is openid_connect' do
|
||||
it 'returns the custom OIDC provider name' do
|
||||
stub_const('OIDC_PROVIDER_NAME', 'Authentik')
|
||||
|
||||
expect(helper.oauth_provider_name(:openid_connect)).to eq('Authentik')
|
||||
end
|
||||
|
||||
it 'returns default name when OIDC_PROVIDER_NAME is not set' do
|
||||
stub_const('OIDC_PROVIDER_NAME', 'Openid Connect')
|
||||
|
||||
expect(helper.oauth_provider_name(:openid_connect)).to eq('Openid Connect')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when provider is not openid_connect' do
|
||||
it 'returns camelized provider name for github' do
|
||||
expect(helper.oauth_provider_name(:github)).to eq('GitHub')
|
||||
end
|
||||
|
||||
it 'returns camelized provider name for google_oauth2' do
|
||||
expect(helper.oauth_provider_name(:google_oauth2)).to eq('GoogleOauth2')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#email_password_registration_enabled?' do
|
||||
context 'in cloud mode' do
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(helper.email_password_registration_enabled?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'in self-hosted mode' do
|
||||
before do
|
||||
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
|
||||
end
|
||||
|
||||
context 'when ALLOW_EMAIL_PASSWORD_REGISTRATION is true' do
|
||||
around do |example|
|
||||
original_value = ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION']
|
||||
ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION'] = 'true'
|
||||
example.run
|
||||
ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION'] = original_value
|
||||
end
|
||||
|
||||
it 'returns true' do
|
||||
expect(helper.email_password_registration_enabled?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ALLOW_EMAIL_PASSWORD_REGISTRATION is false' do
|
||||
around do |example|
|
||||
original_value = ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION']
|
||||
ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION'] = 'false'
|
||||
example.run
|
||||
ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION'] = original_value
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(helper.email_password_registration_enabled?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when ALLOW_EMAIL_PASSWORD_REGISTRATION is not set' do
|
||||
around do |example|
|
||||
original_value = ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION']
|
||||
ENV.delete('ALLOW_EMAIL_PASSWORD_REGISTRATION')
|
||||
example.run
|
||||
ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION'] = original_value
|
||||
end
|
||||
|
||||
it 'returns false (default)' do
|
||||
expect(helper.email_password_registration_enabled?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -63,6 +63,45 @@ RSpec.describe 'Users::OmniauthCallbacks', type: :request do
|
|||
end
|
||||
|
||||
include_examples 'successful OAuth authentication', :openid_connect, 'OpenID Connect'
|
||||
|
||||
context 'when OIDC auto-registration is disabled' do
|
||||
around do |example|
|
||||
original_value = ENV['OIDC_AUTO_REGISTER']
|
||||
ENV['OIDC_AUTO_REGISTER'] = 'false'
|
||||
example.run
|
||||
ENV['OIDC_AUTO_REGISTER'] = original_value
|
||||
end
|
||||
|
||||
context "when user doesn't exist" do
|
||||
it 'rejects the user with an appropriate error message' do
|
||||
expect do
|
||||
Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:openid_connect]
|
||||
get '/users/auth/openid_connect/callback'
|
||||
end.not_to change(User, :count)
|
||||
|
||||
expect(response).to redirect_to(root_path)
|
||||
expect(flash[:alert]).to include('Your account must be created by an administrator')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user already exists (account linking)' do
|
||||
let!(:existing_user) { create(:user, email: email) }
|
||||
|
||||
it 'signs in the existing user and links OIDC provider' do
|
||||
expect do
|
||||
Rails.application.env_config['omniauth.auth'] = OmniAuth.config.mock_auth[:openid_connect]
|
||||
get '/users/auth/openid_connect/callback'
|
||||
end.not_to change(User, :count)
|
||||
|
||||
expect(response).to redirect_to(root_path)
|
||||
expect(flash[:notice]).to include('OpenID Connect')
|
||||
|
||||
existing_user.reload
|
||||
expect(existing_user.provider).to eq('openid_connect')
|
||||
expect(existing_user.uid).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'OAuth flow integration with OpenID Connect' do
|
||||
|
|
|
|||
|
|
@ -140,7 +140,11 @@ RSpec.describe 'Users::Registrations', type: :request do
|
|||
allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('true')
|
||||
end
|
||||
|
||||
context 'when accessing registration without invitation token' do
|
||||
context 'when accessing registration without invitation token and email/password registration disabled' do
|
||||
before do
|
||||
allow(ENV).to receive(:[]).with('ALLOW_EMAIL_PASSWORD_REGISTRATION').and_return(nil)
|
||||
end
|
||||
|
||||
it 'redirects to root with error message' do
|
||||
get new_user_registration_path
|
||||
|
||||
|
|
@ -164,6 +168,37 @@ RSpec.describe 'Users::Registrations', type: :request do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when email/password registration is enabled' do
|
||||
around do |example|
|
||||
original_value = ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION']
|
||||
ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION'] = 'true'
|
||||
example.run
|
||||
ENV['ALLOW_EMAIL_PASSWORD_REGISTRATION'] = original_value
|
||||
end
|
||||
|
||||
it 'allows registration page access' do
|
||||
get new_user_registration_path
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
|
||||
it 'allows account creation' do
|
||||
expect do
|
||||
post user_registration_path, params: {
|
||||
user: {
|
||||
email: 'newuser@example.com',
|
||||
password: 'password123',
|
||||
password_confirmation: 'password123'
|
||||
}
|
||||
}
|
||||
end.to change(User, :count).by(1)
|
||||
|
||||
user = User.find_by(email: 'newuser@example.com')
|
||||
expect(user).to be_present
|
||||
expect(response).to redirect_to(root_path)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when accessing registration with valid invitation token' do
|
||||
it 'allows registration page access' do
|
||||
get new_user_registration_path(invitation_token: invitation.token)
|
||||
|
|
|
|||
50
spec/views/devise/shared/_links.html.erb_spec.rb
Normal file
50
spec/views/devise/shared/_links.html.erb_spec.rb
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'devise/shared/_links.html.erb', type: :view do
|
||||
let(:resource_name) { :user }
|
||||
let(:devise_mapping) { Devise.mappings[:user] }
|
||||
|
||||
before do
|
||||
def view.resource_name
|
||||
:user
|
||||
end
|
||||
|
||||
def view.devise_mapping
|
||||
Devise.mappings[:user]
|
||||
end
|
||||
|
||||
def view.resource_class
|
||||
User
|
||||
end
|
||||
|
||||
def view.signed_in?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
context 'with OIDC provider' do
|
||||
before do
|
||||
stub_const('OMNIAUTH_PROVIDERS', [:openid_connect])
|
||||
allow(User).to receive(:omniauth_providers).and_return([:openid_connect])
|
||||
end
|
||||
|
||||
it 'displays custom OIDC provider name' do
|
||||
stub_const('OIDC_PROVIDER_NAME', 'Authentik')
|
||||
|
||||
render
|
||||
|
||||
expect(rendered).to have_button('Sign in with Authentik')
|
||||
end
|
||||
|
||||
it 'displays default name when OIDC_PROVIDER_NAME is not set' do
|
||||
stub_const('OIDC_PROVIDER_NAME', 'Openid Connect')
|
||||
|
||||
render
|
||||
|
||||
expect(rendered).to have_button('Sign in with Openid Connect')
|
||||
end
|
||||
end
|
||||
|
||||
end
|
||||
Loading…
Reference in a new issue