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

View file

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

View file

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

View file

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

View file

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

View file

@ -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() {

View file

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

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

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

View file

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

View file

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

View file

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

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

View file

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

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