forked from Github-Mirrors/canaille
use flask-themer to allow theme customization
This commit is contained in:
parent
2aac2a0c6a
commit
c0f53c8e6e
39 changed files with 240 additions and 37 deletions
|
@ -11,6 +11,7 @@ It aims to be very light, simple to install and simple to maintain. Its main fea
|
|||
- OpenID Connect support;
|
||||
- No outdated or exotic protocol support;
|
||||
- No additional database required. Everything is stored in your LDAP server;
|
||||
- Customizable, themable;
|
||||
- The code is easy to read and easy to edit, and you should!
|
||||
|
||||
# Screenshots
|
||||
|
|
|
@ -15,8 +15,9 @@ import canaille.account
|
|||
import canaille.groups
|
||||
import canaille.well_known
|
||||
|
||||
from flask import Flask, g, request, render_template, session
|
||||
from flask import Flask, g, request, session
|
||||
from flask_babel import Babel
|
||||
from flask_themer import Themer, render_template, FileSystemThemeLoader
|
||||
|
||||
from .flaskutils import current_user
|
||||
from .ldaputils import LDAPObject
|
||||
|
@ -120,6 +121,18 @@ def setup_app(app):
|
|||
|
||||
babel = Babel(app)
|
||||
|
||||
additional_themes_dir = (
|
||||
os.path.dirname(app.config["THEME"])
|
||||
if app.config.get("THEME") and os.path.exists(app.config["THEME"])
|
||||
else None
|
||||
)
|
||||
themer = Themer(
|
||||
app,
|
||||
loaders=[FileSystemThemeLoader(additional_themes_dir)]
|
||||
if additional_themes_dir
|
||||
else None,
|
||||
)
|
||||
|
||||
@babel.localeselector
|
||||
def get_locale():
|
||||
user = getattr(g, "user", None)
|
||||
|
@ -137,6 +150,11 @@ def setup_app(app):
|
|||
if user is not None:
|
||||
return user.timezone
|
||||
|
||||
@themer.current_theme_loader
|
||||
def get_current_theme():
|
||||
# if config['THEME'] may be a theme name or an absolute path
|
||||
return app.config.get("THEME", "default").split("/")[-1]
|
||||
|
||||
@app.before_request
|
||||
def before_request():
|
||||
setup_ldap_connection(app)
|
||||
|
|
|
@ -7,11 +7,11 @@ from flask import (
|
|||
url_for,
|
||||
current_app,
|
||||
abort,
|
||||
render_template,
|
||||
redirect,
|
||||
session,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
from flask_themer import render_template
|
||||
from werkzeug.datastructures import CombinedMultiDict, FileStorage
|
||||
from .forms import (
|
||||
LoginForm,
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from flask import Blueprint, render_template
|
||||
from flask import Blueprint
|
||||
from flask_themer import render_template
|
||||
from canaille.models import AuthorizationCode
|
||||
from canaille.flaskutils import admin_needed
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import datetime
|
||||
import wtforms
|
||||
from flask import Blueprint, render_template, request, flash, redirect, url_for, abort
|
||||
from flask import Blueprint, request, flash, redirect, url_for, abort
|
||||
from flask_themer import render_template
|
||||
from flask_wtf import FlaskForm
|
||||
from flask_babel import lazy_gettext as _
|
||||
from werkzeug.security import gen_salt
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from flask import Blueprint, render_template, current_app, url_for
|
||||
from flask import Blueprint, current_app, url_for
|
||||
from flask_themer import render_template
|
||||
from flask_babel import gettext as _
|
||||
from canaille.flaskutils import admin_needed
|
||||
from canaille.mails import profile_hash
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from flask import Blueprint, render_template
|
||||
from flask import Blueprint
|
||||
from flask_themer import render_template
|
||||
from canaille.models import Token
|
||||
from canaille.flaskutils import admin_needed
|
||||
|
||||
|
|
|
@ -17,6 +17,11 @@ LOGO = "/static/img/canaille-head.png"
|
|||
# Your favicon. If unset the LOGO will be used.
|
||||
FAVICON = "/static/img/canaille-c.png"
|
||||
|
||||
# The name of a theme in the 'theme' directory, or an absolute path
|
||||
# to a theme. Defaults to 'default'. Theming is done with
|
||||
# https://github.com/tktech/flask-themer
|
||||
# THEME = "default"
|
||||
|
||||
# If unset, language is detected
|
||||
# LANGUAGE = "en"
|
||||
|
||||
|
|
|
@ -8,12 +8,25 @@ from cryptography.hazmat.primitives import serialization as crypto_serialization
|
|||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.backends import default_backend as crypto_default_backend
|
||||
|
||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
class ConfigurationException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def validate(config, validate_remote=False):
|
||||
validate_keypair(config)
|
||||
validate_theme(config)
|
||||
|
||||
if not validate_remote:
|
||||
return
|
||||
|
||||
validate_ldap_configuration(config)
|
||||
validate_smtp_configuration(config)
|
||||
|
||||
|
||||
def validate_keypair(config):
|
||||
if not os.path.exists(config["JWT"]["PUBLIC_KEY"]):
|
||||
raise ConfigurationException(
|
||||
f'Public key does not exist {config["JWT"]["PUBLIC_KEY"]}'
|
||||
|
@ -24,12 +37,6 @@ def validate(config, validate_remote=False):
|
|||
f'Private key does not exist {config["JWT"]["PRIVATE_KEY"]}'
|
||||
)
|
||||
|
||||
if not validate_remote:
|
||||
return
|
||||
|
||||
validate_ldap_configuration(config)
|
||||
validate_smtp_configuration(config)
|
||||
|
||||
|
||||
def validate_ldap_configuration(config):
|
||||
from canaille.models import User, Group
|
||||
|
@ -127,6 +134,16 @@ def validate_smtp_configuration(config):
|
|||
) from exc
|
||||
|
||||
|
||||
def validate_theme(config):
|
||||
if not config.get("THEME"):
|
||||
return
|
||||
|
||||
if not os.path.exists(config["THEME"]) and not os.path.exists(
|
||||
os.path.join(ROOT, "themes", config["THEME"])
|
||||
):
|
||||
raise ConfigurationException(f'Cannot find theme \'{config["THEME"]}\'')
|
||||
|
||||
|
||||
def setup_dev_keypair(config):
|
||||
if os.path.exists(config["JWT"]["PUBLIC_KEY"]) or os.path.exists(
|
||||
config["JWT"]["PRIVATE_KEY"]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from flask import Blueprint, render_template, flash, redirect, url_for
|
||||
from flask import Blueprint, flash, redirect, url_for
|
||||
from flask_themer import render_template
|
||||
from flask_babel import gettext
|
||||
from canaille.models import Consent, Client
|
||||
from canaille.flaskutils import user_needed
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
from flask import (
|
||||
Blueprint,
|
||||
render_template,
|
||||
redirect,
|
||||
url_for,
|
||||
request,
|
||||
|
@ -9,6 +8,7 @@ from flask import (
|
|||
abort,
|
||||
)
|
||||
from flask_babel import gettext as _
|
||||
from flask_themer import render_template
|
||||
|
||||
from .flaskutils import moderator_needed
|
||||
from .forms import GroupForm
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import hashlib
|
||||
from flask import url_for, render_template, current_app
|
||||
from flask import url_for, current_app
|
||||
from flask_babel import gettext as _
|
||||
from flask_themer import render_template
|
||||
from .apputils import logo, send_email
|
||||
|
||||
|
||||
|
|
|
@ -9,11 +9,11 @@ from flask import (
|
|||
session,
|
||||
redirect,
|
||||
abort,
|
||||
render_template,
|
||||
jsonify,
|
||||
flash,
|
||||
)
|
||||
from flask_babel import gettext, lazy_gettext as _
|
||||
from flask_themer import render_template
|
||||
from .models import User, Client, Consent
|
||||
from .oauth2utils import (
|
||||
authorization,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
|
||||
{% block style %}
|
||||
<link href="/static/datatables/jquery.dataTables.min.css" rel="stylesheet">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block script %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
|
||||
{% block style %}
|
||||
<link href="/static/datatables/jquery.dataTables.min.css" rel="stylesheet">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
|
||||
{% block style %}
|
||||
<link href="/static/datatables/jquery.dataTables.min.css" rel="stylesheet">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
|
||||
{% block content %}
|
||||
<div class="ui segment">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
|
||||
{% block style %}
|
||||
<link href="/static/datatables/jquery.dataTables.min.css" rel="stylesheet">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
|
||||
{% block content %}
|
||||
<div class="ui error message">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block script %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
|
||||
{% block content %}
|
||||
<div class="ui segment">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
|
||||
{% block content %}
|
||||
<div class="ui segment">
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block script %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
{% import 'fomanticui.j2' as sui %}
|
||||
|
||||
{% block content %}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
{% extends 'base.html' %}
|
||||
{% extends theme('base.html') %}
|
||||
|
||||
{% block style %}
|
||||
<link href="/static/datatables/jquery.dataTables.min.css" rel="stylesheet">
|
||||
|
|
|
@ -17,6 +17,11 @@ LOGO = "/static/img/canaille-head.png"
|
|||
# Your favicon. If unset the LOGO will be used.
|
||||
FAVICON = "/static/img/canaille-c.png"
|
||||
|
||||
# The name of a theme in the 'theme' directory, or an absolute path
|
||||
# to a theme. Defaults to 'default'. Theming is done with
|
||||
# https://github.com/tktech/flask-themer
|
||||
# THEME = "default"
|
||||
|
||||
# If unset, language is detected
|
||||
# LANGUAGE = "en"
|
||||
|
||||
|
|
|
@ -30,6 +30,7 @@ install_requires =
|
|||
email_validator
|
||||
flask
|
||||
flask-babel
|
||||
flask-themer
|
||||
flask-wtf
|
||||
python-ldap
|
||||
sentry-sdk[flask]
|
||||
|
|
107
tests/fixtures/themes/test/base.html
vendored
Normal file
107
tests/fixtures/themes/test/base.html
vendored
Normal file
|
@ -0,0 +1,107 @@
|
|||
{% import 'flask.j2' as flask %}
|
||||
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
|
||||
<title>{% block title %}{{ website_name|default("Canaille") }} {% trans %}authorization interface{% endtrans %}{% endblock %}</title>
|
||||
|
||||
<link href="/static/fomanticui/semantic.min.css" rel="stylesheet">
|
||||
<link href="/static/fonts/lato.css" rel="stylesheet">
|
||||
<link href="/static/css/base.css" rel="stylesheet">
|
||||
{% if logo_url %}<link rel="icon" href="{{ favicon_url }}">{% endif %}
|
||||
{% block style %}{% endblock %}
|
||||
|
||||
<!--[if lt IE 9]>
|
||||
<script>window.html5 || document.write('<script src="/static/html5shiv/html5shiv.min.js"><\/script>')</script>
|
||||
<script src="/static/js/respond.min.js"></script>
|
||||
<![endif]-->
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
{% block menu %}
|
||||
{% if user and menu %}
|
||||
<nav class="ui stackable labeled icon menu container">
|
||||
{% if logo_url %}
|
||||
<div class="header item">
|
||||
<a href="/" class="logo">
|
||||
<img class="ui image" src="{{ logo_url }}" alt="{{ website_name }}" />
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a class="item {% if menuitem == "profile" %}active{% endif %}"
|
||||
href="{{ url_for('account.profile_edition', username=user.uid[0]) }}">
|
||||
<i class="id card icon"></i>
|
||||
{% trans %}My profile{% endtrans %}
|
||||
</a>
|
||||
<a class="item {% if menuitem == "consents" %}active{% endif %}"
|
||||
href="{{ url_for('consents.consents') }}">
|
||||
<i class="handshake icon"></i>
|
||||
{% trans %}My consents{% endtrans %}
|
||||
</a>
|
||||
{% if user.moderator %}
|
||||
<a class="item {% if menuitem == "users" %}active{% endif %}"
|
||||
href="{{ url_for('account.users') }}">
|
||||
<i class="users icon"></i>
|
||||
{% trans %}Users{% endtrans %}
|
||||
</a>
|
||||
<a class="item {% if menuitem == "groups" %}active{% endif %}"
|
||||
href="{{ url_for('groups.groups') }}">
|
||||
<i class="users cog icon"></i>
|
||||
{% trans %}Groups{% endtrans %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if user.admin %}
|
||||
<div class="ui dropdown item {% if menuitem == "admin" %}active{% endif %}">
|
||||
<i class="settings icon"></i>
|
||||
Admin
|
||||
<div class="menu">
|
||||
<a class="item" href="{{ url_for('admin_clients.index') }}">
|
||||
<i class="plug icon"></i>
|
||||
{% trans %}Clients{% endtrans %}
|
||||
</a>
|
||||
<a class="item" href="{{ url_for('admin_tokens.index') }}">
|
||||
<i class="key icon"></i>
|
||||
{% trans %}Tokens{% endtrans %}
|
||||
</a>
|
||||
<a class="item" href="{{ url_for('admin_authorizations.index') }}">
|
||||
<i class="user secret icon"></i>
|
||||
{% trans %}Codes{% endtrans %}
|
||||
</a>
|
||||
<a class="item" href="">
|
||||
<i class="handshake icon"></i>
|
||||
{% trans %}Consents{% endtrans %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<a class="item" href="{{ url_for('account.logout') }}">
|
||||
<i class="sign out alternate icon"></i>
|
||||
{% trans %}Log out{% endtrans %}
|
||||
</a>
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
<div class="ui container">
|
||||
<div class="content">
|
||||
{{ flask.messages() }}
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<a href="{{ url_for('account.about') }}">{{ _("About canaille") }}</a>
|
||||
</footer>
|
||||
<script src="/static/jquery/jquery.min.js"></script>
|
||||
<script src="/static/fomanticui/semantic.min.js"></script>
|
||||
<script src="/static/js/base.js"></script>
|
||||
{% block script %}{% endblock %}
|
||||
|
||||
TEST_THEME
|
||||
</body>
|
||||
</html>
|
|
@ -1,8 +1,11 @@
|
|||
import ldap
|
||||
import mock
|
||||
import os
|
||||
import pytest
|
||||
from canaille import create_app
|
||||
from canaille.commands import cli
|
||||
from canaille.configuration import validate, ConfigurationException
|
||||
from flask_webtest import TestApp
|
||||
|
||||
|
||||
def test_ldap_connection_no_remote(configuration):
|
||||
|
@ -104,3 +107,43 @@ def test_check_command_fail(testclient):
|
|||
testclient.app.config["LDAP"]["URI"] = "ldap://invalid-ldap.com"
|
||||
runner = testclient.app.test_cli_runner()
|
||||
runner.invoke(cli, ["check"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def themed_testclient(app, configuration):
|
||||
configuration["TESTING"] = True
|
||||
|
||||
root = os.path.dirname(os.path.abspath(__file__))
|
||||
test_theme_path = os.path.join(root, "fixtures", "themes", "test")
|
||||
configuration["THEME"] = test_theme_path
|
||||
|
||||
configuration["AUTHLIB_INSECURE_TRANSPORT"] = "true"
|
||||
app = create_app(configuration)
|
||||
|
||||
return TestApp(app)
|
||||
|
||||
|
||||
def test_theme(testclient, themed_testclient):
|
||||
res = testclient.get("/login")
|
||||
assert "TEST_THEME" not in res
|
||||
|
||||
res = themed_testclient.get("/login")
|
||||
assert "TEST_THEME" in res
|
||||
|
||||
|
||||
def test_invalid_theme(configuration):
|
||||
validate(configuration, validate_remote=False)
|
||||
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"Cannot find theme",
|
||||
):
|
||||
configuration["THEME"] = "invalid"
|
||||
validate(configuration, validate_remote=False)
|
||||
|
||||
with pytest.raises(
|
||||
ConfigurationException,
|
||||
match=r"Cannot find theme",
|
||||
):
|
||||
configuration["THEME"] = "/path/to/invalid"
|
||||
validate(configuration, validate_remote=False)
|
||||
|
|
Loading…
Reference in a new issue