resets passwords
This commit is contained in:
166
.claude/skills/filevine-api.md
Normal file
166
.claude/skills/filevine-api.md
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
# Filevine API Curl Skill
|
||||||
|
|
||||||
|
This skill enables intelligent exploration of the Filevine API by making curl requests and analyzing data patterns.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
1. **Discover relevant data within a project** - Search across all endpoints used in sync.py
|
||||||
|
2. **Make educated guesses** - Use patterns from sample data to infer where data might exist
|
||||||
|
3. **Query specific fields** - Search for fields like "matter number", "case number", dates, etc.
|
||||||
|
|
||||||
|
## API Endpoints Discovered
|
||||||
|
|
||||||
|
Based on analysis of `sync.py`, the following endpoints are used:
|
||||||
|
|
||||||
|
| Endpoint | Purpose | Related Code |
|
||||||
|
|----------|---------|--------------|
|
||||||
|
| `Projects/{id}` | Project details (name, URLs, dates, phase) | `fetch_project_detail()` |
|
||||||
|
| `Projects/{id}/team` | Team members and roles | `fetch_project_team()` |
|
||||||
|
| `Projects/{id}/tasks` | Project tasks | `fetch_project_tasks()` |
|
||||||
|
| `Projects/{id}/Forms/{form}` | Specific forms (datesAndDeadlines, newFileReview, etc.) | `fetch_form()` |
|
||||||
|
| `Projects/{id}/Contacts` | Project contacts | `fetch_contacts()` |
|
||||||
|
| `Contacts/{id}` | Client information | `fetch_client()` |
|
||||||
|
| `Projects/{id}/Collections/{collection}` | Collections (serviceInfo) | `fetch_collection()` |
|
||||||
|
|
||||||
|
## Sample Data Patterns
|
||||||
|
|
||||||
|
From examining `examples/*.json`, here are common patterns:
|
||||||
|
|
||||||
|
**Project-level fields (Projects/{id}):**
|
||||||
|
- `projectName` - Full project name
|
||||||
|
- `number` - Project number
|
||||||
|
- `incidentDate` - Incident date
|
||||||
|
- `projectEmailAddress` - Email address
|
||||||
|
- `projectUrl` - Link to Filevine
|
||||||
|
- `phaseName` - Current phase
|
||||||
|
- `createdDate` - Creation date
|
||||||
|
|
||||||
|
**DatesAndDeadlines form:**
|
||||||
|
- `caseNumber` - Case number
|
||||||
|
- `dateCaseFiled` - Case filed date
|
||||||
|
- `defaultDate` - Default date
|
||||||
|
- `trialDate` - Trial date
|
||||||
|
- `mSCDate` - MSC date
|
||||||
|
- `noticeExpirationDate` - Notice expiration
|
||||||
|
- `judgmentDate` - Judgment date
|
||||||
|
|
||||||
|
**NewFileReview form:**
|
||||||
|
- `noticeServiceDate` - When notice was served
|
||||||
|
- `noticeExpirationDate` - Notice expiration
|
||||||
|
- `noticeType` - Type of notice (NP, PPQ, etc.)
|
||||||
|
- `amountDemandedInNotice` - Amount demanded
|
||||||
|
|
||||||
|
**ComplaintInfo form:**
|
||||||
|
- `totalDamagesSought` - Total damages
|
||||||
|
- `complaintVerificationBy` - Verification method
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
When the user asks about finding data, follow this workflow:
|
||||||
|
|
||||||
|
### 1. Parse the Query
|
||||||
|
|
||||||
|
Identify what the user wants to find:
|
||||||
|
- Field name (e.g., "matter number", "case number", "trial date")
|
||||||
|
- Context (e.g., "in a project", "in the datesAndDeadlines form")
|
||||||
|
|
||||||
|
### 2. Generate Endpoint Candidates
|
||||||
|
|
||||||
|
Based on the query, suggest multiple endpoints to check:
|
||||||
|
|
||||||
|
**For case/project identifiers:**
|
||||||
|
- `Projects/{id}` (check `number`, `caseNumber` fields)
|
||||||
|
- `Projects/{id}/Forms/datesAndDeadlines` (check `caseNumber` field)
|
||||||
|
- `Projects/{id}/Forms/matterOverview` (check `matterNumber` field)
|
||||||
|
|
||||||
|
**For dates:**
|
||||||
|
- `Projects/{id}` (check `incidentDate`, `createdDate`)
|
||||||
|
- `Projects/{id}/Forms/datesAndDeadlines` (check `trialDate`, `mSCDate`, `defaultDate`, etc.)
|
||||||
|
- `Projects/{id}/Forms/newFileReview` (check `noticeServiceDate`)
|
||||||
|
|
||||||
|
**For contact/defendant info:**
|
||||||
|
- `Projects/{id}/Contacts` (check contact list for defendant)
|
||||||
|
- `Contacts/{client_id}` (check full contact info)
|
||||||
|
|
||||||
|
**For attorney/team:**
|
||||||
|
- `Projects/{id}/team` (check `teamOrgRoles` for "Assigned Attorney", "Primary", etc.)
|
||||||
|
|
||||||
|
**For notices:**
|
||||||
|
- `Projects/{id}/Forms/newFileReview` (check `noticeType`, `noticeExpirationDate`, etc.)
|
||||||
|
- `Projects/{id}/Collections/serviceInfo` (check service dates)
|
||||||
|
|
||||||
|
### 3. Execute curl Commands
|
||||||
|
|
||||||
|
For each candidate endpoint, construct and execute curl commands using the auth headers from the codebase:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X GET "https://api.filevineapp.com/fv-app/v2/Projects/{PROJECT_ID}/{ENDPOINT}" \
|
||||||
|
-H "Accept: application/json" \
|
||||||
|
-H "Authorization: Bearer {BEARER_TOKEN}" \
|
||||||
|
-H "x-fv-orgid: {ORG_ID}" \
|
||||||
|
-H "x-fv-userid: {USER_ID}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the project ID from sample data (e.g., `15974631` or `15914808`) as a placeholder.
|
||||||
|
|
||||||
|
### 4. Analyze Results
|
||||||
|
|
||||||
|
Look for the queried field in the JSON response:
|
||||||
|
- Search field names (not values)
|
||||||
|
- Check nested objects and arrays
|
||||||
|
- Note which endpoints actually contain the data
|
||||||
|
|
||||||
|
### 5. Report Findings
|
||||||
|
|
||||||
|
Provide:
|
||||||
|
1. The field value(s) found
|
||||||
|
2. The endpoint(s) that contain it
|
||||||
|
3. Any related fields discovered in the same endpoint
|
||||||
|
4. Context about how this data is used in the application
|
||||||
|
|
||||||
|
## Common Patterns to Guess From
|
||||||
|
|
||||||
|
When the user asks "What endpoint should I hit to find [field]?", consider:
|
||||||
|
|
||||||
|
**Field Mapping:**
|
||||||
|
| Common Field Names | Likely Endpoints |
|
||||||
|
|-------------------|------------------|
|
||||||
|
| `matterNumber`, `matter number` | `Projects/{id}/Forms/matterOverview` |
|
||||||
|
| `caseNumber`, `case number` | `Projects/{id}/Forms/datesAndDeadlines` |
|
||||||
|
| `incident date`, `incidentDate` | `Projects/{id}`, `Projects/{id}/Forms/datesAndDeadlines` |
|
||||||
|
| `trial date`, `trialDate` | `Projects/{id}/Forms/datesAndDeadlines` |
|
||||||
|
| `notice service date`, `noticeServiceDate` | `Projects/{id}/Forms/newFileReview` |
|
||||||
|
| `notice expiration date`, `noticeExpirationDate` | `Projects/{id}/Forms/newFileReview` |
|
||||||
|
| `notice type` | `Projects/{id}/Forms/newFileReview` |
|
||||||
|
| `assigned attorney` | `Projects/{id}/team` (role: "Assigned Attorney") |
|
||||||
|
| `primary contact`, `staff person` | `Projects/{id}/team` (role: "Primary") |
|
||||||
|
| `defendant` | `Projects/{id}/Contacts` (filter for "Defendant" role) |
|
||||||
|
| `client` | `Contacts/{client_id}` or `Projects/{id}` (clientId field) |
|
||||||
|
|
||||||
|
## Environment Setup
|
||||||
|
|
||||||
|
When executing curl commands, use these placeholder values (user should provide actual values):
|
||||||
|
|
||||||
|
- `PROJECT_ID` - Use sample IDs like 15974631 or 15914808
|
||||||
|
- `ORG_ID` - From FILEVINE_ORG_ID env var
|
||||||
|
- `USER_ID` - From FILEVINE_USER_ID env var
|
||||||
|
- `BEARER_TOKEN` - From FilevineClient.get_bearer_token()
|
||||||
|
|
||||||
|
## Tips
|
||||||
|
|
||||||
|
1. Start with the most likely endpoints first
|
||||||
|
2. The sample data shows that many fields are duplicated across endpoints - check multiple places
|
||||||
|
3. Date fields are often in ISO format in API responses
|
||||||
|
4. Some forms (like `newFileReview`) contain multiple related date fields
|
||||||
|
5. Team roles use specific names - "Assigned Attorney", "Primary", "Secondary Paralegal"
|
||||||
|
6. Contact objects in arrays may have `personTypes` to identify their role
|
||||||
|
|
||||||
|
## Example Workflow
|
||||||
|
|
||||||
|
User: "What endpoint should I hit to find the matter number?"
|
||||||
|
|
||||||
|
1. Identify: User wants "matter number" field
|
||||||
|
2. Guess: Matter number might be in `Projects/{id}/Forms/matterOverview` based on sample data patterns
|
||||||
|
3. Check: `Projects/{id}` also has `number` field
|
||||||
|
4. Execute: curl to both endpoints with project 15974631
|
||||||
|
5. Report: Found `number` in project root and `matterNumber` in matterOverview form
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ __pycache__/**
|
|||||||
|
|
||||||
|
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
|
node_modules/**
|
||||||
|
|||||||
65
admin.py
65
admin.py
@@ -1,7 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
|
import string
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from flask import render_template, request, redirect, url_for, session, abort, jsonify
|
from flask import render_template, request, redirect, url_for, session, abort, jsonify, flash
|
||||||
from firebase_init import db
|
from firebase_init import db
|
||||||
from firebase_admin import auth as fb_auth
|
from firebase_admin import auth as fb_auth
|
||||||
from utils import get_user_profile
|
from utils import get_user_profile
|
||||||
@@ -47,7 +49,8 @@ def register_admin_routes(app):
|
|||||||
"case_email": user_data.get("case_email", ""),
|
"case_email": user_data.get("case_email", ""),
|
||||||
"case_domain_email": user_data.get("case_domain_email", ""),
|
"case_domain_email": user_data.get("case_domain_email", ""),
|
||||||
"enabled": bool(user_data.get("enabled", False)),
|
"enabled": bool(user_data.get("enabled", False)),
|
||||||
"is_admin": bool(user_data.get("is_admin", False))
|
"is_admin": bool(user_data.get("is_admin", False)),
|
||||||
|
"password_reset_required": bool(user_data.get("password_reset_required", False))
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -94,17 +97,23 @@ def register_admin_routes(app):
|
|||||||
# Get the user from Firebase Auth
|
# Get the user from Firebase Auth
|
||||||
user = fb_auth.get_user(uid)
|
user = fb_auth.get_user(uid)
|
||||||
|
|
||||||
# Generate password reset link using Firebase Auth
|
|
||||||
password_reset_link = fb_auth.generate_password_reset_link(user.email)
|
|
||||||
|
|
||||||
# Send password reset email using Firebase's built-in template
|
# Generate temporary password (random word + 3 digits)
|
||||||
# This will send an email to the user with a link to reset their password
|
words = ["sun", "moon", "star", "cloud", "rain", "wind", "fire", "water", "snow", "stone",
|
||||||
# Firebase automatically handles the email template and delivery
|
"tree", "leaf", "flower", "bird", "wolf", "tiger", "bear", "fish", "dragon",
|
||||||
print(f"[INFO] Password reset link generated for {user.email}: {password_reset_link}")
|
"magic", "quest", "light", "dark", "gold", "silver", "ruby", "pearl", "diamond"]
|
||||||
|
random_word = random.choice(words)
|
||||||
|
random_digits = ''.join(random.choices(string.digits, k=3))
|
||||||
|
temp_password = f"{random_word}{random_digits}"
|
||||||
|
|
||||||
|
# Create user profile in Firestore with password reset required flag
|
||||||
|
user_ref = db.collection("users").document(user.uid)
|
||||||
|
user_ref.set({
|
||||||
|
"password_reset_required": True
|
||||||
|
},merge=True)
|
||||||
|
|
||||||
# Store the password reset link in the session for display in the banner
|
flash(f"User now has a temporary password {temp_password}.", "success")
|
||||||
session['password_reset_link'] = password_reset_link
|
fb_auth.update_user(uid, password=temp_password)
|
||||||
session['reset_user_email'] = user.email
|
|
||||||
|
|
||||||
# Redirect back to the admin users table
|
# Redirect back to the admin users table
|
||||||
return redirect(url_for('admin_users'))
|
return redirect(url_for('admin_users'))
|
||||||
@@ -152,37 +161,51 @@ def register_admin_routes(app):
|
|||||||
@app.route("/admin/users/create", methods=["POST"])
|
@app.route("/admin/users/create", methods=["POST"])
|
||||||
@admin_required
|
@admin_required
|
||||||
def create_user():
|
def create_user():
|
||||||
"""Create a new user"""
|
"""Create a new user with temporary password"""
|
||||||
try:
|
try:
|
||||||
# Get form data
|
# Get form data
|
||||||
user_email = request.form.get("user_email")
|
user_email = request.form.get("user_email")
|
||||||
if not user_email:
|
if not user_email:
|
||||||
abort(400, "User email is required")
|
abort(400, "User email is required")
|
||||||
|
|
||||||
# Validate email format
|
# Validate email format
|
||||||
if "@" not in user_email:
|
if "@" not in user_email:
|
||||||
abort(400, "Invalid email format")
|
abort(400, "Invalid email format")
|
||||||
|
|
||||||
# Create user in Firebase Authentication
|
# Generate temporary password (random word + 3 digits)
|
||||||
|
words = ["sun", "moon", "star", "cloud", "rain", "wind", "fire", "water", "snow", "stone",
|
||||||
|
"tree", "leaf", "flower", "bird", "wolf", "tiger", "bear", "fish", "dragon",
|
||||||
|
"magic", "quest", "light", "dark", "gold", "silver", "ruby", "pearl", "diamond"]
|
||||||
|
random_word = random.choice(words)
|
||||||
|
random_digits = ''.join(random.choices(string.digits, k=3))
|
||||||
|
temp_password = f"{random_word}{random_digits}"
|
||||||
|
|
||||||
|
# Create user in Firebase Authentication with temporary password
|
||||||
user_record = fb_auth.create_user(
|
user_record = fb_auth.create_user(
|
||||||
email=user_email,
|
email=user_email,
|
||||||
email_verified=False,
|
email_verified=False,
|
||||||
disabled=not request.form.get("enabled", False)
|
disabled=not request.form.get("enabled", False),
|
||||||
|
password=temp_password
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create user profile in Firestore
|
# Create user profile in Firestore with password reset required flag
|
||||||
user_ref = db.collection("users").document(user_record.uid)
|
user_ref = db.collection("users").document(user_record.uid)
|
||||||
user_ref.set({
|
user_ref.set({
|
||||||
"user_email": user_email,
|
"user_email": user_email,
|
||||||
"case_email": request.form.get("case_email", ""),
|
"case_email": request.form.get("case_email", ""),
|
||||||
"case_domain_email": request.form.get("case_domain_email", ""),
|
"case_domain_email": request.form.get("case_domain_email", ""),
|
||||||
"enabled": bool(request.form.get("enabled", False)),
|
"enabled": bool(request.form.get("enabled", False)),
|
||||||
"is_admin": bool(request.form.get("is_admin", False))
|
"is_admin": bool(request.form.get("is_admin", False)),
|
||||||
|
"password_reset_required": True
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Display success message with temporary password
|
||||||
|
flash(f"User created successfully. Temporary password: {temp_password}", "success")
|
||||||
|
print(f"[INFO] Created user {user_email} with temp password: {temp_password}")
|
||||||
|
|
||||||
# Redirect to admin users page
|
# Redirect to admin users page
|
||||||
return redirect(url_for("admin_users"))
|
return redirect(url_for("admin_users"))
|
||||||
|
|
||||||
except fb_auth.EmailAlreadyExistsError:
|
except fb_auth.EmailAlreadyExistsError:
|
||||||
print(f"[ERR] User with email {user_email} already exists")
|
print(f"[ERR] User with email {user_email} already exists")
|
||||||
abort(400, "A user with this email already exists")
|
abort(400, "A user with this email already exists")
|
||||||
|
|||||||
51
app.py
51
app.py
@@ -170,6 +170,11 @@ def session_login():
|
|||||||
# Optional: short session
|
# Optional: short session
|
||||||
session["expires_at"] = (datetime.utcnow() + timedelta(hours=8)).isoformat()
|
session["expires_at"] = (datetime.utcnow() + timedelta(hours=8)).isoformat()
|
||||||
|
|
||||||
|
# Check if user needs password reset
|
||||||
|
user_profile = get_user_profile(uid)
|
||||||
|
if user_profile.get("password_reset_required"):
|
||||||
|
return jsonify({"requires_password_reset": True})
|
||||||
|
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print("[ERR] session_login:", e)
|
print("[ERR] session_login:", e)
|
||||||
@@ -182,6 +187,52 @@ def logout():
|
|||||||
return redirect(url_for("login"))
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/require-password-reset")
|
||||||
|
@login_required
|
||||||
|
def require_password_reset():
|
||||||
|
"""Show password reset page for users who need to reset their password"""
|
||||||
|
return render_template("require_password_reset.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/reset-password-submit", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def reset_password_submit():
|
||||||
|
"""Handle password reset form submission"""
|
||||||
|
uid = session.get("uid")
|
||||||
|
profile = get_user_profile(uid)
|
||||||
|
|
||||||
|
new_password = request.form.get("new_password")
|
||||||
|
confirm_password = request.form.get("confirm_password")
|
||||||
|
|
||||||
|
# Validate passwords match
|
||||||
|
if new_password != confirm_password:
|
||||||
|
flash("Passwords do not match", "error")
|
||||||
|
return redirect(url_for("require_password_reset"))
|
||||||
|
|
||||||
|
# Validate password length
|
||||||
|
if len(new_password) < 6:
|
||||||
|
flash("Password must be at least 6 characters", "error")
|
||||||
|
return redirect(url_for("require_password_reset"))
|
||||||
|
|
||||||
|
# Update password in Firebase Auth
|
||||||
|
try:
|
||||||
|
fb_auth.update_user(uid, password=new_password)
|
||||||
|
print(db.collection("users").document(uid))
|
||||||
|
|
||||||
|
# Clear the password reset required flag in Firestore
|
||||||
|
db.collection("users").document(uid).set({"password_reset_required": False},merge=True)
|
||||||
|
print(db.collection("users").document(uid))
|
||||||
|
|
||||||
|
print(f"[INFO] Password reset successful for user {uid}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERR] Failed to reset password for {uid}: {e}")
|
||||||
|
flash("Failed to reset password. Please try again.", "error")
|
||||||
|
return redirect(url_for("require_password_reset"))
|
||||||
|
|
||||||
|
# Allow user to login now
|
||||||
|
return redirect(url_for("login"))
|
||||||
|
|
||||||
|
|
||||||
@app.route("/welcome")
|
@app.route("/welcome")
|
||||||
@login_required
|
@login_required
|
||||||
def welcome():
|
def welcome():
|
||||||
|
|||||||
71
package-lock.json
generated
Normal file
71
package-lock.json
generated
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "rothbard",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"@playwright/test": "1.58.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.58.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.58.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.58.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
|
||||||
|
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
package.json
Normal file
5
package.json
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"@playwright/test": "1.58.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="h-full flex flex-col">
|
<div class="h-full flex flex-col">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="bg-{% if category == 'success' %}green{% else %}blue{% endif %}-50 border border-{% if category == 'success' %}green{% else %}blue{% endif %}-200 text-{% if category == 'success' %}green{% else %}blue{% endif %}-800 px-4 py-3 rounded-md mb-4">
|
||||||
|
<p>{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
{% if session.get('password_reset_link') %}
|
{% if session.get('password_reset_link') %}
|
||||||
<div class="bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-md mb-4">
|
<div class="bg-blue-50 border border-blue-200 text-blue-800 px-4 py-3 rounded-md mb-4">
|
||||||
<p class="font-medium">Please send an email to {{ session.get('reset_user_email') }}</p>
|
<p class="font-medium">Please send an email to {{ session.get('reset_user_email') }}</p>
|
||||||
|
|||||||
@@ -31,18 +31,14 @@
|
|||||||
<script>
|
<script>
|
||||||
// Initialize Firebase configuration from template
|
// Initialize Firebase configuration from template
|
||||||
window.FIREBASE_CONFIG = {{ firebase_config|tojson }};
|
window.FIREBASE_CONFIG = {{ firebase_config|tojson }};
|
||||||
|
|
||||||
// Initialize Firebase app and auth
|
|
||||||
const app = firebase.initializeApp(window.FIREBASE_CONFIG || {});
|
const app = firebase.initializeApp(window.FIREBASE_CONFIG || {});
|
||||||
const auth = firebase.auth();
|
const auth = firebase.auth();
|
||||||
|
|
||||||
// Get form and input elements
|
|
||||||
const form = document.getElementById('login-form');
|
const form = document.getElementById('login-form');
|
||||||
const email = document.getElementById('email');
|
const email = document.getElementById('email');
|
||||||
const password = document.getElementById('password');
|
const password = document.getElementById('password');
|
||||||
const err = document.getElementById('error');
|
const err = document.getElementById('error');
|
||||||
|
|
||||||
// Handle form submission
|
|
||||||
form.addEventListener('submit', async (e) => {
|
form.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
err.classList.add('hidden');
|
err.classList.add('hidden');
|
||||||
@@ -54,10 +50,17 @@
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ idToken })
|
body: JSON.stringify({ idToken })
|
||||||
});
|
});
|
||||||
if(!res.ok){
|
|
||||||
|
if (!res.ok) {
|
||||||
throw new Error('Session exchange failed');
|
throw new Error('Session exchange failed');
|
||||||
}
|
}
|
||||||
window.location.href = '/';
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.requires_password_reset) {
|
||||||
|
window.location.href = '/require-password-reset';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
err.textContent = e.message || 'Authentication failed';
|
err.textContent = e.message || 'Authentication failed';
|
||||||
err.classList.remove('hidden');
|
err.classList.remove('hidden');
|
||||||
|
|||||||
47
templates/require_password_reset.html
Normal file
47
templates/require_password_reset.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends 'base.html' %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="h-full flex flex-col max-w-md mx-auto">
|
||||||
|
<h1 class="text-xl font-semibold mb-6">Password Reset Required</h1>
|
||||||
|
|
||||||
|
<form method="POST" action="/reset-password-submit" class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label for="new_password" class="block text-sm font-medium text-slate-700">New Password</label>
|
||||||
|
<input type="password" id="new_password" name="new_password"
|
||||||
|
value=""
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
minlength="6">
|
||||||
|
<p class="mt-1 text-sm text-slate-500">Password must be at least 6 characters</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="confirm_password" class="block text-sm font-medium text-slate-700">Confirm Password</label>
|
||||||
|
<input type="password" id="confirm_password" name="confirm_password"
|
||||||
|
value=""
|
||||||
|
required
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-md">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
|
<a href="/login"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-slate-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors text-center inline-block">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors">
|
||||||
|
Reset Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
13
utils.py
13
utils.py
@@ -3,7 +3,7 @@ from firebase_init import db
|
|||||||
from firebase_admin import auth as fb_auth
|
from firebase_admin import auth as fb_auth
|
||||||
|
|
||||||
def get_user_profile(uid: str):
|
def get_user_profile(uid: str):
|
||||||
"""Fetch user's Firestore profile: users/{uid} => { enabled, case_email, is_admin, user_email }"""
|
"""Fetch user's Firestore profile: users/{uid} => { enabled, case_email, is_admin, user_email, password_reset_required }"""
|
||||||
doc_ref = db.collection("users").document(uid)
|
doc_ref = db.collection("users").document(uid)
|
||||||
snap = doc_ref.get()
|
snap = doc_ref.get()
|
||||||
if not snap.exists:
|
if not snap.exists:
|
||||||
@@ -14,20 +14,22 @@ def get_user_profile(uid: str):
|
|||||||
user_email = user.email
|
user_email = user.email
|
||||||
except Exception:
|
except Exception:
|
||||||
user_email = None
|
user_email = None
|
||||||
|
|
||||||
doc_ref.set({
|
doc_ref.set({
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"is_admin": False,
|
"is_admin": False,
|
||||||
"user_email": user_email,
|
"user_email": user_email,
|
||||||
"case_email": user_email,
|
"case_email": user_email,
|
||||||
"case_domain_email": ""
|
"case_domain_email": "",
|
||||||
|
"password_reset_required": True
|
||||||
}, merge=True)
|
}, merge=True)
|
||||||
return {
|
return {
|
||||||
"enabled": False,
|
"enabled": False,
|
||||||
"is_admin": False,
|
"is_admin": False,
|
||||||
"user_email": user_email,
|
"user_email": user_email,
|
||||||
"case_email": user_email,
|
"case_email": user_email,
|
||||||
"case_domain_email": ""
|
"case_domain_email": "",
|
||||||
|
"password_reset_required": True
|
||||||
}
|
}
|
||||||
data = snap.to_dict() or {}
|
data = snap.to_dict() or {}
|
||||||
return {
|
return {
|
||||||
@@ -35,5 +37,6 @@ def get_user_profile(uid: str):
|
|||||||
"is_admin": bool(data.get("is_admin", False)),
|
"is_admin": bool(data.get("is_admin", False)),
|
||||||
"user_email": data.get("user_email"),
|
"user_email": data.get("user_email"),
|
||||||
"case_email": data.get("case_email"),
|
"case_email": data.get("case_email"),
|
||||||
"case_domain_email": data.get("case_domain_email", "")
|
"case_domain_email": data.get("case_domain_email", ""),
|
||||||
|
"password_reset_required": bool(data.get("password_reset_required", False))
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user