Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fields to input client id and secret for login providers, fix collapse state on login UI #491

Merged
merged 6 commits into from
Jan 3, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions doc/development/social_login.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
Social Login Setup
=================

To enable social login for providers, you first need to create an OAuth application on the provider's website.


Google OAuth Application
------------------------
Create an OAuth application on https://console.developers.google.com/

Instructions:
- Follow the setup guide: https://medium.com/@tony.infisical/guide-to-using-oauth-2-0-to-access-google-apis-dead94d6866d
- Set the callback URL to: `{domain}/accounts/google/login/callback/`
- Add the client ID and client secret to admin settings


Github OAuth Application
-----------------------
Create an OAuth application on https://github.com/settings/applications/new

Instructions:
- Set the callback URL to: `{domain}/accounts/github/login/callback/`
- Add the client ID and client secret to admin settings


MediaWiki OAuth Application
--------------------------
To enable MediaWiki social login for your Pretix instance, you need to register an OAuth application with MediaWiki.

Important Notes
~~~~~~~~~~~~~~
- The OAuth application must be approved by a MediaWiki administrator
- Until approved, only the application owner can log in

Registration Steps
~~~~~~~~~~~~~~~~~
1. Register your OAuth application at:
https://meta.wikimedia.org/wiki/Special:OAuthConsumerRegistration/propose

2. Callback URL Configuration
- Set the OAuth "callback" URL to: `{domain}/accounts/mediawiki/login/callback/`
- Example: `http://localhost:8000/accounts/mediawiki/login/callback/`

3. Carefully read and follow the instructions on the registration page

4. The registered application will return:
- One consumer key
- One consumer secret

5. Add the consumer key and consumer secret to your Pretix admin settings

After Approval
~~~~~~~~~~~~~
Once approved, other users can log in to your Pretix instance using their MediaWiki account.
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ dependencies = [
'eventyay-paypal @ git+https://[email protected]/fossasia/eventyay-tickets-paypal.git@master',
'django_celery_beat==2.7.0',
'cron-descriptor==1.4.5',
'django-allauth[socialaccount]==65.3.0'
'django-allauth[socialaccount]==65.3.0',
'pydantic==2.10.4'
]

[project.optional-dependencies]
Expand Down
1 change: 1 addition & 0 deletions src/pretix/control/templates/pretixcontrol/auth/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
{% compress js %}
<script type="text/javascript" src="{% static "jquery/js/jquery-3.7.1.min.js" %}"></script>
<script type="text/javascript" src="{% static "bootstrap/js/bootstrap.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/collapse_state.js" %}"></script>
{% endcompress %}
<meta name="viewport" content="width=device-width, initial-scale=1">

Expand Down
6 changes: 3 additions & 3 deletions src/pretix/control/templates/pretixcontrol/auth/login.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,12 @@
</div>
</div>
<div class="form-group buttons">
<button type="button" class="btn btn-primary btn-block" data-toggle="collapse" data-target="#login-form">
<button type="button" id="toggle-login" class="btn btn-primary btn-block" data-toggle="collapse" data-target="#login-form">
{% trans "Login with Email" %}
</button>
{% if login_providers %}
{% for provider, enable in login_providers.items %}
{% if enable %}
{% for provider, settings in login_providers.items %}
{% if settings.state %}
<a href='{% url "plugins:socialauth:social.oauth.login" provider %}' data-method="post" class="btn btn-primary btn-block">
{% with provider|capfirst as provider_capitalized %}
{% blocktrans %}Login with {{ provider_capitalized }}{% endblocktrans %}
Expand Down
16 changes: 16 additions & 0 deletions src/pretix/plugins/socialauth/schemas/login_providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from pydantic import BaseModel, Field


class ProviderConfig(BaseModel):
state: bool = Field(description="State of this providers", default=False)
client_id: str = Field(description="Client ID of this provider", default="")
secret: str = Field(description="Secret of this provider", default="")


class LoginProviders(BaseModel):
mediawiki: ProviderConfig = Field(default_factory=ProviderConfig)
github: ProviderConfig = Field(default_factory=ProviderConfig)
google: ProviderConfig = Field(default_factory=ProviderConfig)

class Config:
extra = "forbid"
Original file line number Diff line number Diff line change
Expand Up @@ -25,30 +25,53 @@ <h1>{% trans "Social login settings" %}</h1>
<legend>{% trans "Social login providers" %}</legend>
<div class="table-responsive">
<table class="table">
{% for provider, enabled in login_providers.items %}
<tr class="{% if enabled %}success{% else %}default{% endif %}">
<td>
{% with provider|capfirst as provider_capitalized %}
<strong>{% trans provider_capitalized %}</strong>
<p>{% blocktrans %}Login with {{ provider_capitalized }}{% endblocktrans %}</p>
{% endwith %}
</td>
<td class="text-right flip" width="20%">
{% if enabled %}
<button class="btn btn-default btn-block" name="{{ provider }}_login" value="disabled">
{% trans "Disabled" %}
</button>
{% else %}
<button class="btn btn-default btn-block" name="{{ provider }}_login" value="enabled">
{% trans "Enabled" %}
</button>
{% endif %}
</td>
</tr>
{% for provider, settings in login_providers.items %}
<tr class="{% if settings.state %}success{% else %}default{% endif %}">
<td>
{% with provider|capfirst as provider_capitalized %}
<strong>{% trans provider_capitalized %}</strong>
<p>{% blocktrans %}Login with {{ provider_capitalized }}{% endblocktrans %}</p>
{% endwith %}
</td>
<td class="text-right flip" width="20%">
{% if settings.state %}
<button class="btn btn-default btn-block" name="{{ provider }}_login" value="disabled">
{% trans "Disable" %}
</button>
{% else %}
<button class="btn btn-default btn-block" name="{{ provider }}_login" value="enabled">
{% trans "Enable" %}
</button>
{% endif %}
</td>
</tr>
{% if settings.state %}
<tr>
<td colspan="2">
<div class="form-group">
<label class="col-sm-3 control-label">{% trans "Client ID" %}</label>
<div class="col-sm-9">
<input type="text" class="form-control" name="{{ provider }}_client_id" value="{{ settings.client_id }}">
</div>
</div>
<div class="form-group">
<label class="col-sm-3 control-label">{% trans "Secret" %}</label>
<div class="col-sm-9">
<input type="password" class="form-control" name="{{ provider }}_secret" value="{{ settings.secret }}">
</div>
</div>
</td>
</tr>
{% endif %}
{% endfor %}
</table>
</div>
</fieldset>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save" name="save_credentials" value="credentials">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
75 changes: 61 additions & 14 deletions src/pretix/plugins/socialauth/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,32 @@
from urllib.parse import urlencode, urlparse, urlunparse

from allauth.socialaccount.adapter import get_adapter
from allauth.socialaccount.models import SocialApp
from django.conf import settings
from django.contrib import messages
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import TemplateView
from pydantic import ValidationError

from pretix.base.models import User
from pretix.base.settings import GlobalSettingsObject
from pretix.control.permissions import AdministratorPermissionRequiredMixin
from pretix.control.views.auth import process_login_and_set_cookie
from pretix.helpers.urls import build_absolute_uri

from .schemas.login_providers import LoginProviders

logger = logging.getLogger(__name__)
adapter = get_adapter()


def oauth_login(request, provider):
base_url = adapter.get_provider(request, provider).get_login_url(request)
gs = GlobalSettingsObject()
client_id = gs.settings.get('login_providers', as_type=dict).get(provider, {}).get('client_id')
provider = adapter.get_provider(request, provider, client_id=client_id)

base_url = provider.get_login_url(request)
query_params = {
"next": build_absolute_uri("plugins:socialauth:social.oauth.return")
}
Expand Down Expand Up @@ -49,35 +57,74 @@ def oauth_return(request):
return redirect('control:auth.login')


class LoginState(StrEnum):
ENABLED = "enabled"
DISABLED = "disabled"


class SocialLoginView(AdministratorPermissionRequiredMixin, TemplateView):
template_name = 'socialauth/social_auth_settings.html'
LOGIN_PROVIDERS = {'mediawiki': False, 'github': False, 'google': False}

class SettingState(StrEnum):
ENABLED = "enabled"
DISABLED = "disabled"
CREDENTIALS = "credentials"

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.gs = GlobalSettingsObject()
self.set_initial_state()

def set_initial_state(self):
"""
Set the initial state of the login providers
If the login providers are not valid, set them to the default
"""
def validate_login_providers(login_providers):
try:
validated_providers = LoginProviders.model_validate(login_providers)
return validated_providers
except ValidationError as e:
logger.error('Error while validating login providers: %s', e)
return None

login_providers = self.gs.settings.get('login_providers', as_type=dict)
if login_providers is None or validate_login_providers(login_providers) is None:
self.gs.settings.set('login_providers', LoginProviders().model_dump())

def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
if self.gs.settings.get('login_providers', as_type=dict) is None:
self.gs.settings.set('login_providers', self.LOGIN_PROVIDERS)
context['login_providers'] = self.gs.settings.get('login_providers', as_type=dict)
return context

def post(self, request, *args, **kwargs):
login_providers = self.gs.settings.get('login_providers', as_type=dict)
for provider in self.LOGIN_PROVIDERS.keys():
value = request.POST.get(f'{provider}_login', '').lower()
if value not in [s.value for s in LoginState]:
continue
login_providers[provider] = value == LoginState.ENABLED
setting_state = request.POST.get('save_credentials', '').lower()

for provider in LoginProviders.model_fields.keys():
if setting_state == self.SettingState.CREDENTIALS:
self.update_credentials(request, provider, login_providers)
else:
self.update_provider_state(request, provider, login_providers)

self.gs.settings.set('login_providers', login_providers)
return redirect(self.get_success_url())

def update_credentials(self, request, provider, login_providers):
client_id_value = request.POST.get(f'{provider}_client_id', '')
secret_value = request.POST.get(f'{provider}_secret', '')

if client_id_value and secret_value:
login_providers[provider]['client_id'] = client_id_value
login_providers[provider]['secret'] = secret_value

SocialApp.objects.update_or_create(
provider=provider,
defaults={
'client_id': client_id_value,
'secret': secret_value,
}
)

def update_provider_state(self, request, provider, login_providers):
setting_state = request.POST.get(f'{provider}_login', '').lower()
if setting_state in [s.value for s in self.SettingState]:
login_providers[provider]['state'] = setting_state == self.SettingState.ENABLED

def get_success_url(self) -> str:
return reverse('plugins:socialauth:admin.global.social.auth.settings')
20 changes: 0 additions & 20 deletions src/pretix/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,23 +825,3 @@
SOCIALACCOUNT_EMAIL_REQUIRED = True
SOCIALACCOUNT_QUERY_EMAIL = True
SOCIALACCOUNT_LOGIN_ON_GET = True
SOCIALACCOUNT_PROVIDERS = {
"mediawiki": {
"APP": {
"client_id": config.get("social", "mediawiki_client_id", fallback=""),
"secret": config.get("social", "mediawiki_client_secret", fallback=""),
},
},
"google": {
"APP": {
"client_id": config.get("social", "google_client_id", fallback=""),
"secret": config.get("social", "google_client_secret", fallback=""),
},
},
"github": {
"APP": {
"client_id": config.get("social", "github_client_id", fallback=""),
"secret": config.get("social", "github_client_secret", fallback=""),
},
}
}
23 changes: 23 additions & 0 deletions src/pretix/static/pretixcontrol/js/ui/collapse_state.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
document.addEventListener('DOMContentLoaded', function () {
const loginForm = document.getElementById('login-form');
const toggleLogin = document.getElementById('toggle-login');
const collapseStateKey = 'loginFormCollapseState';

// Restore state from localStorage
const storedState = localStorage.getItem(collapseStateKey);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Add error handling for localStorage operations

localStorage might be unavailable in private browsing mode or if quota is exceeded. Consider wrapping these operations in try-catch blocks.

Suggested implementation:

    // Restore state from localStorage
    let storedState = null;
    try {
        storedState = localStorage.getItem(collapseStateKey);
        console.log(storedState);
    } catch (e) {
        console.warn('Failed to read from localStorage:', e);
    }

    if (storedState === 'open') {
        if (loginForm.classList.contains('in')) {
            try {
                localStorage.setItem(collapseStateKey, 'closed');
            } catch (e) {
                console.warn('Failed to write to localStorage:', e);
            }
        } else {

console.log(storedState);
if (storedState === 'open') {
loginForm.classList.add('in');
} else {
loginForm.classList.remove('in');
}

// Save state on toggle
toggleLogin.addEventListener('click', function () {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: The collapse state is being stored based on the current class rather than the final state after transition.

Consider using Bootstrap's 'shown.bs.collapse' and 'hidden.bs.collapse' events to ensure the correct state is stored after the transition completes.

if (loginForm.classList.contains('in')) {
localStorage.setItem(collapseStateKey, 'closed');
} else {
localStorage.setItem(collapseStateKey, 'open');
}
});
});
Loading