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' %>
@@ -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 @@
-
Create New Place
+
Create New Place
diff --git a/config/initializers/01_constants.rb b/config/initializers/01_constants.rb
index 34094edd..a360d0d5 100644
--- a/config/initializers/01_constants.rb
+++ b/config/initializers/01_constants.rb
@@ -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
diff --git a/docker/.env.example b/docker/.env.example
index 18efe182..a96ddf9a 100644
--- a/docker/.env.example
+++ b/docker/.env.example
@@ -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=
diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb
new file mode 100644
index 00000000..5a06a6c8
--- /dev/null
+++ b/spec/helpers/application_helper_spec.rb
@@ -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
diff --git a/spec/requests/users/omniauth_callbacks_spec.rb b/spec/requests/users/omniauth_callbacks_spec.rb
index 7fbc1b07..1ba8e0f4 100644
--- a/spec/requests/users/omniauth_callbacks_spec.rb
+++ b/spec/requests/users/omniauth_callbacks_spec.rb
@@ -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
diff --git a/spec/requests/users/registrations_spec.rb b/spec/requests/users/registrations_spec.rb
index 5cbe54a8..b0049414 100644
--- a/spec/requests/users/registrations_spec.rb
+++ b/spec/requests/users/registrations_spec.rb
@@ -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)
diff --git a/spec/views/devise/shared/_links.html.erb_spec.rb b/spec/views/devise/shared/_links.html.erb_spec.rb
new file mode 100644
index 00000000..6972747b
--- /dev/null
+++ b/spec/views/devise/shared/_links.html.erb_spec.rb
@@ -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