Update OIDC auto-registration and email/password registration settings

This commit is contained in:
Eugene Burmakin 2025-11-24 19:33:51 +01:00
parent f447039bbe
commit 4aa6edc3cc
18 changed files with 619 additions and 77 deletions

View file

@ -6,18 +6,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased ## 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 # OIDC and KML support release
To configure your OIDC provider, set the following environment variables: 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_CLIENT_SECRET=client_secret_example
OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/ OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/
OIDC_REDIRECT_URI=https://your-dawarich-url.com/users/auth/openid_connect/callback 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 ## Added
- Support for KML file uploads. #350 - 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. - 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 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. - 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 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. - 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 - 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

View file

@ -44,11 +44,27 @@ class Users::OmniauthCallbacksController < Devise::OmniauthCallbacksController
def handle_auth(provider) def handle_auth(provider)
@user = User.from_omniauth(request.env['omniauth.auth']) @user = User.from_omniauth(request.env['omniauth.auth'])
if @user.persisted? if @user&.persisted?
flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: provider flash[:notice] = I18n.t 'devise.omniauth_callbacks.success', kind: provider
sign_in_and_redirect @user, event: :authentication 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 else
redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n") redirect_to new_user_registration_url, alert: @user.errors.full_messages.join("\n")
end end
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 end

View file

@ -45,6 +45,7 @@ class Users::RegistrationsController < Devise::RegistrationsController
def check_registration_allowed def check_registration_allowed
return unless self_hosted_mode? return unless self_hosted_mode?
return if valid_invitation_token? return if valid_invitation_token?
return if email_password_registration_allowed?
redirect_to root_path, redirect_to root_path,
alert: 'Registration is not available. Please contact your administrator for access.' alert: 'Registration is not available. Please contact your administrator for access.'
@ -96,4 +97,11 @@ class Users::RegistrationsController < Devise::RegistrationsController
def sign_up_params def sign_up_params
super super
end 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 end

View file

@ -130,4 +130,19 @@ module ApplicationHelper
'btn-success' 'btn-success'
end end
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 end

View file

@ -1,8 +1,9 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
export default class extends Controller { export default class extends Controller {
static targets = ["modal", "form", "nameInput", "latitudeInput", "longitudeInput", static targets = ["modal", "form", "nameInput", "latitudeInput", "longitudeInput", "noteInput",
"nearbyList", "loadingSpinner", "tagCheckboxes", "loadMoreContainer", "loadMoreButton"] "nearbyList", "loadingSpinner", "tagCheckboxes", "loadMoreContainer", "loadMoreButton",
"modalTitle", "submitButton", "placeIdInput"]
static values = { static values = {
apiKey: String apiKey: String
} }
@ -12,12 +13,17 @@ export default class extends Controller {
this.currentRadius = 0.5 // Start with 500m (0.5km) this.currentRadius = 0.5 // Start with 500m (0.5km)
this.maxRadius = 1.5 // Max 1500m (1.5km) this.maxRadius = 1.5 // Max 1500m (1.5km)
this.setupTagListeners() this.setupTagListeners()
this.editingPlaceId = null
} }
setupEventListeners() { setupEventListeners() {
document.addEventListener('place:create', (e) => { document.addEventListener('place:create', (e) => {
this.open(e.detail.latitude, e.detail.longitude) this.open(e.detail.latitude, e.detail.longitude)
}) })
document.addEventListener('place:edit', (e) => {
this.openForEdit(e.detail.place)
})
} }
setupTagListeners() { setupTagListeners() {
@ -47,22 +53,71 @@ export default class extends Controller {
} }
async open(latitude, longitude) { async open(latitude, longitude) {
this.editingPlaceId = null
this.latitudeInputTarget.value = latitude this.latitudeInputTarget.value = latitude
this.longitudeInputTarget.value = longitude this.longitudeInputTarget.value = longitude
this.currentRadius = 0.5 // Reset radius when opening modal 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.modalTarget.classList.add('modal-open')
this.nameInputTarget.focus() this.nameInputTarget.focus()
await this.loadNearbyPlaces(latitude, longitude) 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() { close() {
this.modalTarget.classList.remove('modal-open') this.modalTarget.classList.remove('modal-open')
this.formTarget.reset() this.formTarget.reset()
this.nearbyListTarget.innerHTML = '' this.nearbyListTarget.innerHTML = ''
this.loadMoreContainerTarget.classList.add('hidden') this.loadMoreContainerTarget.classList.add('hidden')
this.currentRadius = 0.5 this.currentRadius = 0.5
this.editingPlaceId = null
const event = new CustomEvent('place:create:cancelled') const event = new CustomEvent('place:create:cancelled')
document.dispatchEvent(event) document.dispatchEvent(event)
@ -180,14 +235,19 @@ export default class extends Controller {
name: formData.get('name'), name: formData.get('name'),
latitude: parseFloat(formData.get('latitude')), latitude: parseFloat(formData.get('latitude')),
longitude: parseFloat(formData.get('longitude')), longitude: parseFloat(formData.get('longitude')),
note: formData.get('note') || null,
source: 'manual', source: 'manual',
tag_ids: tagIds tag_ids: tagIds
} }
} }
try { try {
const response = await fetch('/api/v1/places', { const isEdit = this.editingPlaceId !== null
method: 'POST', const url = isEdit ? `/api/v1/places/${this.editingPlaceId}` : '/api/v1/places'
const method = isEdit ? 'PATCH' : 'POST'
const response = await fetch(url, {
method: method,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKeyValue}` 'Authorization': `Bearer ${this.apiKeyValue}`
@ -197,18 +257,19 @@ export default class extends Controller {
if (!response.ok) { if (!response.ok) {
const error = await response.json() 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() const place = await response.json()
this.close() this.close()
this.showNotification('Place created successfully!', 'success') this.showNotification(`Place ${isEdit ? 'updated' : 'created'} successfully!`, 'success')
const event = new CustomEvent('place:created', { detail: { place } }) const eventName = isEdit ? 'place:updated' : 'place:created'
document.dispatchEvent(event) const customEvent = new CustomEvent(eventName, { detail: { place } })
document.dispatchEvent(customEvent)
} catch (error) { } catch (error) {
console.error('Error creating place:', error) console.error(`Error ${this.editingPlaceId ? 'updating' : 'creating'} place:`, error)
this.showNotification(error.message, 'error') this.showNotification(error.message, 'error')
} }
} }

View file

@ -38,19 +38,89 @@ export class PlacesManager {
// Show success message // Show success message
showFlashMessage('success', `Place "${place.name}" created successfully!`); showFlashMessage('success', `Place "${place.name}" created successfully!`);
// Add the new place to the main places layer // Add the place to our local array
await this.refreshPlaces(); this.places.push(place);
// Refresh all filtered layers that are currently on the map // Create marker for the new place and add to main layer
this.map.eachLayer((layer) => { const marker = this.createPlaceMarker(place);
if (layer._tagIds !== undefined) { if (marker) {
// This is a filtered layer, reload it this.markers[place.id] = marker;
this.loadPlacesIntoLayer(layer, layer._tagIds); marker.addTo(this.placesLayer);
} }
});
// Ensure the main Places layer is visible // Ensure the main Places layer is visible
this.ensurePlacesLayerVisible(); 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.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>` : ''} ${place.visits_count ? `<p class="text-sm">Visits: ${place.visits_count}</p>` : ''}
<div class="mt-2 flex gap-2"> <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"> <button class="btn btn-xs btn-error" data-place-id="${place.id}" data-action="delete-place">
Delete Delete
</button> </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) => { this.map.on('popupopen', (e) => {
const popup = e.popup; 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) { if (deleteBtn) {
deleteBtn.addEventListener('click', async () => { deleteBtn.addEventListener('click', async () => {
@ -211,6 +295,20 @@ export class PlacesManager {
document.dispatchEvent(event); 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) { async deletePlace(placeId) {
if (!confirm('Are you sure you want to delete this place?')) return; if (!confirm('Are you sure you want to delete this place?')) return;
@ -332,38 +430,37 @@ export class PlacesManager {
return; 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'); const layerControl = document.querySelector('.leaflet-control-layers');
if (!layerControl) { if (layerControl) {
this.map.addLayer(this.placesLayer); setTimeout(() => {
return; const inputs = layerControl.querySelectorAll('input[type="checkbox"]');
} inputs.forEach(input => {
const label = input.closest('label') || input.nextElementSibling;
// Find the Places checkbox and enable it if (label && label.textContent.trim() === 'Places') {
setTimeout(() => { if (!input.checked) {
const inputs = layerControl.querySelectorAll('input[type="checkbox"]'); // Set a flag to prevent saving during programmatic layer addition
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 (window.mapsController) { 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() { show() {

View file

@ -5,6 +5,9 @@ class AreaVisitsCalculationSchedulingJob < ApplicationJob
sidekiq_options retry: false sidekiq_options retry: false
def perform 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
end end

View 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

View file

@ -24,8 +24,10 @@ module Omniauthable
return user return user
end 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( create(
email: data['email'], email: data['email'],
password: Devise.friendly_token[0, 20], password: Devise.friendly_token[0, 20],
@ -33,5 +35,15 @@ module Omniauthable
uid: uid uid: uid
) )
end 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
end end

View 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

View file

@ -5,7 +5,7 @@
</div> </div>
<% end %> <% 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'> <div class='my-2'>
<%= link_to "Register", new_registration_path(resource_name) %> <%= link_to "Register", new_registration_path(resource_name) %>
</div> </div>
@ -31,7 +31,7 @@
<% if devise_mapping.omniauthable? %> <% if devise_mapping.omniauthable? %>
<% resource_class.omniauth_providers.each do |provider| %> <% 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 %>
<% end %> <% end %>
</div> </div>

View file

@ -1,7 +1,7 @@
<div data-controller="place-creation" data-place-creation-api-key-value="<%= current_user.api_key %>"> <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" data-place-creation-target="modal">
<div class="modal-box max-w-2xl"> <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"> <form data-place-creation-target="form" data-action="submit->place-creation#submit">
<input type="hidden" name="latitude" data-place-creation-target="latitudeInput"> <input type="hidden" name="latitude" data-place-creation-target="latitudeInput">
@ -80,7 +80,7 @@
<div class="modal-action"> <div class="modal-action">
<button type="button" class="btn btn-ghost" data-action="click->place-creation#close">Cancel</button> <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> </div>
</form> </form>
</div> </div>

View file

@ -42,16 +42,15 @@ METRICS_PASSWORD = ENV.fetch('METRICS_PASSWORD', 'prometheus')
OMNIAUTH_PROVIDERS = OMNIAUTH_PROVIDERS =
if SELF_HOSTED if SELF_HOSTED
# Self-hosted: only OpenID Connect # 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 else
# Cloud: only GitHub and Google # Cloud: only GitHub and Google
providers = [] providers = []
if ENV['GITHUB_OAUTH_CLIENT_ID'].present? && ENV['GITHUB_OAUTH_CLIENT_SECRET'].present? providers << :github if ENV['GITHUB_OAUTH_CLIENT_ID'].present? && ENV['GITHUB_OAUTH_CLIENT_SECRET'].present?
providers << :github
end
if ENV['GOOGLE_OAUTH_CLIENT_ID'].present? && ENV['GOOGLE_OAUTH_CLIENT_SECRET'].present? providers << :google_oauth2 if ENV['GOOGLE_OAUTH_CLIENT_ID'].present? && ENV['GOOGLE_OAUTH_CLIENT_SECRET'].present?
providers << :google_oauth2
end
end end
# Custom OIDC provider display name
OIDC_PROVIDER_NAME = ENV.fetch('OIDC_PROVIDER_NAME', 'Openid Connect').freeze

View file

@ -154,6 +154,27 @@ OIDC_CLIENT_SECRET=client_secret_example
OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/ OIDC_ISSUER=https://authentik.yourdomain.com/application/o/dawarich/
OIDC_REDIRECT_URI=https://your-dawarich-url.com/users/auth/openid_connect/callback 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) # Option 2: Manual Endpoint Configuration (if discovery is not supported)
# Use this if your provider doesn't support OIDC discovery # Use this if your provider doesn't support OIDC discovery
# OIDC_CLIENT_ID= # OIDC_CLIENT_ID=

View 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

View file

@ -63,6 +63,45 @@ RSpec.describe 'Users::OmniauthCallbacks', type: :request do
end end
include_examples 'successful OAuth authentication', :openid_connect, 'OpenID Connect' 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 end
describe 'OAuth flow integration with OpenID Connect' do describe 'OAuth flow integration with OpenID Connect' do

View file

@ -140,7 +140,11 @@ RSpec.describe 'Users::Registrations', type: :request do
allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('true') allow(ENV).to receive(:[]).with('SELF_HOSTED').and_return('true')
end 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 it 'redirects to root with error message' do
get new_user_registration_path get new_user_registration_path
@ -164,6 +168,37 @@ RSpec.describe 'Users::Registrations', type: :request do
end end
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 context 'when accessing registration with valid invitation token' do
it 'allows registration page access' do it 'allows registration page access' do
get new_user_registration_path(invitation_token: invitation.token) get new_user_registration_path(invitation_token: invitation.token)

View 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