diff --git a/.env.tpl b/.env.tpl index ca0657d5..0d01b60d 100644 --- a/.env.tpl +++ b/.env.tpl @@ -99,8 +99,9 @@ NOPAQUE_ADMIN_EMAIL_ADRESS= # CHOOSE ONE: development, production, testing # NOPAQUE_CONFIG= +# DEFAULT: None # EXAMPLE: contact.nopaque@example.com -NOPAQUE_CONTACT_EMAIL_ADRESS= +# NOPAQUE_CONTACT_EMAIL_ADRESS= # DEFAULT: /mnt/nopaque # NOTE: This must be a network share and it must be available on all Docker Swarm nodes diff --git a/docker-compose.yml b/docker-compose.yml index b6f7a9f0..fe90fada 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,7 +11,7 @@ services: - db - mq env_file: .env - image: nopaque/web + image: nopaque:development restart: unless-stopped volumes: - "${NOPAQUE_DATA_DIR:-/mnt/nopaque}:${NOPAQUE_DATA_DIR:-/mnt/nopaque}" @@ -27,7 +27,7 @@ services: - db - nopaque env_file: .env - image: nopaque/daemon + image: nopaqued:development restart: unless-stopped volumes: - "/var/run/docker.sock:/var/run/docker.sock" diff --git a/web/app/__init__.py b/web/app/__init__.py index 7a5d8b68..a39a51a5 100644 --- a/web/app/__init__.py +++ b/web/app/__init__.py @@ -28,22 +28,23 @@ def create_app(config_name): socketio.init_app( app, message_queue=config[config_name].SOCKETIO_MESSAGE_QUEUE_URI) - from . import events - from .admin import admin as admin_blueprint + with app.app_context(): + from . import events + from .admin import admin as admin_blueprint + from .auth import auth as auth_blueprint + from .corpora import corpora as corpora_blueprint + from .errors import errors as errors_blueprint + from .jobs import jobs as jobs_blueprint + from .main import main as main_blueprint + from .services import services as services_blueprint + from .settings import settings as settings_blueprint app.register_blueprint(admin_blueprint, url_prefix='/admin') - from .auth import auth as auth_blueprint app.register_blueprint(auth_blueprint, url_prefix='/auth') - from .corpora import corpora as corpora_blueprint app.register_blueprint(corpora_blueprint, url_prefix='/corpora') - from .errors import errors as errors_blueprint app.register_blueprint(errors_blueprint) - from .jobs import jobs as jobs_blueprint app.register_blueprint(jobs_blueprint, url_prefix='/jobs') - from .main import main as main_blueprint app.register_blueprint(main_blueprint) - from .profile import profile as profile_blueprint - app.register_blueprint(profile_blueprint, url_prefix='/profile') - from .services import services as services_blueprint app.register_blueprint(services_blueprint, url_prefix='/services') + app.register_blueprint(settings_blueprint, url_prefix='/settings') return app diff --git a/web/app/admin/forms.py b/web/app/admin/forms.py index fd41d016..42706bab 100644 --- a/web/app/admin/forms.py +++ b/web/app/admin/forms.py @@ -1,36 +1,15 @@ -from flask_wtf import FlaskForm -from wtforms import (BooleanField, SelectField, StringField, SubmitField, - ValidationError) -from wtforms.validators import DataRequired, Email, Length, Regexp -from ..models import Role, User +from flask_login import current_user +from wtforms import BooleanField, SelectField +from ..models import Role +from ..settings.forms import EditGeneralSettingsForm -class EditUserForm(FlaskForm): - email = StringField('Email', - validators=[DataRequired(), Length(1, 64), Email()]) - username = StringField('Username', - validators=[DataRequired(), Length(1, 64), - Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, - 'Usernames must have only ' - 'letters, numbers, dots or ' - 'underscores')]) +class EditGeneralSettingsAdminForm(EditGeneralSettingsForm): confirmed = BooleanField('Confirmed') role = SelectField('Role', coerce=int) - name = StringField('Real name', validators=[Length(0, 64)]) - submit = SubmitField('Update Profile') - def __init__(self, user, *args, **kwargs): - super(EditUserForm, self).__init__(*args, **kwargs) + def __init__(self, user=current_user, *args, **kwargs): + super().__init__(*args, user=user, **kwargs) self.role.choices = [(role.id, role.name) for role in Role.query.order_by(Role.name).all()] self.user = user - - def validate_email(self, field): - if field.data != self.user.email and \ - User.query.filter_by(email=field.data).first(): - raise ValidationError('Email already registered.') - - def validate_username(self, field): - if field.data != self.user.username and \ - User.query.filter_by(username=field.data).first(): - raise ValidationError('Username already in use.') diff --git a/web/app/admin/views.py b/web/app/admin/views.py index e8d0d916..d06c856a 100644 --- a/web/app/admin/views.py +++ b/web/app/admin/views.py @@ -1,17 +1,17 @@ from flask import flash, redirect, render_template, url_for -from flask_login import login_required +from flask_login import current_user, login_required from . import admin -from .forms import EditUserForm +from .forms import EditGeneralSettingsAdminForm from .. import db from ..decorators import admin_required from ..models import Role, User -from ..profile import tasks as profile_tasks +from ..settings import tasks as settings_tasks -@admin.route('/') +@admin.route('/users') @login_required @admin_required -def index(): +def users(): users = User.query.all() users = [dict(username=u.username, email=u.email, @@ -19,48 +19,49 @@ def index(): confirmed=u.confirmed, id=u.id) for u in users] - return render_template('admin/index.html.j2', - title='Administration tools', - users=users) + return render_template('admin/users.html.j2', title='Users', users=users) -@admin.route('/user/') +@admin.route('/users/') @login_required @admin_required def user(user_id): user = User.query.get_or_404(user_id) - return render_template('admin/user.html.j2', title='Administration: User', - user=user) + return render_template('admin/user.html.j2', title='User', user=user) -@admin.route('/user//delete') +@admin.route('/users//delete') @login_required @admin_required def delete_user(user_id): - profile_tasks.delete_user(user_id) + settings_tasks.delete_user(user_id) flash('User has been deleted!') - return redirect(url_for('admin.index')) + return redirect(url_for('.users')) -@admin.route('/user//edit', methods=['GET', 'POST']) +@admin.route('/users//edit_general_settings', + methods=['GET', 'POST']) @login_required @admin_required -def edit_user(user_id): +def edit_general_settings(user_id): user = User.query.get_or_404(user_id) - edit_user_form = EditUserForm(user=user) - if edit_user_form.validate_on_submit(): - user.email = edit_user_form.email.data - user.username = edit_user_form.username.data - user.confirmed = edit_user_form.confirmed.data - user.role = Role.query.get(edit_user_form.role.data) + form = EditGeneralSettingsAdminForm(user=user) + if form.validate_on_submit(): + user.setting_dark_mode = form.dark_mode.data + user.email = form.email.data + user.username = form.username.data + user.confirmed = form.confirmed.data + user.role = Role.query.get(form.role.data) db.session.add(user) db.session.commit() flash('The profile has been updated.') - return redirect(url_for('admin.edit_user', user_id=user.id)) - edit_user_form.email.data = user.email - edit_user_form.username.data = user.username - edit_user_form.confirmed.data = user.confirmed - edit_user_form.role.data = user.role_id - return render_template('admin/edit_user.html.j2', - edit_user_form=edit_user_form, - title='Administration: Edit user', user=user) + return redirect(url_for('admin.edit_general_settings', user_id=user.id)) + form.confirmed.data = user.confirmed + form.dark_mode.data = user.setting_dark_mode + form.email.data = user.email + form.role.data = user.role_id + form.username.data = user.username + return render_template('admin/edit_general_settings.html.j2', + form=form, + title='General settings', + user=user) diff --git a/web/app/auth/forms.py b/web/app/auth/forms.py index e3f5c276..3344096b 100644 --- a/web/app/auth/forms.py +++ b/web/app/auth/forms.py @@ -1,3 +1,4 @@ +from flask import current_app from ..models import User from flask_wtf import FlaskForm from wtforms import (BooleanField, PasswordField, StringField, SubmitField, @@ -17,9 +18,9 @@ class RegistrationForm(FlaskForm): username = StringField( 'Username', validators=[DataRequired(), Length(1, 64), - Regexp('^[A-Za-z][A-Za-z0-9_.]*$', 0, - 'Usernames must have only letters, numbers, dots ' - 'or underscores')] + Regexp(current_app.config['ALLOWED_USERNAME_REGEX'], + message='Usernames must have only letters, numbers,' + ' dots or underscores')] ) password = PasswordField( 'Password', diff --git a/web/app/corpora/forms.py b/web/app/corpora/forms.py index f25c6b64..6eafbf64 100644 --- a/web/app/corpora/forms.py +++ b/web/app/corpora/forms.py @@ -9,21 +9,22 @@ class AddCorpusFileForm(FlaskForm): ''' Form to add a .vrt corpus file to the current corpus. ''' - address = StringField('Adress', validators=[Length(0, 255)]) + # Required fields author = StringField('Author', validators=[DataRequired(), Length(1, 255)]) + file = FileField('File', validators=[DataRequired()]) + title = StringField('Title', validators=[DataRequired(), Length(1, 255)]) + # Optional fields + address = StringField('Adress', validators=[Length(0, 255)]) booktitle = StringField('Booktitle', validators=[Length(0, 255)]) chapter = StringField('Chapter', validators=[Length(0, 255)]) editor = StringField('Editor', validators=[Length(0, 255)]) - file = FileField('File', validators=[DataRequired()]) institution = StringField('Institution', validators=[Length(0, 255)]) journal = StringField('Journal', validators=[Length(0, 255)]) pages = StringField('Pages', validators=[Length(0, 255)]) publisher = StringField('Publisher', validators=[Length(0, 255)]) - publishing_year = IntegerField('Publishing year', - validators=[DataRequired()]) + publishing_year = IntegerField('Publishing year') school = StringField('School', validators=[Length(0, 255)]) submit = SubmitField() - title = StringField('Title', validators=[DataRequired(), Length(1, 255)]) def __init__(self, corpus, *args, **kwargs): super(AddCorpusFileForm, self).__init__(*args, **kwargs) @@ -43,8 +44,11 @@ class EditCorpusFileForm(FlaskForm): ''' Form to edit meta data of one corpus file. ''' - address = StringField('Adress', validators=[Length(0, 255)]) + # Required fields author = StringField('Author', validators=[DataRequired(), Length(1, 255)]) + title = StringField('Title', validators=[DataRequired(), Length(1, 255)]) + # Optional fields + address = StringField('Adress', validators=[Length(0, 255)]) booktitle = StringField('Booktitle', validators=[Length(0, 255)]) chapter = StringField('Chapter', validators=[Length(0, 255)]) editor = StringField('Editor', validators=[Length(0, 255)]) @@ -52,11 +56,9 @@ class EditCorpusFileForm(FlaskForm): journal = StringField('Journal', validators=[Length(0, 255)]) pages = StringField('Pages', validators=[Length(0, 255)]) publisher = StringField('Publisher', validators=[Length(0, 255)]) - publishing_year = IntegerField('Publishing year', - validators=[DataRequired()]) + publishing_year = IntegerField('Publishing year') school = StringField('School', validators=[Length(0, 255)]) submit = SubmitField() - title = StringField('Title', validators=[DataRequired(), Length(1, 255)]) class AddCorpusForm(FlaskForm): diff --git a/web/app/corpora/views.py b/web/app/corpora/views.py index 1e98b679..0bdc9413 100644 --- a/web/app/corpora/views.py +++ b/web/app/corpora/views.py @@ -51,8 +51,7 @@ def corpus(corpus_id): title=corpus_file.title, publishing_year=corpus_file.publishing_year, corpus_id=corpus.id, - id=corpus_file.id - ) + id=corpus_file.id) for corpus_file in corpus.files] return render_template('corpora/corpus.html.j2', corpus=corpus, @@ -78,6 +77,7 @@ def analyse_corpus(corpus_id): prefix='inspect-display-options-form') return render_template( 'corpora/analyse_corpus.html.j2', + corpus=corpus, corpus_id=corpus_id, display_options_form=display_options_form, query_form=query_form, diff --git a/web/app/profile/forms.py b/web/app/profile/forms.py deleted file mode 100644 index 14517e29..00000000 --- a/web/app/profile/forms.py +++ /dev/null @@ -1,52 +0,0 @@ -from flask_wtf import FlaskForm -from wtforms import (BooleanField, PasswordField, SelectField, StringField, - SubmitField, ValidationError) -from wtforms.validators import DataRequired, Email, EqualTo - - -class EditEmailForm(FlaskForm): - email = StringField('New email', validators=[Email(), DataRequired()]) - save_email = SubmitField('Save email') - - -class EditGeneralSettingsForm(FlaskForm): - dark_mode = BooleanField('Dark mode') - job_status_mail_notifications = SelectField( - 'Job status mail notifications', - choices=[('', 'Choose your option'), - ('all', 'Notify on all status changes'), - ('end', 'Notify only when a job ended'), - ('none', 'No status update notifications')], - validators=[DataRequired()]) - job_status_site_notifications = SelectField( - 'Job status site notifications', - choices=[('', 'Choose your option'), - ('all', 'Notify on all status changes'), - ('end', 'Notify only when a job ended'), - ('none', 'No status update notifications')], - validators=[DataRequired()]) - save_settings = SubmitField('Save settings') - - -class EditPasswordForm(FlaskForm): - current_password = PasswordField('Current password', - validators=[DataRequired()]) - password = PasswordField( - 'New password', - validators=[DataRequired(), EqualTo('password_confirmation', - message='Passwords must match.')] - ) - password_confirmation = PasswordField( - 'Password confirmation', - validators=[DataRequired(), - EqualTo('password', message='Passwords must match.')] - ) - save_password = SubmitField('Save password') - - def __init__(self, user, *args, **kwargs): - super(EditPasswordForm, self).__init__(*args, **kwargs) - self.user = user - - def validate_current_password(self, field): - if not self.user.verify_password(field.data): - raise ValidationError('Invalid password.') diff --git a/web/app/profile/views.py b/web/app/profile/views.py deleted file mode 100644 index f156715f..00000000 --- a/web/app/profile/views.py +++ /dev/null @@ -1,69 +0,0 @@ -from flask import flash, redirect, render_template, url_for -from flask_login import current_user, login_required, logout_user -from . import profile -from . import tasks -from .forms import EditEmailForm, EditGeneralSettingsForm, EditPasswordForm -from .. import db - - -@profile.route('/settings', methods=['GET', 'POST']) -@login_required -def settings(): - edit_email_form = EditEmailForm(prefix='edit-email-form') - edit_general_settings_form = EditGeneralSettingsForm( - prefix='edit-general-settings-form') - edit_password_form = EditPasswordForm(prefix='edit-password-form', - user=current_user) - # Check if edit_email_form is submitted and valid - if (edit_email_form.save_email.data - and edit_email_form.validate_on_submit()): - db.session.add(current_user) - db.session.commit() - flash('Your email address has been updated.') - return redirect(url_for('profile.settings')) - # Check if edit_settings_form is submitted and valid - if (edit_general_settings_form.save_settings.data - and edit_general_settings_form.validate_on_submit()): - current_user.setting_dark_mode = \ - edit_general_settings_form.dark_mode.data - current_user.setting_job_status_mail_notifications = \ - edit_general_settings_form.job_status_mail_notifications.data - current_user.setting_job_status_site_notifications = \ - edit_general_settings_form.job_status_site_notifications.data - db.session.add(current_user) - db.session.commit() - flash('Your settings have been updated.') - return redirect(url_for('profile.settings')) - # Check if edit_password_form is submitted and valid - if (edit_password_form.save_password.data - and edit_password_form.validate_on_submit()): - current_user.password = edit_password_form.password.data - db.session.add(current_user) - db.session.commit() - flash('Your password has been updated.') - return redirect(url_for('profile.settings')) - # If no form is submitted or valid, fill out fields with current values - edit_email_form.email.data = current_user.email - edit_general_settings_form.dark_mode.data = current_user.setting_dark_mode - edit_general_settings_form.job_status_site_notifications.data = \ - current_user.setting_job_status_site_notifications - edit_general_settings_form.job_status_mail_notifications.data = \ - current_user.setting_job_status_mail_notifications - return render_template( - 'profile/settings.html.j2', - edit_email_form=edit_email_form, - edit_password_form=edit_password_form, - edit_general_settings_form=edit_general_settings_form, - title='Settings') - - -@profile.route('/delete', methods=['GET', 'POST']) -@login_required -def delete(): - """ - View to delete yourslef and all associated data. - """ - tasks.delete_user(current_user.id) - logout_user() - flash('Your account has been deleted!') - return redirect(url_for('main.index')) diff --git a/web/app/profile/__init__.py b/web/app/settings/__init__.py similarity index 57% rename from web/app/profile/__init__.py rename to web/app/settings/__init__.py index 85b9f61c..2329d880 100644 --- a/web/app/profile/__init__.py +++ b/web/app/settings/__init__.py @@ -1,5 +1,5 @@ from flask import Blueprint -profile = Blueprint('profile', __name__) +settings = Blueprint('settings', __name__) from . import views # noqa diff --git a/web/app/settings/forms.py b/web/app/settings/forms.py new file mode 100644 index 00000000..6f7abeef --- /dev/null +++ b/web/app/settings/forms.py @@ -0,0 +1,78 @@ +from flask import current_app +from flask_login import current_user +from flask_wtf import FlaskForm +from wtforms import (BooleanField, PasswordField, SelectField, StringField, + SubmitField, ValidationError) +from wtforms.validators import DataRequired, Email, EqualTo, Length, Regexp +from ..models import User + + +class ChangePasswordForm(FlaskForm): + password = PasswordField('Old password', validators=[DataRequired()]) + new_password = PasswordField( + 'New password', + validators=[DataRequired(), EqualTo('password_confirmation', + message='Passwords must match.')] + ) + new_password2 = PasswordField( + 'Confirm new password', validators=[DataRequired()]) + submit = SubmitField('Change password') + + def __init__(self, user=current_user, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + + def validate_current_password(self, field): + if not self.user.verify_password(field.data): + raise ValidationError('Invalid password.') + + +class EditGeneralSettingsForm(FlaskForm): + dark_mode = BooleanField('Dark mode') + email = StringField('E-Mail', + validators=[DataRequired(), Length(1, 254), Email()]) + username = StringField( + 'Benutzername', + validators=[DataRequired(), + Length(1, 64), + Regexp(current_app.config['ALLOWED_USERNAME_REGEX'], + message='Usernames must have only letters, numbers,' + ' dots or underscores')] + ) + submit = SubmitField('Submit') + + def __init__(self, user=current_user, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + + def validate_email(self, field): + if (field.data != self.user.email + and User.query.filter_by(email=field.data).first()): + raise ValidationError('Email already registered.') + + def validate_username(self, field): + if (field.data != self.user.username + and User.query.filter_by(username=field.data).first()): + raise ValidationError('Username already in use.') + + +class EditNotificationSettingsForm(FlaskForm): + job_status_mail_notifications = SelectField( + 'Job status mail notifications', + choices=[('', 'Choose your option'), + ('all', 'Notify on all status changes'), + ('end', 'Notify only when a job ended'), + ('none', 'No status update notifications')], + validators=[DataRequired()]) + job_status_site_notifications = SelectField( + 'Job status site notifications', + choices=[('', 'Choose your option'), + ('all', 'Notify on all status changes'), + ('end', 'Notify only when a job ended'), + ('none', 'No status update notifications')], + validators=[DataRequired()]) + submit = SubmitField('Save settings') + + def __init__(self, user=current_user, *args, **kwargs): + super().__init__(*args, **kwargs) + self.user = user diff --git a/web/app/profile/tasks.py b/web/app/settings/tasks.py similarity index 100% rename from web/app/profile/tasks.py rename to web/app/settings/tasks.py diff --git a/web/app/settings/views.py b/web/app/settings/views.py new file mode 100644 index 00000000..1bd4a07f --- /dev/null +++ b/web/app/settings/views.py @@ -0,0 +1,80 @@ +from flask import current_app, flash, redirect, render_template, url_for +from flask_login import current_user, login_required, logout_user +from . import settings, tasks +from .forms import (ChangePasswordForm, EditGeneralSettingsForm, + EditNotificationSettingsForm) +from .. import db +from ..decorators import admin_required +from ..models import Role, User +import os +import uuid + + +@settings.route('/') +@login_required +def index(): + return redirect(url_for('.edit_general_settings')) + + +@settings.route('/change_password', methods=['GET', 'POST']) +@login_required +def change_password(): + form = ChangePasswordForm() + if form.validate_on_submit(): + current_user.password = form.new_password.data + db.session.commit() + flash('Your password has been updated.') + return redirect(url_for('.change_password')) + return render_template('settings/change_password.html.j2', + form=form, + title='Change password') + + +@settings.route('/edit_general_settings', methods=['GET', 'POST']) +@login_required +def edit_general_settings(): + form = EditGeneralSettingsForm() + if form.validate_on_submit(): + current_user.email = form.email.data + current_user.setting_dark_mode = form.dark_mode.data + current_user.username = form.username.data + db.session.commit() + flash('Your changes have been saved.') + form.dark_mode.data = current_user.setting_dark_mode + form.email.data = current_user.email + form.username.data = current_user.username + return render_template('settings/edit_general_settings.html.j2', + form=form, + title='General settings') + + +@settings.route('/edit_notification_settings', methods=['GET', 'POST']) +@login_required +def edit_notification_settings(): + form = EditNotificationSettingsForm() + if form.validate_on_submit(): + current_user.setting_job_status_mail_notifications = \ + form.job_status_mail_notifications.data + current_user.setting_job_status_site_notifications = \ + form.job_status_site_notifications.data + db.session.commit() + flash('Your changes have been saved.') + form.job_status_mail_notifications.data = \ + current_user.setting_job_status_mail_notifications + form.job_status_site_notifications.data = \ + current_user.setting_job_status_site_notifications + return render_template('settings/edit_notification_settings.html.j2', + form=form, + title='Notification settings') + + +@settings.route('/delete') +@login_required +def delete(): + """ + View to delete current_user and all associated data. + """ + tasks.delete_user(current_user.id) + logout_user() + flash('Your account has been deleted!') + return redirect(url_for('main.index')) diff --git a/web/app/static/css/material_design_icons.min.css b/web/app/static/css/material_design_icons.min.css deleted file mode 100644 index cc005801..00000000 --- a/web/app/static/css/material_design_icons.min.css +++ /dev/null @@ -1 +0,0 @@ -@font-face{font-family:'Material Icons';font-style:normal;font-weight:400;src:url(../fonts/material_design_icons/MaterialIcons-Regular.eot);src:local('Material Icons'),local('MaterialIcons-Regular'),url(../fonts/material_design_icons/MaterialIcons-Regular.ttf) format('truetype'),url(../fonts/material_design_icons/MaterialIconsOutlined-Regular.otf) format('opentype'),url(../fonts/material_design_icons/MaterialIconsRound-Regular.otf) format('opentype'),url(../fonts/material_design_icons/MaterialIconsSharp-Regular.otf) format('opentype'),url(../fonts/material_design_icons/MaterialIconsTwoTone-Regular.otf) format('opentype')}.material-icons{font-family:'Material Icons';font-weight:400;font-style:normal;font-size:24px;display:inline-block;line-height:1;text-transform:none;letter-spacing:normal;word-wrap:normal;white-space:nowrap;direction:ltr;-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;-moz-osx-font-smoothing:grayscale;font-feature-settings:'liga'} diff --git a/web/app/static/css/materialize.sidenav-fixed.css b/web/app/static/css/materialize.sidenav-fixed.css new file mode 100644 index 00000000..7cf789da --- /dev/null +++ b/web/app/static/css/materialize.sidenav-fixed.css @@ -0,0 +1,12 @@ +/* + * The sidenav-fixed class is used which causes the sidenav to be fixed and open + * on large screens and hides to the regular functionality on smaller screens. + * In order to prevent the sidenav to overlap the content, the content (in our + * case header, main and footer) gets an offset equal to the width of the + * sidenav. + */ +@media only screen and (min-width : 993px) { + header, main, footer {padding-left: 300px;} + .modal:not(.bottom-sheet) {left: 300px;} + .navbar-fixed > nav {width: calc(100% - 300px)} +} diff --git a/web/app/static/css/materialize.sticky-footer.css b/web/app/static/css/materialize.sticky-footer.css new file mode 100644 index 00000000..49b8f5b8 --- /dev/null +++ b/web/app/static/css/materialize.sticky-footer.css @@ -0,0 +1,13 @@ +/* + * Force the footer to always stay on the bottom of the page regardless of how + * little content is on the page. + */ +body { + display: flex; + min-height: 100vh; + flex-direction: column; +} + +main { + flex: 1 0 auto; +} diff --git a/web/app/static/css/nopaque.css b/web/app/static/css/nopaque.css index d61138bc..821f8b03 100644 --- a/web/app/static/css/nopaque.css +++ b/web/app/static/css/nopaque.css @@ -1,19 +1,7 @@ -/* - * ### Start sticky footer ### - * Force the footer to always stay on the bottom of the page regardless of how - * little content is on the page. -*/ -body { - display: flex; - min-height: 100vh; - flex-direction: column; +.tab .material-icons { + line-height: inherit; } -main { - flex: 1 0 auto; -} -/* ### End sticky footer ### */ - /* add custom bold class */ .bold { font-weight: bold; diff --git a/web/app/static/js/nopaque.js b/web/app/static/js/nopaque.js index 35ceac83..b006ec25 100644 --- a/web/app/static/js/nopaque.js +++ b/web/app/static/js/nopaque.js @@ -4,12 +4,8 @@ */ var nopaque = {}; -// nopaque ressources -nopaque.socket = undefined; - // User data nopaque.user = {}; -nopaque.user.isAuthenticated = undefined; nopaque.user.settings = {}; nopaque.user.settings.darkMode = undefined; nopaque.corporaSubscribers = []; @@ -25,81 +21,76 @@ nopaque.foreignCorporaSubscribers = []; nopaque.foreignJobsSubscribers = []; nopaque.foreignQueryResultsSubscribers = []; -nopaque.flashedMessages = undefined; - // nopaque functions -nopaque.socket = {}; -nopaque.socket.init = function() { - nopaque.socket = io({transports: ['websocket']}); - // Add event handlers - nopaque.socket.on("user_data_stream_init", function(msg) { - nopaque.user = JSON.parse(msg); - for (let subscriber of nopaque.corporaSubscribers) { - subscriber._init(nopaque.user.corpora); - } - for (let subscriber of nopaque.jobsSubscribers) { - subscriber._init(nopaque.user.jobs); - } - for (let subscriber of nopaque.queryResultsSubscribers) { - subscriber._init(nopaque.user.query_results); - } - }); +nopaque.socket = io({transports: ['websocket']}); +// Add event handlers +nopaque.socket.on("user_data_stream_init", function(msg) { + nopaque.user = JSON.parse(msg); + for (let subscriber of nopaque.corporaSubscribers) { + subscriber._init(nopaque.user.corpora); + } + for (let subscriber of nopaque.jobsSubscribers) { + subscriber._init(nopaque.user.jobs); + } + for (let subscriber of nopaque.queryResultsSubscribers) { + subscriber._init(nopaque.user.query_results); + } +}); - nopaque.socket.on("user_data_stream_update", function(msg) { - var patch; +nopaque.socket.on("user_data_stream_update", function(msg) { + var patch; - patch = JSON.parse(msg); - nopaque.user = jsonpatch.apply_patch(nopaque.user, patch); - corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora")); - jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs")); - query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results")); - for (let subscriber of nopaque.corporaSubscribers) { - subscriber._update(corpora_patch); - } - for (let subscriber of nopaque.jobsSubscribers) { - subscriber._update(jobs_patch); - } - for (let subscriber of nopaque.queryResultsSubscribers) { - subscriber._update(query_results_patch); - } - if (["all", "end"].includes(nopaque.user.settings.job_status_site_notifications)) { - for (operation of jobs_patch) { - /* "/jobs/{jobId}/..." -> ["{jobId}", ...] */ - pathArray = operation.path.split("/").slice(2); - if (operation.op === "replace" && pathArray[1] === "status") { - if (nopaque.user.settings.job_status_site_notifications === "end" && !["complete", "failed"].includes(operation.value)) {continue;} - nopaque.flash(`[${nopaque.user.jobs[pathArray[0]].title}] New status: ${operation.value}`, "job"); - } + patch = JSON.parse(msg); + nopaque.user = jsonpatch.apply_patch(nopaque.user, patch); + corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora")); + jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs")); + query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results")); + for (let subscriber of nopaque.corporaSubscribers) { + subscriber._update(corpora_patch); + } + for (let subscriber of nopaque.jobsSubscribers) { + subscriber._update(jobs_patch); + } + for (let subscriber of nopaque.queryResultsSubscribers) { + subscriber._update(query_results_patch); + } + if (["all", "end"].includes(nopaque.user.settings.job_status_site_notifications)) { + for (operation of jobs_patch) { + /* "/jobs/{jobId}/..." -> ["{jobId}", ...] */ + pathArray = operation.path.split("/").slice(2); + if (operation.op === "replace" && pathArray[1] === "status") { + if (nopaque.user.settings.job_status_site_notifications === "end" && !["complete", "failed"].includes(operation.value)) {continue;} + nopaque.flash(`[${nopaque.user.jobs[pathArray[0]].title}] New status: ${operation.value}`, "job"); } } - }); + } +}); - nopaque.socket.on("foreign_user_data_stream_init", function(msg) { - nopaque.foreignUser = JSON.parse(msg); - for (let subscriber of nopaque.foreignCorporaSubscribers) { - subscriber._init(nopaque.foreignUser.corpora); - } - for (let subscriber of nopaque.foreignJobsSubscribers) { - subscriber._init(nopaque.foreignUser.jobs); - } - for (let subscriber of nopaque.foreignQueryResultsSubscribers) { - subscriber._init(nopaque.foreignUser.query_results); - } - }); +nopaque.socket.on("foreign_user_data_stream_init", function(msg) { + nopaque.foreignUser = JSON.parse(msg); + for (let subscriber of nopaque.foreignCorporaSubscribers) { + subscriber._init(nopaque.foreignUser.corpora); + } + for (let subscriber of nopaque.foreignJobsSubscribers) { + subscriber._init(nopaque.foreignUser.jobs); + } + for (let subscriber of nopaque.foreignQueryResultsSubscribers) { + subscriber._init(nopaque.foreignUser.query_results); + } +}); - nopaque.socket.on("foreign_user_data_stream_update", function(msg) { - var patch; +nopaque.socket.on("foreign_user_data_stream_update", function(msg) { + var patch; - patch = JSON.parse(msg); - nopaque.foreignUser = jsonpatch.apply_patch(nopaque.foreignUser, patch); - corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora")); - jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs")); - query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results")); - for (let subscriber of nopaque.foreignCorporaSubscribers) {subscriber._update(corpora_patch);} - for (let subscriber of nopaque.foreignJobsSubscribers) {subscriber._update(jobs_patch);} - for (let subscriber of nopaque.foreignQueryResultsSubscribers) {subscriber._update(query_results_patch);} - }); -} + patch = JSON.parse(msg); + nopaque.foreignUser = jsonpatch.apply_patch(nopaque.foreignUser, patch); + corpora_patch = patch.filter(operation => operation.path.startsWith("/corpora")); + jobs_patch = patch.filter(operation => operation.path.startsWith("/jobs")); + query_results_patch = patch.filter(operation => operation.path.startsWith("/query_results")); + for (let subscriber of nopaque.foreignCorporaSubscribers) {subscriber._update(corpora_patch);} + for (let subscriber of nopaque.foreignJobsSubscribers) {subscriber._update(jobs_patch);} + for (let subscriber of nopaque.foreignQueryResultsSubscribers) {subscriber._update(query_results_patch);} +}); nopaque.Forms = {}; nopaque.Forms.init = function() { @@ -173,32 +164,10 @@ nopaque.Forms.init = function() { } } -nopaque.Navigation = {}; -nopaque.Navigation.init = function() { - /* ### Initialize sidenav-main ### */ - for (let entry of document.querySelectorAll("#sidenav-main a")) { - if (entry.href === window.location.href) { - entry.parentNode.classList.add("active"); - } - } -} - -nopaque.flash = function() { - var classes, toast, toastActionElement; - - switch (arguments.length) { - case 1: - category = "message"; - message = arguments[0]; - break; - case 2: - message = arguments[0]; - category = arguments[1]; - break; - default: - console.error("Usage: nopaque.flash(message) or nopaque.flash(message, category)") - } +nopaque.flash = function(message, category) { + let toast; + let toastActionElement; switch (category) { case "corpus": @@ -219,27 +188,5 @@ nopaque.flash = function() { close `}); toastActionElement = toast.el.querySelector('.toast-action[data-action="close"]'); - if (toastActionElement) { - toastActionElement.addEventListener('click', function() { - toast.dismiss(); - }); - } + toastActionElement.addEventListener('click', () => {toast.dismiss();}); } - - -document.addEventListener('DOMContentLoaded', () => { - // Disable all option elements with no value - for (let optionElement of document.querySelectorAll('option[value=""]')) { - optionElement.disabled = true; - } - M.AutoInit(); - M.CharacterCounter.init(document.querySelectorAll('input[data-length][type="text"]')); - M.Dropdown.init(document.querySelectorAll('#nav-notifications, #nav-account'), - {alignment: 'right', constrainWidth: false, coverTrigger: false}); - nopaque.Forms.init(); - nopaque.Navigation.init(); - while (nopaque.flashedMessages.length) { - flashedMessage = nopaque.flashedMessages.shift(); - nopaque.flash(flashedMessage[1], flashedMessage[0]); - } -}); diff --git a/web/app/static/js/nopaque.lists.js b/web/app/static/js/nopaque.lists.js index d91186dc..28ae8679 100644 --- a/web/app/static/js/nopaque.lists.js +++ b/web/app/static/js/nopaque.lists.js @@ -85,7 +85,6 @@ RessourceList.dataMappers = { author: corpus_file.author, filename: corpus_file.filename, link: `${corpus_file.corpus_id}/files/${corpus_file.id}`, - publishing_year: corpus_file.publishing_year, title: corpus_file.title, title1: corpus_file.title, "delete-link": `/corpora/${corpus_file.corpus_id}/files/${corpus_file.id}/delete`, @@ -131,11 +130,11 @@ RessourceList.dataMappers = { confirmed: user.confirmed, email: user.email, id: user.id, - link: `user/${user.id}`, + link: `users/${user.id}`, role_id: user.role_id, username: user.username, username2: user.username, - "delete-link": `/admin/user/${user.id}/delete`, + "delete-link": `/admin/users/${user.id}/delete`, "delete-modal": `delete-user-${user.id}-modal`, "delete-modal-trigger": `delete-user-${user.id}-modal`, }), @@ -239,7 +238,6 @@ RessourceList.options = { -
@@ -267,7 +265,6 @@ RessourceList.options = { valueNames: [ "author", "filename", - "publishing_year", "title", "title1", {name: "delete-link", attr: "href"}, diff --git a/web/app/templates/_colors.html.j2 b/web/app/templates/_colors.html.j2 new file mode 100644 index 00000000..be667210 --- /dev/null +++ b/web/app/templates/_colors.html.j2 @@ -0,0 +1,15 @@ +{% set colors = {'primary': '#00426f', + 'secondary': '#1A5C89', + 'footer': '#b1b3b4', + 'corpus_analysis': '#aa9cc9', + 'corpus_analysis_darken': '#6b3f89', + 'corpus_analysis_lighten': '#ebe8f6', + 'file_setup': '#d5dc95', + 'file_setup_darken': '#a1b300', + 'file_setup_lighten': '#f2f3e1', + 'nlp': '#98acd2', + 'nlp_darken': '#0064a3', + 'nlp_lighten': '#e5e8f5', + 'ocr': '#a9d8c8', + 'ocr_darken': '#00a58b', + 'ocr_lighten': '#e7f4f1'} %} diff --git a/web/app/templates/admin/edit_general_settings.html.j2 b/web/app/templates/admin/edit_general_settings.html.j2 new file mode 100644 index 00000000..929a867c --- /dev/null +++ b/web/app/templates/admin/edit_general_settings.html.j2 @@ -0,0 +1,70 @@ +{% extends "nopaque.html.j2" %} +{% import 'materialize/wtf.html.j2' as wtf %} + +{% block page_content %} +
+
+
+

Edit user

+
+ +
+ +
+
+
+
+ {{ form.hidden_tag() }} + {{ wtf.render_field(form.username, data_length='64', material_icon='account_circle') }} + {{ wtf.render_field(form.email, class_='validate', material_icon='email', type='email') }} + {{ wtf.render_field(form.role, material_icon='swap_vert') }} +
+

 

+
+

brightness_3

+
+
+

{{ form.dark_mode.label.text }}

+

Enable dark mode to ease your eyes.

+
+
+
+ +
+
+

 

+
+

 

+
+

check

+
+
+

{{ form.confirmed.label.text }}

+

Change confirmation status manually.

+
+
+
+ +
+
+
+
+
+ {{ wtf.render_field(form.submit, material_icon='send') }} +
+
+
+
+
+
+{% endblock %} diff --git a/web/app/templates/admin/edit_user.html.j2 b/web/app/templates/admin/edit_user.html.j2 deleted file mode 100644 index 3a48adf8..00000000 --- a/web/app/templates/admin/edit_user.html.j2 +++ /dev/null @@ -1,27 +0,0 @@ -{% extends "nopaque.html.j2" %} - -{% block page_content %} -
-

{{ user.username }}

-

Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren,

- arrow_backBack to user administration -
- -
-
-
-
- {{ edit_user_form.hidden_tag() }} - {{ M.render_field(edit_user_form.username, data_length='64', material_icon='account_circle') }} - {{ M.render_field(edit_user_form.email, class_='validate', material_icon='email', type='email') }} - {{ M.render_field(edit_user_form.role, material_icon='swap_vert') }} - {{ M.render_field(edit_user_form.confirmed, material_icon='check') }} -
-
- {{ M.render_field(edit_user_form.submit, material_icon='send') }} -
-
-
-
- -{% endblock %} diff --git a/web/app/templates/admin/index.html.j2 b/web/app/templates/admin/index.html.j2 deleted file mode 100644 index 9fca5b58..00000000 --- a/web/app/templates/admin/index.html.j2 +++ /dev/null @@ -1,42 +0,0 @@ -{% extends "nopaque.html.j2" %} - -{% set full_width = True %} - -{% block page_content %} -
-
-
- User list -
- search - - -
-
    - - - - - - - - - - - - - -
    UsernameEmailRoleConfirmed StatusId{# Actions #}
    -
      -
      -
      -
      - - -{% endblock %} diff --git a/web/app/templates/admin/user.html.j2 b/web/app/templates/admin/user.html.j2 index c1e53a18..570b6dd3 100644 --- a/web/app/templates/admin/user.html.j2 +++ b/web/app/templates/admin/user.html.j2 @@ -1,34 +1,40 @@ {% extends "nopaque.html.j2" %} {% block page_content %} -
      -

      {{ user.username }}

      -

      Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren,

      - arrow_backBack to admin board -
      +
      +
      +
      +

      {{ title }}

      +
      -
      -
      -
      - User information -
        -
      • Username: {{ user.username }}
      • -
      • Email: {{ user.email }}
      • -
      • ID: {{ user.id }}
      • -
      • Member since: {{ user.member_since.strftime('%m/%d/%Y, %H:%M:%S %p') }}
      • -
      • Confirmed status: {{ user.confirmed }}
      • -
      • Last seen: {{ user.last_seen.strftime('%m/%d/%Y, %H:%M:%S %p') }}
      • -
      • Role ID: {{ user.role_id }}
      • -
      • Permissions as Int: {{ user.role.permissions }}
      • -
      • Role name: {{ user.role.name }}
      • -
      +
      +

      {{ user.username }}

      +

      Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren,

      + arrow_backBack to Users
      -
      - editEdit - deleteDelete + +
      +
      +
      + User information +
        +
      • Username: {{ user.username }}
      • +
      • Email: {{ user.email }}
      • +
      • ID: {{ user.id }}
      • +
      • Member since: {{ user.member_since.strftime('%m/%d/%Y, %H:%M:%S %p') }}
      • +
      • Confirmed status: {{ user.confirmed }}
      • +
      • Last seen: {{ user.last_seen.strftime('%m/%d/%Y, %H:%M:%S %p') }}
      • +
      • Role ID: {{ user.role_id }}
      • +
      • Permissions as Int: {{ user.role.permissions }}
      • +
      • Role name: {{ user.role.name }}
      • +
      +
      + +
      -
      -

      Corpora

      @@ -92,21 +98,23 @@ - - {% endblock %} + + +{% block scripts %} +{{ super() }} + +{% endblock scripts %} diff --git a/web/app/templates/admin/users.html.j2 b/web/app/templates/admin/users.html.j2 new file mode 100644 index 00000000..539ce0f1 --- /dev/null +++ b/web/app/templates/admin/users.html.j2 @@ -0,0 +1,48 @@ +{% extends "nopaque.html.j2" %} + +{% block page_content %} +
      +
      +
      +

      {{ title }}

      +
      + +
      +
      +
      +
      + search + + +
      +
        + + + + + + + + + + + + + +
        UsernameEmailRoleConfirmed StatusId{# Actions #}
        +
          +
          +
          +
          +
          +
          +{% endblock %} + +{% block scripts %} +{{ super() }} + +{% endblock scripts %} diff --git a/web/app/templates/auth/login.html.j2 b/web/app/templates/auth/login.html.j2 index 69ccbe57..657da25f 100644 --- a/web/app/templates/auth/login.html.j2 +++ b/web/app/templates/auth/login.html.j2 @@ -1,8 +1,8 @@ {% extends "nopaque.html.j2" %} +{% import 'materialize/wtf.html.j2' as wtf %} -{% set headline = ' ' %} - -{% block page_content %} +{% block styles %} +{{ super() }} +{% endblock styles %} -
          -
          -
          -

          Log in

          -

          Want to boost your research and get going? nopaque is free and no download is needed. Register now!

          -
          - -
          -
          - -
          -
          -
          -
          - {{ login_form.hidden_tag() }} - {{ M.render_field(login_form.user, material_icon='person') }} - {{ M.render_field(login_form.password, material_icon='vpn_key') }} -
          - -
          - {{ M.render_field(login_form.remember_me) }} -
          +{% block page_content %} +
          +
          +
          +
          +
          +

          {{ title }}

          +

          Want to boost your research and get going? nopaque is free and no download is needed. Register now!

          +
          +
          -
          - {{ M.render_field(login_form.submit, material_icon='send') }} +
          + +
          +
          + +
          + {{ login_form.hidden_tag() }} + {{ wtf.render_field(login_form.user, material_icon='person') }} + {{ wtf.render_field(login_form.password, material_icon='vpn_key') }} +
          + +
          + {{ wtf.render_field(login_form.remember_me) }} +
          +
          +
          +
          + {{ wtf.render_field(login_form.submit, material_icon='send') }} +
          +
          - +
          {% endblock %} diff --git a/web/app/templates/auth/register.html.j2 b/web/app/templates/auth/register.html.j2 index af8a0b95..9909cd60 100644 --- a/web/app/templates/auth/register.html.j2 +++ b/web/app/templates/auth/register.html.j2 @@ -1,8 +1,8 @@ {% extends "nopaque.html.j2" %} +{% import 'materialize/wtf.html.j2' as wtf %} -{% set headline = ' ' %} - -{% block page_content %} +{% block styles %} +{{ super() }} +{% endblock styles %} -
          -
          -
          -

          Register

          -

          Simply enter a username and password to receive your registration email. After that you can start right away.

          -

          It goes without saying that the General Data Protection Regulation applies, only necessary data is stored.

          -

          Please also read our terms of use before signing up for nopaque!

          +{% block page_content %} +
          +
          +
          +
          +
          +

          {{ title }}

          +

          Simply enter a username and password to receive your registration email. After that you can start right away.

          +

          It goes without saying that the General Data Protection Regulation applies, only necessary data is stored.

          +

          Please also read our terms of use before signing up for nopaque!

          +
          +
          +
          + +
          +
          +
          +
          + {{ registration_form.hidden_tag() }} + {{ wtf.render_field(registration_form.username, data_length='64', material_icon='person') }} + {{ wtf.render_field(registration_form.password, data_length='128', material_icon='vpn_key') }} + {{ wtf.render_field(registration_form.password_confirmation, data_length='128', material_icon='vpn_key') }} + {{ wtf.render_field(registration_form.email, class_='validate', material_icon='email', type='email') }} +
          +
          + {{ wtf.render_field(registration_form.submit, material_icon='send') }} +
          +
          +
          - -
          -
          -
          -
          - {{ registration_form.hidden_tag() }} - {{ M.render_field(registration_form.username, data_length='64', material_icon='person') }} - {{ M.render_field(registration_form.password, data_length='128', material_icon='vpn_key') }} - {{ M.render_field(registration_form.password_confirmation, data_length='128', material_icon='vpn_key') }} - {{ M.render_field(registration_form.email, class_='validate', material_icon='email', type='email') }} -
          -
          - {{ M.render_field(registration_form.submit, material_icon='send') }} -
          -
          -
          -
          {% endblock %} diff --git a/web/app/templates/auth/reset_password.html.j2 b/web/app/templates/auth/reset_password.html.j2 index 6bd93f56..f3e4f26e 100644 --- a/web/app/templates/auth/reset_password.html.j2 +++ b/web/app/templates/auth/reset_password.html.j2 @@ -1,23 +1,31 @@ {% extends "nopaque.html.j2" %} +{% import 'materialize/wtf.html.j2' as wtf %} {% block page_content %} -
          -

          Lorem ipsum

          -

          dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren,

          -
          +
          +
          +
          +

          {{ title }}

          +
          -
          -
          -
          -
          - {{ reset_password_form.hidden_tag() }} - {{ M.render_field(reset_password_form.password, data_length='128') }} - {{ M.render_field(reset_password_form.password_confirmation, data_length='128') }} +
          +

          Enter a new password and confirm it! After that, the entered password is your new one!

          +
          + +
          +
          + +
          + {{ reset_password_form.hidden_tag() }} + {{ wtf.render_field(reset_password_form.password, data_length='128') }} + {{ wtf.render_field(reset_password_form.password_confirmation, data_length='128') }} +
          +
          + {{ wtf.render_field(reset_password_form.submit, material_icon='send') }} +
          +
          -
          - {{ M.render_field(reset_password_form.submit, material_icon='send') }} -
          - +
          {% endblock %} diff --git a/web/app/templates/auth/reset_password_request.html.j2 b/web/app/templates/auth/reset_password_request.html.j2 index cb8f23c8..403f1388 100644 --- a/web/app/templates/auth/reset_password_request.html.j2 +++ b/web/app/templates/auth/reset_password_request.html.j2 @@ -1,21 +1,30 @@ {% extends "nopaque.html.j2" %} +{% import 'materialize/wtf.html.j2' as wtf %} {% block page_content %} -
          -

          After entering your email address you will receive instructions on how to reset your password.

          -
          +
          +
          +
          +

          {{ title }}

          +
          -
          -
          -
          -
          - {{ reset_password_request_form.hidden_tag() }} - {{ M.render_field(reset_password_request_form.email, class_='validate', material_icon='email', type='email') }} +
          +

          After entering your email address you will receive instructions on how to reset your password.

          +
          + +
          +
          + +
          + {{ reset_password_request_form.hidden_tag() }} + {{ wtf.render_field(reset_password_request_form.email, class_='validate', material_icon='email', type='email') }} +
          +
          + {{ wtf.render_field(reset_password_request_form.submit, material_icon='send') }} +
          +
          -
          - {{ M.render_field(reset_password_request_form.submit, material_icon='send') }} -
          - +
          {% endblock %} diff --git a/web/app/templates/auth/unconfirmed.html.j2 b/web/app/templates/auth/unconfirmed.html.j2 index f3d26dc9..613c51a4 100644 --- a/web/app/templates/auth/unconfirmed.html.j2 +++ b/web/app/templates/auth/unconfirmed.html.j2 @@ -1,20 +1,25 @@ {% extends "nopaque.html.j2" %} -{% block title %}Opaque - Confirm your account{% endblock %} - {% block page_content %} -