-
Notifications
You must be signed in to change notification settings - Fork 153
Add Password Reset Page #2420
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Add Password Reset Page #2420
Changes from all commits
8f90cb0
b4836ff
6cba981
0325a09
fb28856
cf7d611
bd9ab61
5eb0ba1
e51e5e0
badade7
8e31d82
1831d19
d99f083
5165930
0e5d131
08c921f
a842ed4
28c8428
7c8a67a
d9fa8ac
ef34da4
4da6b19
cf6f5ad
8ec0346
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import os | ||
|
|
||
| import requests | ||
|
|
||
| RESEND_API_URL = "https://api.resend.com/emails" | ||
| RESEND_TIMEOUT = 10 | ||
|
|
||
|
|
||
| class EmailSendError(RuntimeError): | ||
| pass | ||
|
|
||
|
|
||
| class EmailSender: | ||
| def __init__( | ||
| self, | ||
| api_key=None, | ||
| from_email=None, | ||
| from_name=None, | ||
| session=None, | ||
| ): | ||
| self.api_key = api_key or os.getenv("FISHTEST_RESEND_API_KEY") | ||
| self.from_email = from_email or os.getenv( | ||
| "FISHTEST_RESEND_FROM_EMAIL", "fishtest@resend.dev" | ||
| ) | ||
| self.from_name = from_name or os.getenv("FISHTEST_RESEND_FROM_NAME", "Fishtest") | ||
| self.session = session or requests.Session() | ||
| missing_settings = [] | ||
| if not self.api_key: | ||
| missing_settings.append("FISHTEST_RESEND_API_KEY") | ||
| if not self.from_email: | ||
| missing_settings.append("FISHTEST_RESEND_FROM_EMAIL") | ||
| if missing_settings: | ||
| print( | ||
| "Email sending is not configured; missing {}.".format( | ||
| ", ".join(missing_settings) | ||
| ), | ||
| flush=True, | ||
| ) | ||
|
|
||
| def _from_field(self): | ||
| if not self.from_email: | ||
| raise EmailSendError("FISHTEST_RESEND_FROM_EMAIL is missing.") | ||
| if self.from_name: | ||
| return f"{self.from_name} <{self.from_email}>" | ||
| return self.from_email | ||
|
|
||
| def send( | ||
| self, | ||
| to_email, | ||
| subject, | ||
| text, | ||
| html=None, | ||
| reply_to=None, | ||
| ): | ||
|
Disservin marked this conversation as resolved.
|
||
| if not self.api_key: | ||
| raise EmailSendError("FISHTEST_RESEND_API_KEY is missing.") | ||
| if not to_email: | ||
| raise EmailSendError("to_email is required.") | ||
|
|
||
| payload = { | ||
| "from": self._from_field(), | ||
| "to": [to_email] if isinstance(to_email, str) else to_email, | ||
| "subject": subject, | ||
| "text": text, | ||
| } | ||
| if html: | ||
| payload["html"] = html | ||
| if reply_to: | ||
| payload["reply_to"] = reply_to | ||
|
|
||
| headers = {"Authorization": f"Bearer {self.api_key}"} | ||
| try: | ||
| response = self.session.post( | ||
| RESEND_API_URL, json=payload, headers=headers, timeout=RESEND_TIMEOUT | ||
| ) | ||
| except Exception as exc: | ||
| raise EmailSendError(f"Failed to reach Resend: {exc}") from exc | ||
|
|
||
| if not response.ok: | ||
| raise EmailSendError( | ||
| f"Resend error {response.status_code}: {response.text}" | ||
| ) | ||
|
|
||
| return response.json() | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| <%inherit file="base.mak"/> | ||
|
|
||
| <script> | ||
| document.title = "Forgot Password | Stockfish Testing"; | ||
| </script> | ||
|
|
||
| <%block name="head"> | ||
| <script src='https://www.google.com/recaptcha/api.js'></script> | ||
| </%block> | ||
|
|
||
| <div class="col-limited-size"> | ||
| <header class="text-md-center py-2"> | ||
| <h2>Reset your password</h2> | ||
| <div class="alert alert-info"> | ||
| Enter the email linked to your account and we'll send a reset link. | ||
| </div> | ||
| </header> | ||
|
|
||
| <form method="POST"> | ||
| <div class="form-floating mb-3"> | ||
| <input | ||
| type="email" | ||
| class="form-control mb-3" | ||
| id="email" | ||
| name="email" | ||
| placeholder="Email" | ||
| autocomplete="email" | ||
| required | ||
| autofocus | ||
| > | ||
| <label for="email" class="d-flex align-items-end">Email</label> | ||
| </div> | ||
|
|
||
| <div class="g-recaptcha mb-3" | ||
| data-sitekey="6LePs8YUAAAAABMmqHZVyVjxat95Z1c_uHrkugZM"></div> | ||
|
|
||
| <button type="submit" class="btn btn-primary w-100">Send reset link</button> | ||
| </form> | ||
|
|
||
| <div class="text-center mt-3"> | ||
| <a href="${request.route_url('login')}" class="alert-link">Back to login</a> | ||
| </div> | ||
| </div> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| <%inherit file="base.mak"/> | ||
|
|
||
| <script> | ||
| document.title = "Reset Password | Stockfish Testing"; | ||
| </script> | ||
|
|
||
| <div class="col-limited-size"> | ||
| <header class="text-md-center py-2"> | ||
| <h2>Choose a new password</h2> | ||
| </header> | ||
|
|
||
| <form method="POST"> | ||
| <input type="hidden" name="form_token" value="${form_token}"> | ||
| <div class="input-group mb-3"> | ||
| <div class="form-floating"> | ||
| <input | ||
| type="password" | ||
| class="form-control" | ||
| id="password" | ||
| name="password" | ||
| placeholder="New Password" | ||
| pattern=".{8,}" | ||
| title="Eight or more characters: a password too simple or trivial to guess will be rejected" | ||
| autocomplete="new-password" | ||
| required | ||
| autofocus | ||
| > | ||
| <label for="password" class="d-flex align-items-end">New Password</label> | ||
| </div> | ||
| <span class="input-group-text toggle-password-visibility" role="button"> | ||
| <i class="fa-solid fa-lg fa-eye pe-none" style="width: 30px"></i> | ||
| </span> | ||
| </div> | ||
|
|
||
| <div class="input-group mb-3"> | ||
| <div class="form-floating"> | ||
| <input | ||
| type="password" | ||
| class="form-control" | ||
| id="password2" | ||
| name="password2" | ||
| placeholder="Repeat Password" | ||
| autocomplete="new-password" | ||
| required | ||
| > | ||
| <label for="password2" class="d-flex align-items-end">Repeat Password</label> | ||
| </div> | ||
| <span class="input-group-text toggle-password-visibility" role="button"> | ||
| <i class="fa-solid fa-lg fa-eye pe-none" style="width: 30px"></i> | ||
| </span> | ||
| </div> | ||
|
|
||
| <button type="submit" class="btn btn-primary w-100">Reset password</button> | ||
| </form> | ||
| </div> | ||
|
|
||
| <script src="${request.static_url('fishtest:static/js/toggle_password.js')}"></script> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -137,6 +137,68 @@ def save_user(self, user): | |
| self.last_blocked_time = 0 | ||
| self.clear_cache() | ||
|
|
||
| def set_password_reset(self, user, token, expires_at): | ||
| user["password_reset"] = {"token": token, "expires_at": expires_at} | ||
| self.save_user(user) | ||
|
|
||
| def clear_expired_password_reset(self, token, now): | ||
| result = self.users.update_one( | ||
| {"password_reset.token": token, "password_reset.expires_at": {"$lt": now}}, | ||
| {"$unset": {"password_reset": ""}}, | ||
| ) | ||
| if result.matched_count: | ||
| self.clear_cache() | ||
| return result | ||
|
|
||
| def update_password_with_reset_token(self, user_id, token, new_password): | ||
| result = self.users.update_one( | ||
| {"_id": user_id, "password_reset.token": token}, | ||
| {"$set": {"password": new_password}, "$unset": {"password_reset": ""}}, | ||
| ) | ||
|
Disservin marked this conversation as resolved.
|
||
| if result.modified_count: | ||
| self.clear_cache() | ||
| return result | ||
|
Comment on lines
+153
to
+160
|
||
|
|
||
| def mark_password_reset_opened(self, user_id, token, opened_at): | ||
| result = self.users.update_one( | ||
| { | ||
| "_id": user_id, | ||
| "password_reset.token": token, | ||
| "password_reset.opened_at": {"$exists": False}, | ||
| }, | ||
| {"$set": {"password_reset.opened_at": opened_at}}, | ||
| ) | ||
| if result.modified_count: | ||
| self.clear_cache() | ||
| return result | ||
|
|
||
| def set_password_reset_form_token(self, user_id, token, form_token, opened_at): | ||
| result = self.users.update_one( | ||
| { | ||
| "_id": user_id, | ||
| "password_reset.token": token, | ||
| "password_reset.form_token": {"$exists": False}, | ||
| }, | ||
| { | ||
| "$set": { | ||
| "password_reset.form_token": form_token, | ||
| "password_reset.opened_at": opened_at, | ||
| } | ||
| }, | ||
| ) | ||
| if result.modified_count: | ||
| self.clear_cache() | ||
| return result | ||
|
|
||
| def update_password_with_reset_form_token(self, user_id, form_token, new_password): | ||
| result = self.users.update_one( | ||
| {"_id": user_id, "password_reset.form_token": form_token}, | ||
| {"$set": {"password": new_password}, "$unset": {"password_reset": ""}}, | ||
| ) | ||
| if result.modified_count: | ||
| self.clear_cache() | ||
| return result | ||
|
|
||
| def remove_user(self, user, rejector): | ||
| result = self.users.delete_one({"_id": user["_id"]}) | ||
| if result.deleted_count > 0: | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.