Commit 029fbfce by Owo Sugiana

Add forgot password

1 parent 7bbfdd64
0.1.1
-----
- Forgot password.
0.1
---
......
Web Starter
===========
Getting Started
---------------
Change directory into your newly created project::
cd web-starter
......
......@@ -18,6 +18,12 @@ sqlalchemy.url = postgresql://user:pass@localhost/dbname
timezone = Asia/Jakarta
localization = id_ID.UTF-8
mail.host = localhost
mail.port = 25
mail.username = user@example.com
mail.password = FIXME
mail.sender_name = Example Name
# By default, the toolbar only appears for clients from IP addresses
# '127.0.0.1' and '::1'.
# debugtoolbar.hosts = 127.0.0.1 ::1
......
NAME="$1"
msgfmt -o web_starter/locale/id/LC_MESSAGES/$NAME.mo web_starter/locale/id/LC_MESSAGES/$NAME.po
......@@ -16,6 +16,12 @@ sqlalchemy.url = postgresql://user:pass@localhost/dbname
timezone = Asia/Jakarta
localization = id_ID.UTF-8
mail.host = localhost
mail.port = 25
mail.username = user@example.com
mail.password = FIXME
mail.sender_name = Example Name
###
# wsgi server configuration
###
......
name,path
home,/
login,/login
logout,/logout
password,/password
......@@ -14,6 +14,7 @@ requires = [
'pyramid',
'pyramid_chameleon',
'pyramid_debugtoolbar',
'pyramid_tm',
'waitress',
'zope.sqlalchemy',
'psycopg2-binary',
......@@ -23,6 +24,7 @@ requires = [
'colander',
'deform',
'pyramid_beaker',
'pyramid_mailer',
]
tests_require = [
......
import os
import csv
import deform
from pkg_resources import resource_filename
......@@ -7,6 +8,7 @@ from pyramid.config import Configurator
from pyramid_beaker import session_factory_from_settings
from pyramid.authentication import AuthTktAuthenticationPolicy
from pyramid.authorization import ACLAuthorizationPolicy
from pyramid_mailer import mailer_factory_from_settings
from sqlalchemy import engine_from_config
from .models import (
DBSession,
......@@ -20,6 +22,29 @@ from .tools.this_framework import get_locale_name
from .views import RemoveSlashNotFoundViewFactory
here = os.path.abspath(os.path.dirname(__file__))
routes_file = os.path.join(here, 'routes.csv')
def set_paths(config):
with open(routes_file) as f:
c = csv.DictReader(f)
for row in c:
path = row['path'] or '/' + row['name']
config.add_route(row['name'], path)
config.scan()
def translator(term):
return get_localizer(get_current_request()).translate(term)
deform_template_dir = resource_filename('deform', 'templates/')
zpt_renderer = deform.ZPTRendererFactory(
[deform_template_dir], translator=translator)
deform.Form.set_default_renderer(zpt_renderer)
def main(global_config, **settings):
""" This function returns a Pyramid WSGI application.
"""
......@@ -32,6 +57,7 @@ def main(global_config, **settings):
root_factory='web_starter.models.ziggurat.RootFactory',
session_factory=session_factory,
locale_negotiator=get_locale_name)
config.include('pyramid_tm')
config.include('pyramid_beaker')
config.include('pyramid_chameleon')
......@@ -48,18 +74,6 @@ def main(global_config, **settings):
config.add_static_view('deform_static', 'deform:static')
config.add_translation_dirs('locale')
def translator(term):
return get_localizer(get_current_request()).translate(term)
deform_template_dir = resource_filename('deform', 'templates/')
zpt_renderer = deform.ZPTRendererFactory(
[deform_template_dir],
translator=translator,)
deform.Form.set_default_renderer(zpt_renderer)
with open('routes.csv') as f:
c = csv.DictReader(f)
for row in c:
config.add_route(row['name'], row['path'])
config.scan()
config.registry['mailer'] = mailer_factory_from_settings(settings)
set_paths(config)
return config.make_wsgi_app()
......@@ -19,3 +19,15 @@ msgstr ""
#. Default: Copyright © Pylons Project
msgid "copyright"
msgstr ""
msgid "welcome"
msgstr ""
msgid "you-are-logged-in"
msgstr ""
msgid "login-link"
msgstr ""
msgid "Home"
msgstr ""
......@@ -6,7 +6,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE 1.0\n"
"POT-Creation-Date: 2018-10-15 20:15+0700\n"
"PO-Revision-Date: 2018-10-15 20:17+0700\n"
"PO-Revision-Date: 2018-10-19 17:11+0700\n"
"Last-Translator: Owo Sugiana <sugiana@gmail.com>\n"
"Language-Team: Indonesian\n"
"Language: id\n"
......@@ -24,3 +24,9 @@ msgstr "Selamat datang di <span class=\"font-normal\">Web Starter</span>, sebuah
msgid "you-are-logged-in"
msgstr "Anda sedang masuk sebagai ${username}. Klik <a href=\"/logout\">di sini</a> untuk keluar."
msgid "login-link"
msgstr "Silakan masuk"
msgid "Home"
msgstr "Beranda"
......@@ -16,11 +16,11 @@ msgstr ""
"Generated-By: Lingua 4.13\n"
#. Default: Enter new password for ${name}:
msgid "Enter new password for ${name}: "
msgid "ask-password-1"
msgstr "Masukkan password baru untuk ${name}: "
#. Default: Retype new password for ${name}:
msgid "Retype new password for ${name}: "
msgid "ask-password-2"
msgstr "Ulangi password baru untuk ${name}: "
msgid "Sorry, passwords do not match"
......
......@@ -33,3 +33,54 @@ msgstr "Nama"
msgid "Password"
msgstr "Kata kunci"
msgid "Reset password"
msgstr "Pemulihan kata kunci"
msgid "email-reset-password"
msgstr "Tulis email Anda dan kami akan mengirimkan tautan untuk penetapan ulang kata kunci"
msgid "Send password reset email"
msgstr "Kirim email"
msgid "reset-password-body"
msgstr "Kami menerima permintaan pemulihan kata sandi. Silakan klik tautan berikut:\n\n${url}\n\nTautan ini akan kedaluwarsa dalam ${minutes} menit. Mohon abaikan jika Anda tidak memintanya."
msgid "reset-password-link-sent"
msgstr "Periksa email Anda untuk tautan pemulihan kata kunci. Jika tidak muncul dalam beberapa menit, periksa di bagian spam."
msgid "Invalid email"
msgstr "Email tidak terdaftar"
msgid "Forgot password"
msgstr "Lupa kata kunci"
msgid "change-password-done"
msgstr "Kata kunci Anda telah diubah"
msgid "Save"
msgstr "Simpan"
msgid "Cancel"
msgstr "Batalkan"
msgid "Invalid old password"
msgstr "Kata kunci yang lama tidak benar"
msgid "Retype mismatch"
msgstr "Pengulangan kata kunci yang baru tidak sama"
msgid "Old password"
msgstr "Kata kunci yang lama"
msgid "New password"
msgstr "Kata kunci yang baru"
msgid "Retype new password"
msgstr "Ulangi kata kunci yang baru"
msgid "Change password"
msgstr "Ganti kata kunci"
msgid "Invalid security code"
msgstr "Kode keamananan tidak benar"
......@@ -25,3 +25,49 @@ msgstr ""
#: ./views/login.py:58
msgid "login"
msgstr ""
msgid "Reset password"
msgstr ""
#. Default: Enter your email address and we will send you a link to reset your password.
msgid "email-reset-password"
msgstr ""
msgid "Send password reset email"
msgstr ""
msgid "reset-password-body"
msgstr ""
msgid "reset-password-link-sent"
msgstr ""
msgid "change-password-done"
msgstr ""
msgid "Save"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Invalid old password"
msgstr ""
msgid "Retype mismatch"
msgstr ""
msgid "Old password"
msgstr ""
msgid "New password"
msgstr ""
msgid "Retype new password"
msgstr ""
msgid "Change password"
msgstr ""
msgid "Invalid security code"
msgstr ""
name,path
home,/
login
logout
password
change-password
change-password-done
reset-password
reset-password-sent
login-by-code-failed
id,email,status,user_name
0,anonymous@local,0
,admin@local,1,admin
email,status,user_name
admin@local,1,admin
......@@ -4,18 +4,17 @@ import csv
import subprocess
import transaction
from getpass import getpass
from translationstring import (
TranslationString,
Translator,
ugettext_policy,
)
from translationstring.tests.translations import Translations
from sqlalchemy import engine_from_config
from ziggurat_foundations.models.services.user import UserService
from pyramid.paster import (
get_appsettings,
setup_logging,
)
from pyramid.i18n import (
Localizer,
TranslationStringFactory,
Translations,
)
from ..models import (
DBSession,
Base,
......@@ -28,34 +27,23 @@ from ..models.ziggurat import (
)
class Penerjemah:
def __init__(self, domain='initialize_db'):
self.domain = domain
def configure(self, settings):
locale_ids = [settings['pyramid.default_locale_name']]
here = os.path.abspath(os.path.dirname(__file__))
localedir = os.path.join(here, '..', 'locale')
try:
self.translations = Translations.load(
localedir, locale_ids, self.domain)
except TypeError:
self.translations = None
self.translator = self.translations and Translator(
self.translations, ugettext_policy)
def terjemahkan(self, msg, mapping=None):
ts = TranslationString(msg, mapping=mapping)
if self.translator:
return self.translator(ts)
return ts.interpolate()
domain = 'initialize_db'
_ = TranslationStringFactory(domain)
my_registry = dict()
obj_penerjemah = Penerjemah()
class MyLocalizer:
def __init__(self):
settings = my_registry['settings']
locale_name = settings['pyramid.default_locale_name']
here = os.path.abspath(os.path.dirname(__file__))
locale_dir = os.path.join(here, '..', 'locale')
translations = Translations.load(locale_dir, [locale_name], domain)
self.localizer = Localizer(locale_name, translations)
def _(msg, mapping=None):
return obj_penerjemah.terjemahkan(msg, mapping)
def translate(self, ts):
return self.localizer.translate(ts)
def usage(argv):
......@@ -92,9 +80,16 @@ def get_file(filename):
def ask_password(name):
localizer = MyLocalizer()
data = dict(name=name)
msg1 = _('Enter new password for ${name}: ', mapping=data)
msg2 = _('Retype new password for ${name}: ', mapping=data)
t_msg1 = _(
'ask-password-1', default='Enter new password for ${name}: ',
mapping=data)
t_msg2 = _(
'ask-password-2', default='Retype new password for ${name}: ',
mapping=data)
msg1 = localizer.translate(t_msg1)
msg2 = localizer.translate(t_msg2)
while True:
pass1 = getpass(msg1)
if not pass1:
......@@ -102,7 +97,8 @@ def ask_password(name):
pass2 = getpass(msg2)
if pass1 == pass2:
return pass1
print(_('Sorry, passwords do not match'))
ts = _('Sorry, passwords do not match')
print(localizer.translate(ts))
def restore_csv(table, filename):
......@@ -128,7 +124,7 @@ def main(argv=sys.argv):
config_uri = argv[1]
setup_logging(config_uri)
settings = get_appsettings(config_uri)
obj_penerjemah.configure(settings)
my_registry['settings'] = settings
engine = engine_from_config(settings, 'sqlalchemy.')
Base.metadata.bind = engine
Base.metadata.create_all()
......@@ -140,6 +136,5 @@ def main(argv=sys.argv):
user = q.first()
password = ask_password(user.user_name)
UserService.set_password(user, password)
UserService.regenerate_security_code(user)
restore_csv(Group, 'groups.csv')
restore_csv(UserGroup, 'users_groups.csv')
......@@ -5,6 +5,10 @@ from pyramid.httpexceptions import (
)
from pyramid.interfaces import IRoutesMapper
from pyramid.response import Response
from pyramid.i18n import TranslationStringFactory
_ = TranslationStringFactory('home')
# http://stackoverflow.com/questions/9845669/pyramid-inverse-to-add-notfound-viewappend-slash-true
......@@ -33,12 +37,11 @@ class RemoveSlashNotFoundViewFactory:
return self.notfound_view(context, request)
@view_config(route_name='home', renderer='templates/mytemplate.pt',
permission='view')
@view_config(route_name='home', renderer='templates/home.pt')
def my_view(request):
if '_LOCALE_' in request.GET:
resp = Response()
resp.set_cookie('_LOCALE_', request.GET['_LOCALE_'], 31536000)
return HTTPFound(
location=request.route_url('home'), headers=resp.headers)
return {'project': 'Web Starter'}
return dict(project='Web Starter', title=_('Home'))
from datetime import datetime
import os
from datetime import (
datetime,
timedelta,
)
from urllib.parse import urlparse
from pyramid.response import Response
from pyramid.view import view_config
from pyramid.httpexceptions import (
......@@ -11,7 +16,6 @@ from pyramid.security import (
authenticated_userid,
)
from pyramid.i18n import TranslationStringFactory
import transaction
import colander
from deform import (
Form,
......@@ -20,7 +24,9 @@ from deform import (
Button,
)
from ziggurat_foundations.models.services.user import UserService
from pyramid_mailer.message import Message
from ..tools.waktu import create_now
from ..tools.this_framework import get_settings
from ..models import DBSession
from ..models.ziggurat import User
......@@ -32,7 +38,8 @@ class Login(colander.Schema):
username = colander.SchemaNode(colander.String(), title=_('Username'))
password = colander.SchemaNode(
colander.String(), widget=widget.PasswordWidget(),
title=_('Password'))
title=_('Password'),
description=_('Forgot password'))
# http://deformdemo.repoze.org/interfield/
......@@ -44,15 +51,43 @@ def login_validator(form, value):
not UserService.check_password(user, value['password']):
raise colander.Invalid(form, _('Login failed'))
def get_login_headers(request, user):
def login_ok(request, user, route='home'):
headers = remember(request, user.id)
user.last_login_date = create_now()
DBSession.add(user)
return HTTPFound(location=request.route_url(route), headers=headers)
def get_user_by_identity(request):
identity = request.POST.get('username')
if identity.find('@') > -1:
q = DBSession.query(User).filter_by(email=identity)
else:
q = DBSession.query(User).filter_by(user_name=identity)
return q.first()
one_hour = timedelta(1/24)
two_minutes = timedelta(1/24/60)
def login_by_code(request):
q = DBSession.query(User).filter_by(security_code=request.GET['code'])
user = q.first()
if not user or \
create_now() - user.security_code_date > one_hour:
return HTTPFound(location=request.route_url('login-by-code-failed'))
user.security_code = None
DBSession.add(user)
DBSession.flush()
transaction.commit()
return headers
return login_ok(request, user, 'change-password')
def login_default_response():
return dict(title=_('Login'), label_forgot_password=_('Forgot password'))
@view_config(context=HTTPForbidden, renderer='templates/login.pt')
@view_config(route_name='login', renderer='templates/login.pt')
def view_login(request):
if authenticated_userid(request):
......@@ -62,28 +97,23 @@ def view_login(request):
resp.set_cookie('_LOCALE_', request.GET['_LOCALE_'], 31536000)
return HTTPFound(
location=request.route_url('login'), headers=resp.headers)
resp = dict(title=_('Login'))
if 'code' in request.GET:
return login_by_code(request)
resp = login_default_response()
schema = Login(validator=login_validator)
btn_submit = Button('submit', _('Submit'))
form = Form(schema, buttons=(btn_submit,))
if 'submit' in request.POST:
if 'submit' not in request.POST:
resp['form'] = form.render()
return resp
controls = request.POST.items()
identity = request.POST.get('username')
if identity.find('@') > -1:
q = DBSession.query(User).filter_by(email=identity)
else:
q = DBSession.query(User).filter_by(user_name=identity)
schema.user = user = q.first()
schema.user = user = get_user_by_identity(request)
try:
c = form.validate(controls)
except ValidationFailure:
resp['form'] = form.render()
return resp
headers = get_login_headers(request, user)
return HTTPFound(location=request.route_url('home'),
headers=headers)
resp['form'] = form.render()
return resp
return login_ok(request, user)
@view_config(route_name='logout')
......@@ -93,52 +123,151 @@ def view_logout(request):
headers = headers)
@view_config(route_name='login-by-code-failed', renderer='templates/login-by-code-failed.pt')
def view_login_by_code_failed(request):
return dict(title='Login by code failed')
###################
# Change password #
###################
class Password(colander.Schema):
old_password = colander.SchemaNode(colander.String(),
title="Kata Sandi Lama",
old_password = colander.SchemaNode(
colander.String(), title=_('Old password'),
widget=widget.PasswordWidget())
new_password = colander.SchemaNode(colander.String(),
title="Kata Sandi Baru",
new_password = colander.SchemaNode(
colander.String(), title=_('New password'),
widget=widget.PasswordWidget())
retype_password = colander.SchemaNode(colander.String(),
title="Ketik Ulang Kata Sandi",
retype_password = colander.SchemaNode(
colander.String(), title=_('Retype new password'),
widget=widget.PasswordWidget())
def password_validator(form, value):
if not form.request.user.check_password(value['old_password']):
raise colander.Invalid(form, 'Invalid old password.')
if not UserService.check_password(
form.request.user, value['old_password']):
raise colander.Invalid(form, _('Invalid old password'))
if value['new_password'] != value['retype_password']:
raise colander.Invalid(form, 'Retype mismatch.')
raise colander.Invalid(form, _('Retype mismatch'))
@view_config(route_name='password', renderer='templates/password.pt',
@view_config(
route_name='change-password', renderer='templates/change-password.pt',
permission='view')
def view_password(request):
def view_change_password(request):
schema = Password(validator=password_validator)
form = Form(schema, buttons=('simpan','batal'))
if request.POST:
if 'simpan' in request.POST:
btn_submit = Button('save', _('Save'))
btn_cancel = Button('cancel', _('Cancel'))
form = Form(schema, buttons=(btn_submit, btn_cancel))
resp = dict(title=_('Change password'))
if not request.POST:
resp['form'] = form.render()
return resp
if 'save' not in request.POST:
return HTTPFound(location=request.route_url('home'))
schema.request = request
controls = request.POST.items()
try:
c = form.validate(controls)
except ValidationFailure as e:
request.session['invalid password'] = e.render()
return HTTPFound(location=request.route_url('password'))
user = request.user
user.password = c['new_password']
resp['form'] = form.render()
return resp
UserService.set_password(request.user, c['new_password'])
DBSession.add(request.user)
return HTTPFound(location=request.route_url('change-password-done'))
@view_config(
route_name='change-password-done',
renderer='templates/change-password-done.pt', permission='view')
def view_change_password_done(request):
return dict(title=_('Change password'))
##################
# Reset password #
##################
class ResetPassword(colander.Schema):
email = colander.SchemaNode(
colander.String(), title=_('Email'),
description=_(
'email-reset-password',
default='Enter your email address and we will send you '\
'a link to reset your password.')
)
def reset_password_validator(form, value):
user = form.user
if not user or \
not user.status or \
not user.security_code:
raise colander.Invalid(form, _('Invalid email'))
def security_code_age(user):
return create_now() - user.security_code_date
def send_email_security_code(request, user, time_remain):
settings = get_settings()
up = urlparse(request.url)
url = '{}://{}/login?code={}'.format(
up.scheme, up.netloc, user.security_code)
minutes = int(time_remain.seconds / 60)
data = dict(url=url, minutes=minutes)
here = os.path.abspath(os.path.dirname(__file__))
body_file = os.path.join(here, 'reset-password-body.tpl')
with open(body_file) as f:
body_tpl = f.read()
body = _('reset-password-body', default=body_tpl, mapping=data)
body = request.localizer.translate(body)
sender = '{} <{}>'.format(
settings['mail.sender_name'], settings['mail.username'])
subject = request.localizer.translate(_('Reset password'))
message = Message(
subject=subject, sender=sender, recipients=[user.email], body=body)
mailer = request.registry['mailer']
mailer.send(message)
def regenerate_security_code(user):
age = security_code_age(user)
remain = one_hour - age
if user.security_code and age < one_hour and remain > two_minutes:
return remain
UserService.regenerate_security_code(user)
user.security_code_date = create_now()
DBSession.add(user)
DBSession.flush()
transaction.commit()
#request.session.flash('Your password has been changed.')
request.session.flash('Password telah berhasil diubah.')
return one_hour
@view_config(route_name='reset-password', renderer='templates/reset-password.pt')
def view_reset_password(request):
if authenticated_userid(request):
return HTTPFound(location=request.route_url('home'))
elif 'invalid password' in request.session:
r = dict(form=request.session['invalid password'])
del request.session['invalid password']
return r
return dict(form=form.render())
resp = dict(title=_('Reset password'))
schema = ResetPassword(validator=reset_password_validator)
btn_submit = Button('submit', _('Send password reset email'))
form = Form(schema, buttons=(btn_submit,))
if 'submit' in request.POST:
controls = request.POST.items()
identity = request.POST.get('email')
q = DBSession.query(User).filter_by(email=identity)
schema.user = user = q.first()
try:
c = form.validate(controls)
except ValidationFailure:
resp['form'] = form.render()
return resp
remain = regenerate_security_code(user)
send_email_security_code(request, user, remain)
return HTTPFound(location=request.route_url('reset-password-sent'))
resp['form'] = form.render()
return resp
@view_config(
route_name='reset-password-sent', renderer='templates/reset-password-sent.pt')
def view_reset_password_sent(request):
return dict(title=_('Reset password'))
We accepted password recovery requests. Please click the following link:
${url}
This link will expire in ${minutes} minutes.
If you did not request this, please ignore it.
<div metal:use-macro="load: layout.pt">
<div metal:fill-slot="content" i18n:domain="login">
<p i18n:translate="change-password-done">
Your password has been changed
</p>
</div>
</div>
<div metal:use-macro="load: layout-form.pt">
<div metal:fill-slot="content">
<div tal:content="structure form" />
</div>
</div>
<div metal:use-macro="load: layout.pt">
<div metal:fill-slot="content" i18n:domain="ChameleonI18n">
<div metal:fill-slot="content" i18n:domain="home">
<div class="content">
<h1><span class="font-semi-bold">Pyramid</span> <span class="smaller">Starter project</span></h1>
<p i18n:translate="welcome" class="lead">Welcome to <span
class="font-normal">Web Starter</span>, a&nbsp;Pyramid
application generated&nbsp;by<br><span class="font-normal">Cookiecutter</span>.</p>
<p tal:condition="not request.user">
<a href="/login" i18n:translate="login-link">Login please</a>
</p>
<p i18n:translate="you-are-logged-in" tal:condition="request.user">You are logged in as
<span tal:replace="request.user.user_name" i18n:name="username" />. Click <a href="/logout">here</a> to
logout.</p>
</div>
</div>
......
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">
<link rel="shortcut icon" href="/static/pyramid-16x16.png">
<title tal:content="title"/>
<!-- Bootstrap core CSS -->
<link href="/deform_static/css/bootstrap.min.css" rel="stylesheet"/>
<link href="/deform_static/css/form.css" rel="stylesheet"/>
<!-- Custom styles for this template -->
<link href="/static/signin.css" rel="stylesheet">
</head>
<body>
<div class="container">
<div metal:define-slot="content"/>
</div> <!-- /container -->
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<script type="text/javascript" src="/deform_static/scripts/jquery-2.0.3.min.js"></script>
<script type="text/javascript" src="/deform_static/scripts/bootstrap.min.js"></script>
<script type="text/javascript" src="/deform_static/scripts/deform.js"></script>
<div metal:define-slot="content-script"/>
</body>
</html>
<!DOCTYPE html metal:define-macro="layout">
<html lang="${request.locale_name}"
xmlns:i18n="http://xml.zope.org/namespaces/i18n"
i18n:domain="ChameleonI18n">
i18n:domain="home">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
......@@ -10,7 +10,7 @@
<meta name="author" content="Pylons Project">
<link rel="shortcut icon" href="${request.static_url('web_starter:static/pyramid-16x16.png')}">
<title>Cookiecutter Starter project for the Pyramid Web Framework</title>
<title>Web Starter - <span tal:replace="title"/></title>
<!-- Bootstrap core CSS -->
<link href="//oss.maxcdn.com/libs/twitter-bootstrap/3.0.3/css/bootstrap.min.css" rel="stylesheet">
......
<div metal:use-macro="load: layout.pt">
<div metal:fill-slot="content" i18n:domain="login">
<p i18n:translate="Invalid security code">
Invalid security code.
</p>
</div>
</div>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="">
<meta name="author" content="">
<link rel="shortcut icon" href="/static/pyramid-16x16.png">
<div metal:use-macro="load: layout-form.pt">
<div metal:fill-slot="content">
<title tal:content="title"/>
<!-- Bootstrap core CSS -->
<link href="/deform_static/css/bootstrap.min.css" rel="stylesheet"/>
<link href="/deform_static/css/form.css" rel="stylesheet"/>
<!-- Custom styles for this template -->
<link href="/static/signin.css" rel="stylesheet">
</head>
<body>
<div class="container">
<div tal:content="structure form"/>
<div tal:condition="form" tal:content="structure form"/>
<p tal:condition="request.locale_name != 'en'">
<a href="?_LOCALE_=en">English</a>
......@@ -32,13 +10,16 @@
<a href="?_LOCALE_=id">Indonesia</a>
</p>
</div> <!-- /container -->
</div>
<div metal:fill-slot="content-script">
<script type="text/javascript">
$(window).on('load', function() {
$("p.help-block").html('<a href="/reset-password">${label_forgot_password}</a>');
});
</script>
</div>
<!-- Bootstrap core JavaScript
================================================== -->
<!-- Placed at the end of the document so the pages load faster -->
<script type="text/javascript" src="/deform_static/scripts/jquery-2.0.3.min.js"></script>
<script type="text/javascript" src="/deform_static/scripts/bootstrap.min.js"></script>
<script type="text/javascript" src="/deform_static/scripts/deform.js"></script>
</body>
</html>
</div>
<div metal:use-macro="load: layout.pt">
<div metal:fill-slot="content" i18n:domain="login">
<p i18n:translate="reset-password-link-sent">
Check your email for a link to reset your password. If it
doesn’t appear within a few minutes, check your spam folder. The
reset password link has been sent. Please check your email.
</p>
</div>
</div>
<div metal:use-macro="load: layout-form.pt">
<div metal:fill-slot="content">
<div tal:content="structure form" />
</div>
</div>
Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!