Compare commits

..

10 Commits

Author SHA1 Message Date
dc81c8e2a7 fixes 2026-04-01 13:51:25 -07:00
c3263a0eaf undid 2026-02-19 20:29:51 -08:00
607e65560c resets passwords 2026-01-29 20:51:14 -08:00
86a09225e7 fixes 2026-01-28 13:10:56 -08:00
dc6c24ca6d fixes 2026-01-27 22:06:18 -08:00
6cde9ab75f progress 2026-01-03 21:28:34 -08:00
a1b836b392 fixes export 2025-12-17 16:14:02 -08:00
234578b646 supports lookup by domains 2025-12-09 22:01:06 -08:00
c3108ff68c Improvement 2025-12-05 00:10:08 -08:00
c3e943f135 final tweaks. 2025-12-04 10:55:23 -08:00
21 changed files with 1234 additions and 137 deletions

View 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

5
.env
View File

@@ -9,8 +9,9 @@ GOOGLE_APPLICATION_CREDENTIALS=./rothbard-staging2-12345-firebase-adminsdk-fbsvc
# Filevine auth
FILEVINE_CLIENT_ID=4F18738C-107A-4B82-BFAC-308F1B6A626A
FILEVINE_CLIENT_SECRET=*2{aXWvYN(9!BiYUXC_tXj^n8
FILEVINE_PERSONAL_ACCESS_TOKEN=C8F5C606B834D4EE0CBF0793969496F6210037EB934523756BA80BF2D8EC1880
FILEVINE_CLIENT_SECRET=q<}QzfD^3t_atF-7+U8(gJCgj
FILEVINE_PERSONAL_ACCESS_TOKEN=68BEBFB8437B9668642BD71EDEAF09593ACF075CE35AE4079FC7588410094210
FILEVINE_ORG_ID=9227
FILEVINE_USER_ID=100510

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ __pycache__/**
**/*.pyc
node_modules/**

139
admin.py
View File

@@ -1,7 +1,9 @@
import json
import os
import random
import string
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_admin import auth as fb_auth
from utils import get_user_profile
@@ -45,8 +47,10 @@ def register_admin_routes(app):
"uid": doc.id,
"user_email": user_data.get("user_email", ""),
"case_email": user_data.get("case_email", ""),
"case_domain_email": user_data.get("case_domain_email", ""),
"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:
@@ -78,6 +82,7 @@ def register_admin_routes(app):
"uid": uid,
"user_email": user_data.get("user_email", ""),
"case_email": user_data.get("case_email", ""),
"case_domain_email": user_data.get("case_domain_email", ""),
"enabled": bool(user_data.get("enabled", False)),
"is_admin": bool(user_data.get("is_admin", False))
}
@@ -92,17 +97,23 @@ def register_admin_routes(app):
# Get the user from Firebase Auth
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
# This will send an email to the user with a link to reset their password
# Firebase automatically handles the email template and delivery
print(f"[INFO] Password reset link generated for {user.email}: {password_reset_link}")
# 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}"
# Store the password reset link in the session for display in the banner
session['password_reset_link'] = password_reset_link
session['reset_user_email'] = user.email
# 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)
flash(f"User now has a temporary password {temp_password}.", "success")
fb_auth.update_user(uid, password=temp_password)
# Redirect back to the admin users table
return redirect(url_for('admin_users'))
@@ -126,11 +137,14 @@ def register_admin_routes(app):
# Update user in Firestore
user_ref = db.collection("users").document(target_uid)
user_ref.update({
# Only update fields that can be changed, excluding is_admin
update_data = {
"enabled": data.get("enabled", False),
"is_admin": data.get("is_admin", False),
"case_email": data.get("case_email", "")
})
"case_email": data.get("case_email", ""),
"case_domain_email": data.get("case_domain_email", "")
}
# Never allow changing is_admin field during updates - admin status can only be set during creation
user_ref.update(update_data)
return jsonify({"success": True})
@@ -147,7 +161,7 @@ def register_admin_routes(app):
@app.route("/admin/users/create", methods=["POST"])
@admin_required
def create_user():
"""Create a new user"""
"""Create a new user with temporary password"""
try:
# Get form data
user_email = request.form.get("user_email")
@@ -158,22 +172,37 @@ def register_admin_routes(app):
if "@" not in user_email:
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(
email=user_email,
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.set({
"user_email": user_email,
"case_email": request.form.get("case_email", ""),
"case_domain_email": request.form.get("case_domain_email", ""),
"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
return redirect(url_for("admin_users"))
@@ -183,3 +212,73 @@ def register_admin_routes(app):
except Exception as e:
print(f"[ERR] Failed to create user: {e}")
abort(500, "Failed to create user")
@app.route("/admin/users/<uid>/become-user", methods=["POST"])
@admin_required
def become_user(uid):
"""Allow admin to impersonate another user by replacing session UID"""
try:
# Verify the target user exists
target_user_doc = db.collection("users").document(uid).get()
if not target_user_doc.exists:
abort(404, "User not found")
# Store original admin UID and set impersonation flags
session['original_uid'] = session.get('uid')
session['impersonating'] = True
# Replace session UID with target user's UID
session['uid'] = uid
print(f"[INFO] Admin {session['original_uid']} is now impersonating user {uid}")
# Redirect to dashboard
return redirect(url_for('dashboard'))
except Exception as e:
print(f"[ERR] Failed to become user {uid}: {e}")
abort(500, "Failed to impersonate user")
@app.route("/admin/users/revert")
def revert_user():
"""Revert back to the original admin session.
Only accessible if the original session owner (before impersonation) was an admin.
This check must happen before @admin_required decorator to verify the original UID,
not the currently impersonated user's UID.
"""
try:
# Check if user is currently impersonating
if 'original_uid' not in session:
# Not impersonating - check if current user is admin
uid = session.get('uid')
if not uid:
return redirect(url_for('login'))
profile = get_user_profile(uid)
if profile.get('is_admin'):
return redirect(url_for('admin_users'))
# Not an admin, deny access
abort(403, "Access denied. Admin privileges required.")
# Verify the original session owner was an admin (not the impersonated user)
original_uid = session.get('original_uid')
if not original_uid:
abort(403, "Access denied. Invalid session state.")
original_profile = get_user_profile(original_uid)
if not original_profile.get('is_admin'):
abort(403, "Access denied. Only admins can revert from impersonation.")
# Restore original admin UID
session['uid'] = original_uid
session.pop('impersonating', None)
session.pop('original_uid', None)
print(f"[INFO] Reverted from impersonation back to admin")
# Redirect to admin users page
return redirect(url_for('admin_users'))
except Exception as e:
print(f"[ERR] Failed to revert user: {e}")
abort(500, "Failed to revert session")

216
app.py
View File

@@ -29,6 +29,82 @@ def login_required(view):
return view(*args, **kwargs)
return wrapped
def projects_for(profile, case_email_match, per_page, offset):
"""
Filter projects based on user profile and case_email query string argument.
Args:
profile (dict): User profile containing 'enabled', 'is_admin', 'case_email', and 'case_domain_email' fields
case_email_match (str): Case email from query string argument, or None
Returns:
list: List of project dictionaries that match the filtering criteria
"""
is_admin = profile.get("is_admin", False)
if not profile.get("enabled"):
return ([], 0)
# Query Firestore for projects where case_email is in viewing_emails array
try:
cnt = 0
if is_admin:
if case_email_match:
case_email_match_lower = case_email_match.lower().strip()
# Check if case_email_match is a valid email address (contains @)
if '@' in case_email_match_lower and not case_email_match_lower.startswith('@'):
# If it's a complete email address, filter by exact match in viewing_emails
projects_ref = db.collection("projects").where("viewing_emails", "array_contains", case_email_match_lower)
cnt = int(projects_ref.count().get()[0][0].value)
projects = []
for doc in projects_ref.order_by("matter_description").limit(per_page).offset(offset).stream():
projects.append(doc.to_dict())
return (projects, cnt)
else:
# If no @ sign, treat as domain search
# Also handle cases like "@gmail.com" by extracting the domain
domain_search = case_email_match_lower
if domain_search.startswith('@'):
domain_search = domain_search[1:] # Remove the @ sign
# Filter by domain match in viewing_emails
projects_ref = db.collection("projects").where("viewing_domains", "array_contains", domain_search)
print("HERE domain", domain_search)
cnt = int(projects_ref.count().get()[0][0].value)
projects = []
for doc in projects_ref.order_by("matter_description").limit(per_page).offset(offset).stream():
projects.append(doc.to_dict())
return (projects, cnt)
else:
projects_ref = db.collection("projects")
else:
# For non-admin users, check if they have domain email or specific case email
case_domain_email = profile.get("case_domain_email", "")
case_email = profile.get("case_email", "")
if case_domain_email:
# Use exact match on viewing_domains field
domain_lower = case_domain_email.lower()
projects_ref = db.collection("projects").where("viewing_domains", "array_contains", domain_lower)
elif case_email:
# Use the original logic for specific case email match
projects_ref = db.collection("projects").where("viewing_emails", "array_contains", case_email.lower())
else:
return ([], 0)
cnt = int(projects_ref.count().get()[0][0].value)
projects = []
for doc in projects_ref.order_by("matter_description").limit(per_page).offset(offset).stream():
projects.append(doc.to_dict())
return (projects, cnt)
except Exception as e:
print(f"[ERROR] Failed to query projects: {e}")
return ([], 0)
@app.context_processor
@@ -63,7 +139,7 @@ def index():
if not uid:
return redirect(url_for("login"))
profile = get_user_profile(uid)
if profile.get("enabled") and profile.get("case_email"):
if profile.get("enabled") and (profile.get("case_email") or profile.get("case_domain_email")):
return redirect(url_for("dashboard"))
return redirect(url_for("welcome"))
@@ -94,6 +170,12 @@ def session_login():
# Optional: short session
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})
print(f"logged in as {uid} - user_profile.email")
return jsonify({"ok": True})
except Exception as e:
print("[ERR] session_login:", e)
@@ -106,6 +188,52 @@ def logout():
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")
@login_required
def welcome():
@@ -127,74 +255,34 @@ def dashboard(page=1):
return redirect(url_for("welcome"))
is_admin = profile.get("is_admin")
case_email = None
if not is_admin:
case_email = profile.get("case_email")
if not case_email:
return redirect(url_for("welcome"))
if is_admin and request.args.get('case_email'):
case_email = request.args.get('case_email').lower()
# Validate email format
if '@' not in case_email:
return abort(400, "Invalid email format")
# Pagination settings
per_page = int(request.args.get('per_page', 25))
offset = (page - 1) * per_page
query = None
# Get total count efficiently using a count aggregation query
try:
# Firestore doesn't have a direct count() method, so we need to count documents
import time
start_time = time.time()
projects_ref = db.collection("projects")
# Filter projects where case_email is in viewing_emails array
if case_email:
query = projects_ref.where("viewing_emails", "array_contains", case_email.lower())
else:
query = projects_ref
total_projects = int(query.count().get()[0][0].value)
end_time = time.time()
print(f"Filtered projects count: {total_projects} (took {end_time - start_time:.2f}s)")
except Exception as e:
print(f"[WARN] Failed to get filtered count: {e}")
total_projects = 0
case_email_match = None
if is_admin and request.args.get('case_email'):
case_email_match = request.args.get('case_email')
if not is_admin and (not profile.get('case_email') and not profile.get('case_domain_email')):
return redirect(url_for("welcome"))
paginated_rows, total_projects = projects_for(profile, case_email_match, per_page, offset)
# Calculate pagination
total_pages = (total_projects + per_page - 1) // per_page # Ceiling division
# Read only the current page from Firestore using limit() and offset()
import time
start_time = time.time()
# Filter projects where case_email is in viewing_emails array
if case_email:
projects_ref = db.collection("projects").where("viewing_emails", "array_contains", case_email.lower()).order_by("matter_description").limit(per_page).offset(offset)
else:
projects_ref = db.collection("projects").order_by("matter_description").limit(per_page).offset(offset)
docs = projects_ref.stream()
paginated_rows = []
for doc in docs:
paginated_rows.append(doc.to_dict())
end_time = time.time()
print(f"Retrieved {len(paginated_rows)} projects from Firestore (page {page} of {total_pages}) in {end_time - start_time:.2f}s")
from pprint import pprint
pprint([p['property_contacts'] for p in paginated_rows if p['property_contacts'].get('propertyManager1', None)])
pprint([p['ProjectId'] for p in paginated_rows ])
print(f"Retrieved {len(paginated_rows)} projects from Firestore")
# Render table with pagination data
return render_template("dashboard.html",
rows=paginated_rows,
case_email=case_email,
case_email=case_email_match,
current_page=page,
total_pages=total_pages,
total_projects=total_projects,
per_page=per_page)
@app.route("/dashboard/export_xls")
@login_required
def dashboard_export_xls():
@@ -206,33 +294,15 @@ def dashboard_export_xls():
is_admin = profile.get("is_admin")
case_email = None
if not is_admin:
case_email = profile.get("case_email")
if not case_email:
if not is_admin and (not profile.get('case_email') and not profile.get('case_domain_email')):
return redirect(url_for("welcome"))
if is_admin and request.args.get('case_email'):
case_email = request.args.get('case_email').lower()
# Validate email format
if '@' not in case_email:
return abort(400, "Invalid email format")
# Get all projects without pagination
try:
projects_ref = db.collection("projects")
all_rows, cnt = projects_for(profile, case_email, 10000, 0)
# Filter projects where case_email is in viewing_emails array
if case_email:
projects_ref = projects_ref.where("viewing_emails", "array_contains", case_email.lower())
# Order by matter_description to maintain consistent ordering
projects_ref = projects_ref.order_by("matter_description")
docs = projects_ref.stream()
all_rows = []
for doc in docs:
all_rows.append(doc.to_dict())
print(f"Retrieved {len(all_rows)} projects from Firestore for XLS export")
print(f"Retrieved {cnt} projects from Firestore for XLS export")
# Create workbook and worksheet
wb = Workbook()
@@ -362,7 +432,7 @@ def dashboard_export_xls():
elif header == 'Notice Expir. Date':
field_name = 'notice_expiration_date'
elif header == 'Date Case Filed':
field_name = 'case_field_date'
field_name = 'case_filed_date'
elif header == 'Daily Rent Damages':
field_name = 'daily_rent_damages'
elif header == 'Default Date':

View File

@@ -11,14 +11,15 @@ FV_USER_ID = os.environ.get("FILEVINE_USER_ID")
class FilevineClient:
def __init__(self, bearer_token: str = None):
self.bearer_token = bearer_token
self.base_url = "https://api.filevineapp.com/fv-app/v2"
self.bearer_token = bearer_token
self.headers = {
"Accept": "application/json",
"Authorization": f"Bearer {self.bearer_token}",
"x-fv-orgid": str(FV_ORG_ID),
"x-fv-userid": str(FV_USER_ID),
}
self.get_bearer_token()
def get_bearer_token(self) -> str:
"""Get a new bearer token using Filevine credentials"""
@@ -32,6 +33,7 @@ class FilevineClient:
}
headers = {"Accept": "application/json"}
print("data is", data)
print(data)
resp = requests.post(url, data=data, headers=headers, timeout=30)
resp.raise_for_status()

View File

@@ -31,6 +31,7 @@ class ProjectModel:
pending_tasks: List[Dict[str, Any]] = None,
notice_service_date: str = "",
notice_expiration_date: str = "",
case_filed_date: str = "",
case_field_date: str = "",
daily_rent_damages: str = "",
default_date: str = "",
@@ -69,7 +70,8 @@ class ProjectModel:
project_name: str = "",
project_url: str = "",
property_contacts: Dict[str, Any] = None,
viewing_emails: List[str] = None
viewing_emails: List[str] = None,
viewing_domains: List[str] = None
):
self.client = client
@@ -88,6 +90,7 @@ class ProjectModel:
self.pending_tasks = pending_tasks or []
self.notice_service_date = notice_service_date
self.notice_expiration_date = notice_expiration_date
self.case_filed_date = case_filed_date or case_field_date
self.case_field_date = case_field_date
self.daily_rent_damages = daily_rent_damages
self.default_date = default_date
@@ -127,6 +130,7 @@ class ProjectModel:
self.project_url = project_url
self.property_contacts = property_contacts or {}
self.viewing_emails = viewing_emails or []
self.viewing_domains = viewing_domains or []
def to_dict(self) -> Dict[str, Any]:
"""Convert the ProjectModel to a dictionary for Firestore storage."""
@@ -147,6 +151,7 @@ class ProjectModel:
"pending_tasks": self.pending_tasks,
"notice_service_date": self.notice_service_date,
"notice_expiration_date": self.notice_expiration_date,
"case_filed_date": self.case_filed_date,
"case_field_date": self.case_field_date,
"daily_rent_damages": self.daily_rent_damages,
"default_date": self.default_date,
@@ -185,7 +190,8 @@ class ProjectModel:
"ProjectName": self.project_name,
"ProjectUrl": self.project_url,
"property_contacts": self.property_contacts,
"viewing_emails": self.viewing_emails
"viewing_emails": self.viewing_emails,
"viewing_domains": self.viewing_domains
}
@classmethod
@@ -208,6 +214,7 @@ class ProjectModel:
pending_tasks=data.get("pending_tasks", []),
notice_service_date=data.get("notice_service_date", ""),
notice_expiration_date=data.get("notice_expiration_date", ""),
case_filed_date=data.get("case_filed_date", ""),
case_field_date=data.get("case_field_date", ""),
daily_rent_damages=data.get("daily_rent_damages", ""),
default_date=data.get("default_date", ""),
@@ -246,5 +253,6 @@ class ProjectModel:
project_name=data.get("ProjectName", ""),
project_url=data.get("ProjectUrl", ""),
property_contacts=data.get("property_contacts", {}),
viewing_emails=data.get("viewing_emails", [])
viewing_emails=data.get("viewing_emails", []),
viewing_domains=data.get("viewing_domains", [])
)

71
package-lock.json generated Normal file
View 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
View File

@@ -0,0 +1,5 @@
{
"dependencies": {
"@playwright/test": "1.58.0"
}
}

209
query_projects.py Executable file
View File

@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""
CLI script to query Firebase for projects associated with a user email.
Usage:
python scripts/query_projects.py --email user@example.com
python scripts/query_projects.py --email user@example.com --limit 10
python scripts/query_projects.py --email @gmail.com # Domain search
"""
import argparse
import json
import sys
from typing import List, Dict, Any
# Add parent directory to path for imports
sys.path.insert(0, "..")
from dotenv import load_dotenv
load_dotenv()
from firebase_init import db
from firebase_admin import auth as fb_auth
from utils import get_user_profile
def get_uid_from_email(email: str) -> str | None:
"""Get Firebase Auth UID from user email."""
try:
user = fb_auth.get_user_by_email(email)
return user.uid
except fb_auth.UserNotFound:
return None
except Exception as e:
print(f"Error looking up user: {e}")
return None
def query_projects_for_user(
uid: str,
case_email: str | None = None,
case_domain_email: str | None = None,
limit: int = 100
) -> tuple[List[Dict[str, Any]], int]:
"""
Query Firestore for projects associated with a user.
Args:
uid: Firebase user UID
case_email: Specific case email to filter by (optional)
case_domain_email: Domain to filter by (optional)
limit: Maximum number of projects to return
Returns:
Tuple of (projects list, total count)
"""
profile = get_user_profile(uid)
if not profile.get("enabled"):
print(f"Warning: User is not enabled")
# Determine which filter to use
filter_email = case_email or profile.get("case_email")
filter_domain = case_domain_email or profile.get("case_domain_email")
if not filter_email and not filter_domain:
print("Error: No case_email or case_domain_email configured for this user")
return ([], 0)
try:
if filter_domain:
# Domain-based search
domain_lower = filter_domain.lower()
projects_ref = db.collection("projects").where(
"viewing_domains", "array_contains", domain_lower
)
else:
# Email-based search
email_lower = filter_email.lower()
projects_ref = db.collection("projects").where(
"viewing_emails", "array_contains", email_lower
)
# Get total count
total_count = int(projects_ref.count().get()[0][0].value)
# Get paginated results
projects = []
for doc in projects_ref.order_by("matter_description").limit(limit).stream():
projects.append(doc.to_dict())
return (projects, total_count)
except Exception as e:
print(f"Error querying projects: {e}")
return ([], 0)
def format_project(project: Dict[str, Any]) -> str:
"""Format a project dictionary for display."""
lines = [
f"Matter #: {project.get('number', 'N/A')}",
f"Description: {project.get('matter_description', 'N/A')}",
f"Client: {project.get('client', 'N/A')}",
f"Defendant 1: {project.get('defendant_1', 'N/A')}",
f"Case #: {project.get('case_number', 'N/A')}",
f"Premises: {project.get('premises_address', 'N/A')}, {project.get('premises_city', 'N/A')}",
f"Assigned Attorney: {project.get('responsible_attorney', 'N/A')}",
f"Primary Contact: {project.get('staff_person', 'N/A')}",
f"Matter Stage: {project.get('phase_name', 'N/A')}",
f"Documents: {project.get('documents_url', 'N/A')}",
]
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(
description="Query Firebase for projects associated with a user email"
)
parser.add_argument(
"--email", "-e",
required=True,
help="User email address (e.g., user@example.com or @domain.com for domain search)"
)
parser.add_argument(
"--limit", "-l",
type=int,
default=100,
help="Maximum number of projects to return (default: 100)"
)
parser.add_argument(
"--json", "-j",
action="store_true",
help="Output results as JSON"
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Show detailed project information"
)
parser.add_argument(
"--case-email",
help="Override case email filter (use user's case_email by default)"
)
parser.add_argument(
"--case-domain",
help="Override case domain filter (use user's case_domain_email by default)"
)
args = parser.parse_args()
# Get UID from email
uid = get_uid_from_email(args.email)
if not uid:
print(f"Error: User not found with email '{args.email}'")
sys.exit(1)
# Get user profile
profile = get_user_profile(uid)
print(f"\nUser: {profile.get('user_email', 'N/A')}")
print(f"UID: {uid}")
print(f"Enabled: {profile.get('enabled', False)}")
print(f"Is Admin: {profile.get('is_admin', False)}")
print(f"Case Email: {profile.get('case_email', 'N/A')}")
print(f"Case Domain Email: {profile.get('case_domain_email', 'N/A')}")
# Query projects
projects, total_count = query_projects_for_user(
uid=uid,
case_email=args.case_email,
case_domain_email=args.case_domain,
limit=args.limit
)
print(f"\nFound {total_count} projects (showing {min(len(projects), args.limit)})")
if args.json:
# Output as JSON
result = {
"user": {
"email": profile.get("user_email"),
"uid": uid,
"enabled": profile.get("enabled"),
"is_admin": profile.get("is_admin"),
"case_email": profile.get("case_email"),
"case_domain_email": profile.get("case_domain_email")
},
"total_count": total_count,
"projects": projects
}
print(json.dumps(result, indent=2, default=str))
else:
# Human-readable output
for i, project in enumerate(projects, 1):
print(f"\n{'='*60}")
print(f"Project {i}:")
print('='*60)
if args.verbose:
print(format_project(project))
else:
print(f" Matter #: {project.get('number', 'N/A')}")
print(f" Description: {project.get('matter_description', 'N/A')}")
print(f" Client: {project.get('client', 'N/A')}")
print(f" Case #: {project.get('case_number', 'N/A')}")
if __name__ == "__main__":
main()

59
sync.py
View File

@@ -29,6 +29,44 @@ def convert_to_pacific_time(date_str):
if not date_str:
return ''
try:
# Parse the UTC datetime
utc_time = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
# Set timezone to UTC
utc_time = utc_time.replace(tzinfo=pytz.UTC)
# Convert to Pacific Time
pacific_time = utc_time.astimezone(pytz.timezone('America/Los_Angeles'))
# Format as YYYY-MM-DD
return pacific_time.strftime('%Y-%m-%d')
except (ValueError, AttributeError) as e:
print(f"[WARN] Date conversion failed for '{date_str}': {e}")
return ''
def extract_domains_from_emails(emails: List[str]) -> List[str]:
"""Extract unique domains from a list of email addresses.
Args:
emails (List[str]): List of email addresses
Returns:
List[str]: List of unique domains extracted from the emails
"""
if not emails:
return []
domains = set()
for email in emails:
if email and '@' in email:
# Extract domain part after @
domain = email.split('@')[1].lower()
domains.add(domain)
return sorted(list(domains))
try:
# Parse the UTC datetime
utc_time = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
@@ -133,6 +171,9 @@ def process_project(index: int, total: int, project_data: dict, client: Filevine
# Extract default date
default_date = convert_to_pacific_time(dates_and_deadlines.get("defaultDate")) or ''
case_filed_date = convert_to_pacific_time(dates_and_deadlines.get("dateCaseFiled")) or ''
cf = dates_and_deadlines.get("dateCaseFiled")
from pprint import pprint
print(f"CASE FILED {case_filed_date} {cf}")
# Extract motion hearing dates
demurrer_hearing_date = convert_to_pacific_time(dates_and_deadlines.get("demurrerHearingDate")) or ''
@@ -183,7 +224,7 @@ def process_project(index: int, total: int, project_data: dict, client: Filevine
import itertools
# valid_property_managers = list(itertools.chain(*))
valid_property_managers = [e.get('address').lower() for pm in property_managers if pm and pm.get('emails') for e in pm.get('emails') if e and e.get('address')]
pprint(valid_property_managers)
print(valid_property_managers)
row = ProjectModel(
@@ -204,6 +245,7 @@ def process_project(index: int, total: int, project_data: dict, client: Filevine
notice_service_date=notice_service_date,
notice_expiration_date=notice_expiration_date,
case_field_date=case_filed_date,
case_filed_date=case_filed_date,
daily_rent_damages=daily_rent_damages,
default_date=default_date,
demurrer_hearing_date=demurrer_hearing_date,
@@ -235,13 +277,14 @@ def process_project(index: int, total: int, project_data: dict, client: Filevine
service_attempt_date_1=convert_to_pacific_time(next(iter(service_info), {}).get('serviceDate')),
contacts=cs,
project_email_address=p.get("projectEmailAddress", ""),
number=p.get("number", ""),
number=p.get("number", "") or matter_overview.get('matterNumber', ''),
incident_date=convert_to_pacific_time(p.get("incidentDate") or detail.get("incidentDate")),
project_id=pid,
project_name=p.get("projectName") or detail.get("projectName"),
project_url=p.get("projectUrl") or detail.get("projectUrl"),
#property_contacts=property_contacts
viewing_emails = valid_property_managers
viewing_emails = valid_property_managers,
viewing_domains = extract_domains_from_emails(valid_property_managers)
)
# Store the results in Firestore
from app import db # Import db from app
@@ -303,7 +346,7 @@ def main():
# List projects (all pages) with filter for projects updated in the last 7 days
from datetime import datetime, timedelta
seven_days_ago = (datetime.now() - timedelta(days=14)).strftime('%Y-%m-%d')
seven_days_ago = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
projects = client.list_all_projects(latest_activity_since=seven_days_ago)
#projects = [p for p in projects if (p.get("projectId") or {}).get("native") == 15914808]
@@ -321,5 +364,13 @@ def main():
traceback.print_exc()
sys.exit(1)
def sync_single(x):
client = FilevineClient()
z = process_project(0, 1, client.fetch_project_detail(x), client)
from pprint import pprint
#pprint(z)
if __name__ == "__main__":
main()

View File

@@ -40,6 +40,14 @@
<p class="mt-1 text-sm text-slate-500">The email address used for project access.</p>
</div>
<div>
<label for="case_domain_email" class="block text-sm font-medium text-slate-700">Case Domain Email</label>
<input type="text" id="case_domain_email" name="case_domain_email"
value=""
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">
<p class="mt-1 text-sm text-slate-500">All cases with property contacts in this domain will be viewable to the user</p>
</div>
<div class="flex justify-end space-x-3 pt-4">
<button type="button" onclick="window.location.href='/admin/users'"
class="px-4 py-2 text-sm font-medium text-slate-700 bg-gray-100 hover:bg-gray-200 rounded-md transition-colors">

View File

@@ -28,9 +28,13 @@
<div class="mt-1 flex items-center">
<input type="checkbox" id="is_admin" name="is_admin"
{% if user.is_admin %}checked{% endif %}
{% if not user.is_admin %}disabled{% endif %}
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-slate-300 rounded">
<label for="is_admin" class="ml-2 block text-sm text-slate-700">Check to make this user an admin</label>
</div>
{% if not user.is_admin %}
<p class="mt-1 text-sm text-slate-500">Admin status can only be set during user creation.</p>
{% endif %}
</div>
<div>
@@ -38,7 +42,15 @@
<input type="email" id="case_email" name="case_email"
value="{{ user.case_email }}"
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">
<p class="mt-1 text-sm text-slate-500">The email address used for project access.</p>
<p class="mt-1 text-sm text-slate-500">All cases with this email will be viewable by the user</p>
</div>
<div>
<label for="case_domain_email" class="block text-sm font-medium text-slate-700">Case Domain Email</label>
<input type="text" id="case_domain_email" name="case_domain_email"
value="{{ user.case_domain_email }}"
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">
<p class="mt-1 text-sm text-slate-500">All cases with property contacts in this domain will be viewable to the user</p>
</div>
<div class="flex justify-end space-x-3 pt-4">
@@ -62,8 +74,8 @@ document.getElementById('userForm').addEventListener('submit', function(e) {
const userData = {
uid: '{{ user.uid }}',
enabled: formData.get('enabled') === 'on',
is_admin: formData.get('is_admin') === 'on',
case_email: formData.get('case_email')
case_email: formData.get('case_email'),
case_domain_email: formData.get('case_domain_email')
};
fetch('/admin/users/update', {

View File

@@ -1,6 +1,16 @@
{% extends 'base.html' %}
{% block content %}
<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') %}
<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>
@@ -67,6 +77,14 @@
</td>
<td class="px-4 py-3 text-sm text-slate-800">{{ user.case_email }}</td>
<td class="px-4 py-3 text-sm text-slate-800">
<div class="flex flex-col space-y-1">
<form method="POST" action="/admin/users/{{ user.uid }}/become-user" style="display: inline;">
<button type="submit"
class="text-green-600 hover:text-green-800 text-sm font-medium underline"
onclick="return confirm('Are you sure you want to view as {{ user.user_email }}? This will replace your current session.')">
Become User
</button>
</form>
<form method="POST" action="/admin/users/{{ user.uid }}/reset-password" style="display: inline;">
<button type="submit"
class="text-blue-600 hover:text-blue-800 text-sm font-medium underline"
@@ -74,6 +92,7 @@
Reset Password
</button>
</form>
</div>
</td>
</tr>
{% else %}

View File

@@ -18,11 +18,15 @@
<a href="/" class="font-semibold">Rothbard Law Group - Cases</a>
<nav class="space-x-4">
{% if session.uid %}
{% if session.impersonating %}
<a href="/admin/users/revert" class="text-sm text-orange-600 hover:text-orange-900 font-medium">Revert to Admin</a>
{% else %}
<a href="/dashboard" class="text-sm text-slate-600 hover:text-slate-900">Dashboard</a>
{% set profile = get_user_profile(session.uid) %}
{% if profile.is_admin %}
<a href="/admin/users" class="text-sm text-slate-600 hover:text-slate-900">Admin Users</a>
{% endif %}
{% endif %}
<a href="/logout" class="text-sm text-slate-600 hover:text-slate-900">Logout</a>
{% else %}
<a href="/login" class="text-sm text-slate-600 hover:text-slate-900">Login</a>

View File

@@ -1,7 +1,24 @@
{% extends 'base.html' %}
{% block content %}
<div class="h-full flex flex-col" x-data="columnConfig()">
{% if session.impersonating %}
{% set impersonated_profile = get_user_profile(session.uid) %}
<div class="bg-orange-50 border border-orange-200 text-orange-800 px-4 py-3 rounded-md mb-4 flex justify-between items-center">
<div>
<p class="font-medium">Viewing as: {{ impersonated_profile.user_email }}</p>
<p class="text-sm mt-1">You are impersonating this user. Click "Revert to Admin" in the navigation to return to your admin account.</p>
</div>
<a href="/admin/users/revert" class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-orange-700 bg-orange-100 hover:bg-orange-200 rounded-md transition-colors">
Revert to Admin
</a>
</div>
{% endif %}
{% if case_email %}
<h1 class="text-xl font-semibold mb-4">Projects for {{ case_email }}</h1>
{% else %}
<h1 class="text-xl font-semibold mb-4">All projects</h1>
{% endif %}
<div class="flex justify-between">
{% set profile = get_user_profile(session.uid) %}
@@ -375,9 +392,8 @@
{% endcall %}
</td>
<td class="px-4 py-3 text-sm" :class="{'hidden': !isColumnVisible('Date Case Filed')}">
{% call expander() %}
{{ r.case_field_date }}
{% if r.case_filed_date %}{{ r.case_filed_date }}{% else %}{{ r.case_field_date }}{% endif %}
{% endcall %}
</td>
<td class="px-4 py-3 text-sm" :class="{'hidden': !isColumnVisible('Daily Rent Damages')}">

View File

@@ -4,6 +4,9 @@
<div class="flex justify-center py-8">
<div class="w-full max-w-md p-8 space-y-6 bg-white rounded-xl shadow-lg">
<h1 class="text-2xl font-bold text-center text-gray-800">Secure Access</h1>
<div class="bg-blue-50 border border-blue-200 text-blue-700 px-4 py-3 rounded-lg mb-4">
<p>If you don't have a user account, or need to reset your password, send an email to <a href="mailto:office@rothbardlawgroup.com" class="underline">office@rothbardlawgroup.com</a>.</p>
</div>
<form id="login-form" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email Address</label>
@@ -28,18 +31,14 @@
<script>
// Initialize Firebase configuration from template
window.FIREBASE_CONFIG = {{ firebase_config|tojson }};
// Initialize Firebase app and auth
const app = firebase.initializeApp(window.FIREBASE_CONFIG || {});
const auth = firebase.auth();
// Get form and input elements
const form = document.getElementById('login-form');
const email = document.getElementById('email');
const password = document.getElementById('password');
const err = document.getElementById('error');
// Handle form submission
form.addEventListener('submit', async (e) => {
e.preventDefault();
err.classList.add('hidden');
@@ -51,10 +50,17 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ idToken })
});
if (!res.ok) {
throw new Error('Session exchange failed');
}
const data = await res.json();
if (data.requires_password_reset) {
window.location.href = '/require-password-reset';
} else {
window.location.href = '/';
}
} catch (e) {
err.textContent = e.message || 'Authentication failed';
err.classList.remove('hidden');

View 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 %}

152
terraform/main.tf Normal file
View File

@@ -0,0 +1,152 @@
terraform {
required_providers {
google-beta = {
source = "hashicorp/google-beta"
version = "~> 6.0"
}
}
}
provider "google" {
project = var.gcp_project_id
region = var.gcp_region
}
# Firebase Project Setup
resource "google_firebase_project" "default" {
provider = google-beta
project = var.gcp_project_id
}
# Firebase Web App
resource "google_firebase_web_app" "rothbard_portal" {
provider = google-beta
project = google_firebase_project.default.project
display_name = "Rothbard Client Portal"
app_urls = ["https://${var.domain_name}"]
}
# Firestore Database
resource "google_firestore_database" "default" {
provider = google-beta
project = var.gcp_project_id
name = "(default)"
location_id = var.firestore_location
type = "FIRESTORE_NATIVE"
delete_protection_state = "DELETE_PROTECTION_DISABLED"
}
# Firebase Authentication - Complete Configuration
resource "google_firebase_auth_config" "default" {
provider = google-beta
project = var.gcp_project_id
sign_in_options {
email {
enabled = true
password_required = true
}
# Disable other providers for security
phone {
enabled = false
}
google {
enabled = var.enable_google_signin
}
facebook {
enabled = false
}
apple {
enabled = false
}
}
# Email configuration
email {
reset_password_template {
from_email_address = var.auth_from_email
from_display_name = var.auth_from_name
reply_to = var.auth_reply_to
subject = "Reset your Rothbard Law Group password"
html = file("${path.module}/templates/reset_password.html")
text = file("${path.module}/templates/reset_password.txt")
}
email_verification_template {
from_email_address = var.auth_from_email
from_display_name = var.auth_from_name
reply_to = var.auth_reply_to
subject = "Verify your Rothbard Law Group account"
html = file("${path.module}/templates/email_verification.html")
text = file("${path.module}/templates/email_verification.txt")
}
}
# Security settings
sign_in {
allow_duplicate_emails = false
}
# Multi-factor authentication (disabled for simplicity)
multi_factor_auth {
enabled = false
}
# Anonymous user access (disabled)
anonymous {
enabled = false
}
}
# Service Account for the Flask App
resource "google_service_account" "flask_app" {
account_id = "rothbard-flask-app"
display_name = "Rothbard Flask App Service Account"
}
# IAM permissions for the Flask App
resource "google_project_iam_member" "firestore_access" {
project = var.gcp_project_id
role = "roles/datastore.user"
member = "serviceAccount:${google_service_account.flask_app.email}"
}
resource "google_project_iam_member" "firebase_admin" {
project = var.gcp_project_id
role = "roles/firebase.admin"
member = "serviceAccount:${google_service_account.flask_app.email}"
}
# Firestore Security Rules - Note: Firestore security policies are managed through Firestore rules
# This section is commented out as google_firestore_security_policy is not supported
# Security rules should be managed through firestore.rules file or Firebase console
# Firebase Hosting (optional - for static assets)
resource "google_firebase_hosting_site" "default" {
provider = google-beta
project = var.gcp_project_id
site_id = "rothbard-portal"
}
# Output important values
output "firebase_web_app_id" {
description = "Firebase Web App ID"
value = google_firebase_web_app.rothbard_portal.app_id
}
output "firebase_project_id" {
description = "Firebase Project ID"
value = google_firebase_project.default.project
}
output "service_account_email" {
description = "Service account email for Flask app"
value = google_service_account.flask_app.email
}

View File

@@ -0,0 +1,144 @@
# Cloud Run Service for Flask App
resource "google_cloud_run_service" "flask_app" {
name = "${var.app_name}-service"
location = var.gcp_region
template {
spec {
containers {
image = var.container_image
# Environment variables for the Flask app
env {
name = "FLASK_SECRET_KEY"
value = var.flask_secret_key
}
env {
name = "FIREBASE_PROJECT_ID"
value = var.firebase_project_id
}
env {
name = "GOOGLE_APPLICATION_CREDENTIALS"
value = "/etc/secrets/service-account.json"
}
# Filevine API credentials
env {
name = "FILEVINE_CLIENT_ID"
value = var.filevine_client_id
}
env {
name = "FILEVINE_CLIENT_SECRET"
value = var.filevine_client_secret
}
env {
name = "FILEVINE_PERSONAL_ACCESS_TOKEN"
value = var.filevine_pat
}
env {
name = "FILEVINE_ORG_ID"
value = var.filevine_org_id
}
env {
name = "FILEVINE_USER_ID"
value = var.filevine_user_id
}
# Memory and CPU limits
resources {
limits = {
cpu = "1000m"
memory = "512Mi"
}
}
# Mount service account key
volume_mount {
name = "service-account-key"
mount_path = "/etc/secrets"
read_only = true
}
}
# Service account for the container
service_account_name = var.service_account_email
# Volumes
volumes {
name = "service-account-key"
secret {
secret_name = google_secret_manager_secret.service_account_key.secret_id
items {
key = "latest"
path = "service-account.json"
}
}
}
# Allow unauthenticated access
container_concurrency = 100
timeout_seconds = 300
}
# Traffic settings
metadata {
annotations = {
"autoscaling.knative.dev/maxScale" = "10"
"autoscaling.knative.dev/minScale" = "1"
"run.googleapis.com/ingress" = "all"
}
}
}
traffic {
percent = 100
latest_revision = true
}
depends_on = [google_secret_manager_secret_version.service_account_key]
}
# Make Cloud Run service publicly accessible
resource "google_cloud_run_service_iam_member" "public" {
location = google_cloud_run_service.flask_app.location
project = google_cloud_run_service.flask_app.project
service = google_cloud_run_service.flask_app.name
role = "roles/run.invoker"
member = "allUsers"
}
# Store service account key in Secret Manager
resource "google_secret_manager_secret" "service_account_key" {
project = var.gcp_project_id
secret_id = "${var.app_name}-service-account-key"
replication {
automatic {}
}
}
resource "google_secret_manager_secret_version" "service_account_key" {
secret = google_secret_manager_secret.service_account_key.id
secret_data = var.service_account_key_data
}
# Cloud Storage bucket for container storage (if needed)
resource "google_storage_bucket" "app_storage" {
name = "${var.app_name}-storage-${var.gcp_project_id}"
location = var.gcp_region
force_destroy = true
uniform_bucket_level_access = true
}
# Output the service URL
output "service_url" {
description = "Cloud Run service URL"
value = google_cloud_run_service.flask_app.status[0].url
}

View File

@@ -3,7 +3,7 @@ from firebase_init import db
from firebase_admin import auth as fb_auth
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)
snap = doc_ref.get()
if not snap.exists:
@@ -19,18 +19,24 @@ def get_user_profile(uid: str):
"enabled": False,
"is_admin": False,
"user_email": user_email,
"case_email": user_email
"case_email": user_email,
"case_domain_email": "",
"password_reset_required": True
}, merge=True)
return {
"enabled": False,
"is_admin": False,
"user_email": user_email,
"case_email": user_email
"case_email": user_email,
"case_domain_email": "",
"password_reset_required": True
}
data = snap.to_dict() or {}
return {
"enabled": bool(data.get("enabled", False)),
"is_admin": bool(data.get("is_admin", False)),
"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", ""),
"password_reset_required": bool(data.get("password_reset_required", False))
}