Spaces:
Running
Running
mike boone commited on
Commit ·
ece9b51
1
Parent(s): 98056e5
feat: add temporary-password onboarding flow
Browse files- chat_interface.py +101 -13
- docs/onboarding_auth_migration.md +10 -0
- supabase_client.py +78 -5
- tests/e2e_quality.py +3 -0
- tests/test_research_logging_regression.py +33 -0
chat_interface.py
CHANGED
|
@@ -205,6 +205,29 @@ def require_authenticated_email(request: gr.Request = None, user_email: str = No
|
|
| 205 |
)
|
| 206 |
|
| 207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
def build_initial_chat_message(company: str, use_case: str) -> str:
|
| 209 |
"""Build the pre-filled chat message from current settings."""
|
| 210 |
if company and use_case:
|
|
@@ -264,6 +287,23 @@ class ChatDemoInterface:
|
|
| 264 |
"""Resolve and cache effective user identity for settings access."""
|
| 265 |
self.user_email = require_authenticated_email(user_email=self.user_email)
|
| 266 |
return self.user_email
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
|
| 268 |
def load_default_settings(self):
|
| 269 |
"""Load settings from Supabase or defaults"""
|
|
@@ -408,6 +448,12 @@ I'll research a company, build a Snowflake schema, generate realistic data, and
|
|
| 408 |
# Add user message to history
|
| 409 |
chat_history.append((message, None))
|
| 410 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
# If data_adjuster_url is saved in settings and we're at init, inject it as the message
|
| 412 |
# so the user lands directly in Data Adjuster without having to paste the URL manually
|
| 413 |
da_url = self.settings.get('data_adjuster_url', '').strip()
|
|
@@ -1886,6 +1932,12 @@ To change settings, use:
|
|
| 1886 |
model=resolved_model,
|
| 1887 |
model_setting=model_setting,
|
| 1888 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1889 |
_t = _slog.log_start("research") if _slog else None
|
| 1890 |
|
| 1891 |
print(f"\n\n[CACHE DEBUG] === run_research_streaming called ===")
|
|
@@ -4913,8 +4965,8 @@ def create_chat_interface():
|
|
| 4913 |
with gr.Column(scale=2):
|
| 4914 |
gr.Markdown("#### Current Users")
|
| 4915 |
user_list_display = gr.Dataframe(
|
| 4916 |
-
headers=["Email", "Display Name", "Admin", "Active", "Last Login EST"],
|
| 4917 |
-
datatype=["str", "str", "bool", "bool", "str"],
|
| 4918 |
interactive=False,
|
| 4919 |
label="Users"
|
| 4920 |
)
|
|
@@ -4923,10 +4975,21 @@ def create_chat_interface():
|
|
| 4923 |
with gr.Column(scale=1):
|
| 4924 |
gr.Markdown("#### Add New User")
|
| 4925 |
new_user_email = gr.Textbox(label="Email", placeholder="user@company.com")
|
| 4926 |
-
new_user_password = gr.Textbox(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4927 |
new_user_display = gr.Textbox(label="Display Name", placeholder="Jane Doe")
|
| 4928 |
new_user_admin = gr.Checkbox(label="Admin?", value=False)
|
| 4929 |
add_user_btn = gr.Button("➕ Add User", variant="primary")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4930 |
|
| 4931 |
gr.Markdown("---")
|
| 4932 |
gr.Markdown("#### User Actions")
|
|
@@ -4965,26 +5028,50 @@ def create_chat_interface():
|
|
| 4965 |
u.get('display_name', ''),
|
| 4966 |
u.get('is_admin', False),
|
| 4967 |
u.get('is_active', True),
|
|
|
|
| 4968 |
last_login_str
|
| 4969 |
])
|
| 4970 |
return rows
|
| 4971 |
except Exception as e:
|
| 4972 |
-
return [[f"Error: {e}", "", False, False, ""]]
|
| 4973 |
|
| 4974 |
-
def add_user_handler(email, password, display_name, is_admin):
|
| 4975 |
"""Add a new user."""
|
| 4976 |
-
if not email
|
| 4977 |
-
return load_user_list(), "Email
|
| 4978 |
try:
|
| 4979 |
from supabase_client import UserManager
|
| 4980 |
um = UserManager()
|
| 4981 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4982 |
if success:
|
| 4983 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4984 |
else:
|
| 4985 |
-
return load_user_list(), f"Failed to add user {email}."
|
| 4986 |
except Exception as e:
|
| 4987 |
-
return load_user_list(), f"Error: {e}"
|
| 4988 |
|
| 4989 |
def deactivate_handler(email):
|
| 4990 |
if not email:
|
|
@@ -5014,13 +5101,13 @@ def create_chat_interface():
|
|
| 5014 |
try:
|
| 5015 |
from supabase_client import UserManager
|
| 5016 |
um = UserManager()
|
| 5017 |
-
um.reset_password(email, new_pw)
|
| 5018 |
return f"Password reset for {email}."
|
| 5019 |
except Exception as e:
|
| 5020 |
return f"Error: {e}"
|
| 5021 |
|
| 5022 |
refresh_users_btn.click(fn=load_user_list, inputs=[], outputs=[user_list_display])
|
| 5023 |
-
add_user_btn.click(fn=add_user_handler, inputs=[new_user_email, new_user_password, new_user_display, new_user_admin], outputs=[user_list_display, user_mgmt_status])
|
| 5024 |
deactivate_btn.click(fn=deactivate_handler, inputs=[action_email], outputs=[user_list_display, user_mgmt_status])
|
| 5025 |
activate_btn.click(fn=activate_handler, inputs=[action_email], outputs=[user_list_display, user_mgmt_status])
|
| 5026 |
reset_pw_btn.click(fn=reset_password_handler, inputs=[action_email, new_password], outputs=[user_mgmt_status])
|
|
@@ -6581,6 +6668,7 @@ def create_settings_tab():
|
|
| 6581 |
if not um.authenticate(user_email, current):
|
| 6582 |
return "❌ Current password is incorrect."
|
| 6583 |
um.reset_password(user_email, new_pw)
|
|
|
|
| 6584 |
return "✅ Password changed successfully. Use your new password next time you sign in."
|
| 6585 |
except Exception as e:
|
| 6586 |
return f"❌ Error: {e}"
|
|
|
|
| 205 |
)
|
| 206 |
|
| 207 |
|
| 208 |
+
def resolve_app_url_for_invite(request: gr.Request = None) -> str:
|
| 209 |
+
"""Resolve the public app URL for copy/paste onboarding invites."""
|
| 210 |
+
configured_url = (
|
| 211 |
+
os.getenv("DEMOPREP_APP_URL", "").strip()
|
| 212 |
+
or os.getenv("PUBLIC_APP_URL", "").strip()
|
| 213 |
+
or os.getenv("SPACE_HOST", "").strip()
|
| 214 |
+
)
|
| 215 |
+
if configured_url:
|
| 216 |
+
if configured_url.startswith("http"):
|
| 217 |
+
return configured_url.rstrip("/")
|
| 218 |
+
return f"https://{configured_url.strip('/')}"
|
| 219 |
+
|
| 220 |
+
try:
|
| 221 |
+
headers = getattr(request, "headers", {}) if request else {}
|
| 222 |
+
referer = headers.get("referer") or headers.get("origin") or ""
|
| 223 |
+
if referer:
|
| 224 |
+
return referer.split("?")[0].rstrip("/")
|
| 225 |
+
except Exception:
|
| 226 |
+
pass
|
| 227 |
+
|
| 228 |
+
return "https://thoughtspot-dp-test-demoprep.hf.space"
|
| 229 |
+
|
| 230 |
+
|
| 231 |
def build_initial_chat_message(company: str, use_case: str) -> str:
|
| 232 |
"""Build the pre-filled chat message from current settings."""
|
| 233 |
if company and use_case:
|
|
|
|
| 287 |
"""Resolve and cache effective user identity for settings access."""
|
| 288 |
self.user_email = require_authenticated_email(user_email=self.user_email)
|
| 289 |
return self.user_email
|
| 290 |
+
|
| 291 |
+
def _temporary_password_block_message(self) -> str:
|
| 292 |
+
"""Return a blocking message when a temp-password user tries to run the app."""
|
| 293 |
+
try:
|
| 294 |
+
from supabase_client import UserManager
|
| 295 |
+
user_email = self._get_effective_user_email()
|
| 296 |
+
um = UserManager()
|
| 297 |
+
if um.enabled and um.must_change_password(user_email):
|
| 298 |
+
return (
|
| 299 |
+
"🔒 **Password change required**\n\n"
|
| 300 |
+
"You are signed in with a temporary password. "
|
| 301 |
+
"Open **Settings → Change Password**, set your own password, "
|
| 302 |
+
"then come back and run DemoPrep."
|
| 303 |
+
)
|
| 304 |
+
except Exception as e:
|
| 305 |
+
print(f"[Auth] Unable to check temporary-password status: {e}")
|
| 306 |
+
return ""
|
| 307 |
|
| 308 |
def load_default_settings(self):
|
| 309 |
"""Load settings from Supabase or defaults"""
|
|
|
|
| 448 |
# Add user message to history
|
| 449 |
chat_history.append((message, None))
|
| 450 |
|
| 451 |
+
password_block = self._temporary_password_block_message()
|
| 452 |
+
if password_block and current_stage in {'initialization', 'awaiting_context'}:
|
| 453 |
+
chat_history[-1] = (message, password_block)
|
| 454 |
+
yield chat_history, current_stage, current_model, company, use_case, ""
|
| 455 |
+
return
|
| 456 |
+
|
| 457 |
# If data_adjuster_url is saved in settings and we're at init, inject it as the message
|
| 458 |
# so the user lands directly in Data Adjuster without having to paste the URL manually
|
| 459 |
da_url = self.settings.get('data_adjuster_url', '').strip()
|
|
|
|
| 1932 |
model=resolved_model,
|
| 1933 |
model_setting=model_setting,
|
| 1934 |
)
|
| 1935 |
+
password_block = self._temporary_password_block_message()
|
| 1936 |
+
if password_block:
|
| 1937 |
+
if _slog:
|
| 1938 |
+
_slog.log("auth", "temporary password blocked pipeline start")
|
| 1939 |
+
yield password_block
|
| 1940 |
+
return
|
| 1941 |
_t = _slog.log_start("research") if _slog else None
|
| 1942 |
|
| 1943 |
print(f"\n\n[CACHE DEBUG] === run_research_streaming called ===")
|
|
|
|
| 4965 |
with gr.Column(scale=2):
|
| 4966 |
gr.Markdown("#### Current Users")
|
| 4967 |
user_list_display = gr.Dataframe(
|
| 4968 |
+
headers=["Email", "Display Name", "Admin", "Active", "Must Change PW", "Last Login EST"],
|
| 4969 |
+
datatype=["str", "str", "bool", "bool", "bool", "str"],
|
| 4970 |
interactive=False,
|
| 4971 |
label="Users"
|
| 4972 |
)
|
|
|
|
| 4975 |
with gr.Column(scale=1):
|
| 4976 |
gr.Markdown("#### Add New User")
|
| 4977 |
new_user_email = gr.Textbox(label="Email", placeholder="user@company.com")
|
| 4978 |
+
new_user_password = gr.Textbox(
|
| 4979 |
+
label="Temporary Password (optional)",
|
| 4980 |
+
type="password",
|
| 4981 |
+
placeholder="Leave blank to generate one"
|
| 4982 |
+
)
|
| 4983 |
new_user_display = gr.Textbox(label="Display Name", placeholder="Jane Doe")
|
| 4984 |
new_user_admin = gr.Checkbox(label="Admin?", value=False)
|
| 4985 |
add_user_btn = gr.Button("➕ Add User", variant="primary")
|
| 4986 |
+
invite_message = gr.Textbox(
|
| 4987 |
+
label="Slack invite message",
|
| 4988 |
+
lines=9,
|
| 4989 |
+
interactive=True,
|
| 4990 |
+
show_copy_button=True,
|
| 4991 |
+
placeholder="Add a user to generate the message to send in Slack."
|
| 4992 |
+
)
|
| 4993 |
|
| 4994 |
gr.Markdown("---")
|
| 4995 |
gr.Markdown("#### User Actions")
|
|
|
|
| 5028 |
u.get('display_name', ''),
|
| 5029 |
u.get('is_admin', False),
|
| 5030 |
u.get('is_active', True),
|
| 5031 |
+
u.get('must_change_password', False),
|
| 5032 |
last_login_str
|
| 5033 |
])
|
| 5034 |
return rows
|
| 5035 |
except Exception as e:
|
| 5036 |
+
return [[f"Error: {e}", "", False, False, False, ""]]
|
| 5037 |
|
| 5038 |
+
def add_user_handler(email, password, display_name, is_admin, request: gr.Request = None):
|
| 5039 |
"""Add a new user."""
|
| 5040 |
+
if not email:
|
| 5041 |
+
return load_user_list(), "Email is required.", ""
|
| 5042 |
try:
|
| 5043 |
from supabase_client import UserManager
|
| 5044 |
um = UserManager()
|
| 5045 |
+
temp_password = password or um.generate_temp_password()
|
| 5046 |
+
success = um.add_user(
|
| 5047 |
+
email,
|
| 5048 |
+
temp_password,
|
| 5049 |
+
display_name,
|
| 5050 |
+
is_admin,
|
| 5051 |
+
must_change_password=True,
|
| 5052 |
+
)
|
| 5053 |
if success:
|
| 5054 |
+
clean_email = email.lower().strip()
|
| 5055 |
+
display = (display_name or clean_email.split("@")[0]).strip()
|
| 5056 |
+
app_url = resolve_app_url_for_invite(request)
|
| 5057 |
+
invite = (
|
| 5058 |
+
f"Hi {display} - you now have access to DemoPrep.\n\n"
|
| 5059 |
+
f"App: {app_url}\n"
|
| 5060 |
+
f"Username: {clean_email}\n"
|
| 5061 |
+
f"Temporary password: {temp_password}\n\n"
|
| 5062 |
+
"Please sign in and immediately change your password in "
|
| 5063 |
+
"Settings -> Change Password before running demos."
|
| 5064 |
+
)
|
| 5065 |
+
if not um._supports_must_change_password():
|
| 5066 |
+
invite += (
|
| 5067 |
+
"\n\nAdmin note: temporary-password enforcement is not active "
|
| 5068 |
+
"until the demoprep_users.must_change_password migration is applied."
|
| 5069 |
+
)
|
| 5070 |
+
return load_user_list(), f"User {clean_email} added. Slack invite generated below.", invite
|
| 5071 |
else:
|
| 5072 |
+
return load_user_list(), f"Failed to add user {email}.", ""
|
| 5073 |
except Exception as e:
|
| 5074 |
+
return load_user_list(), f"Error: {e}", ""
|
| 5075 |
|
| 5076 |
def deactivate_handler(email):
|
| 5077 |
if not email:
|
|
|
|
| 5101 |
try:
|
| 5102 |
from supabase_client import UserManager
|
| 5103 |
um = UserManager()
|
| 5104 |
+
um.reset_password(email, new_pw, must_change_password=True)
|
| 5105 |
return f"Password reset for {email}."
|
| 5106 |
except Exception as e:
|
| 5107 |
return f"Error: {e}"
|
| 5108 |
|
| 5109 |
refresh_users_btn.click(fn=load_user_list, inputs=[], outputs=[user_list_display])
|
| 5110 |
+
add_user_btn.click(fn=add_user_handler, inputs=[new_user_email, new_user_password, new_user_display, new_user_admin], outputs=[user_list_display, user_mgmt_status, invite_message])
|
| 5111 |
deactivate_btn.click(fn=deactivate_handler, inputs=[action_email], outputs=[user_list_display, user_mgmt_status])
|
| 5112 |
activate_btn.click(fn=activate_handler, inputs=[action_email], outputs=[user_list_display, user_mgmt_status])
|
| 5113 |
reset_pw_btn.click(fn=reset_password_handler, inputs=[action_email, new_password], outputs=[user_mgmt_status])
|
|
|
|
| 6668 |
if not um.authenticate(user_email, current):
|
| 6669 |
return "❌ Current password is incorrect."
|
| 6670 |
um.reset_password(user_email, new_pw)
|
| 6671 |
+
um.clear_must_change_password(user_email)
|
| 6672 |
return "✅ Password changed successfully. Use your new password next time you sign in."
|
| 6673 |
except Exception as e:
|
| 6674 |
return f"❌ Error: {e}"
|
docs/onboarding_auth_migration.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Onboarding Auth Migration
|
| 2 |
+
|
| 3 |
+
Run this once against the Supabase database that backs DemoPrep auth before onboarding users with temporary passwords:
|
| 4 |
+
|
| 5 |
+
```sql
|
| 6 |
+
alter table demoprep_users
|
| 7 |
+
add column if not exists must_change_password boolean not null default false;
|
| 8 |
+
```
|
| 9 |
+
|
| 10 |
+
Without this column, admins can still add users and generate Slack invite text, but the app cannot enforce the first-login password change.
|
supabase_client.py
CHANGED
|
@@ -18,6 +18,8 @@ Usage:
|
|
| 18 |
|
| 19 |
import os
|
| 20 |
import json
|
|
|
|
|
|
|
| 21 |
from typing import Dict, List, Optional, Any
|
| 22 |
from datetime import datetime
|
| 23 |
from dotenv import load_dotenv
|
|
@@ -328,8 +330,11 @@ class UserManager:
|
|
| 328 |
is_active BOOLEAN DEFAULT TRUE
|
| 329 |
created_at TIMESTAMPTZ DEFAULT now()
|
| 330 |
last_login TIMESTAMPTZ
|
|
|
|
| 331 |
"""
|
| 332 |
|
|
|
|
|
|
|
| 333 |
def __init__(self):
|
| 334 |
self.client = None
|
| 335 |
self.enabled = False
|
|
@@ -371,6 +376,36 @@ class UserManager:
|
|
| 371 |
pw_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
|
| 372 |
return pw_hash.hex() == expected_hash
|
| 373 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
def authenticate(self, email: str, password: str) -> Optional[Dict]:
|
| 375 |
"""
|
| 376 |
Authenticate a user by email and password.
|
|
@@ -416,6 +451,7 @@ class UserManager:
|
|
| 416 |
"display_name": user.get("display_name", ""),
|
| 417 |
"is_admin": user.get("is_admin", False),
|
| 418 |
"is_active": user.get("is_active", True),
|
|
|
|
| 419 |
}
|
| 420 |
|
| 421 |
except Exception as e:
|
|
@@ -423,7 +459,7 @@ class UserManager:
|
|
| 423 |
return None
|
| 424 |
|
| 425 |
def add_user(self, email: str, password: str, display_name: str = "",
|
| 426 |
-
is_admin: bool = False) -> bool:
|
| 427 |
"""Add a new user. Returns True if successful."""
|
| 428 |
if not self.enabled:
|
| 429 |
return False
|
|
@@ -437,6 +473,8 @@ class UserManager:
|
|
| 437 |
"is_active": True,
|
| 438 |
"created_at": datetime.utcnow().isoformat(),
|
| 439 |
}
|
|
|
|
|
|
|
| 440 |
self.client.table("demoprep_users").insert(data).execute()
|
| 441 |
print(f"UserManager: Added user {email} (admin={is_admin})")
|
| 442 |
return True
|
|
@@ -450,8 +488,11 @@ class UserManager:
|
|
| 450 |
return []
|
| 451 |
|
| 452 |
try:
|
|
|
|
|
|
|
|
|
|
| 453 |
result = self.client.table("demoprep_users") \
|
| 454 |
-
.select(
|
| 455 |
.order("created_at") \
|
| 456 |
.execute()
|
| 457 |
return result.data or []
|
|
@@ -480,14 +521,17 @@ class UserManager:
|
|
| 480 |
print(f"UserManager: Error updating user {email}: {e}")
|
| 481 |
return False
|
| 482 |
|
| 483 |
-
def reset_password(self, email: str, new_password: str) -> bool:
|
| 484 |
"""Reset a user's password."""
|
| 485 |
if not self.enabled:
|
| 486 |
return False
|
| 487 |
|
| 488 |
try:
|
|
|
|
|
|
|
|
|
|
| 489 |
self.client.table("demoprep_users") \
|
| 490 |
-
.update(
|
| 491 |
.eq("email", email.lower().strip()) \
|
| 492 |
.execute()
|
| 493 |
return True
|
|
@@ -495,6 +539,35 @@ class UserManager:
|
|
| 495 |
print(f"UserManager: Error resetting password for {email}: {e}")
|
| 496 |
return False
|
| 497 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 498 |
def deactivate_user(self, email: str) -> bool:
|
| 499 |
"""Deactivate a user (soft delete)."""
|
| 500 |
return self.update_user(email, is_active=False)
|
|
@@ -842,4 +915,4 @@ if __name__ == "__main__":
|
|
| 842 |
settings.delete_setting(test_email, "test_setting")
|
| 843 |
print("\n✅ All tests completed!")
|
| 844 |
else:
|
| 845 |
-
print("❌ Supabase not configured. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env file.")
|
|
|
|
| 18 |
|
| 19 |
import os
|
| 20 |
import json
|
| 21 |
+
import string
|
| 22 |
+
import secrets
|
| 23 |
from typing import Dict, List, Optional, Any
|
| 24 |
from datetime import datetime
|
| 25 |
from dotenv import load_dotenv
|
|
|
|
| 330 |
is_active BOOLEAN DEFAULT TRUE
|
| 331 |
created_at TIMESTAMPTZ DEFAULT now()
|
| 332 |
last_login TIMESTAMPTZ
|
| 333 |
+
must_change_password BOOLEAN DEFAULT FALSE
|
| 334 |
"""
|
| 335 |
|
| 336 |
+
_must_change_password_supported: Optional[bool] = None
|
| 337 |
+
|
| 338 |
def __init__(self):
|
| 339 |
self.client = None
|
| 340 |
self.enabled = False
|
|
|
|
| 376 |
pw_hash = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 100000)
|
| 377 |
return pw_hash.hex() == expected_hash
|
| 378 |
|
| 379 |
+
def _supports_must_change_password(self) -> bool:
|
| 380 |
+
"""Return whether demoprep_users has the onboarding password flag."""
|
| 381 |
+
if not self.enabled:
|
| 382 |
+
return False
|
| 383 |
+
if UserManager._must_change_password_supported is not None:
|
| 384 |
+
return UserManager._must_change_password_supported
|
| 385 |
+
|
| 386 |
+
try:
|
| 387 |
+
self.client.table("demoprep_users") \
|
| 388 |
+
.select("must_change_password") \
|
| 389 |
+
.limit(1) \
|
| 390 |
+
.execute()
|
| 391 |
+
UserManager._must_change_password_supported = True
|
| 392 |
+
except Exception:
|
| 393 |
+
UserManager._must_change_password_supported = False
|
| 394 |
+
return UserManager._must_change_password_supported
|
| 395 |
+
|
| 396 |
+
def generate_temp_password(self, length: int = 16) -> str:
|
| 397 |
+
"""Generate a Slack-friendly temporary password."""
|
| 398 |
+
alphabet = string.ascii_letters + string.digits + "!@#$%&*?"
|
| 399 |
+
while True:
|
| 400 |
+
password = ''.join(secrets.choice(alphabet) for _ in range(length))
|
| 401 |
+
if (
|
| 402 |
+
any(c.islower() for c in password)
|
| 403 |
+
and any(c.isupper() for c in password)
|
| 404 |
+
and any(c.isdigit() for c in password)
|
| 405 |
+
and any(c in "!@#$%&*?" for c in password)
|
| 406 |
+
):
|
| 407 |
+
return password
|
| 408 |
+
|
| 409 |
def authenticate(self, email: str, password: str) -> Optional[Dict]:
|
| 410 |
"""
|
| 411 |
Authenticate a user by email and password.
|
|
|
|
| 451 |
"display_name": user.get("display_name", ""),
|
| 452 |
"is_admin": user.get("is_admin", False),
|
| 453 |
"is_active": user.get("is_active", True),
|
| 454 |
+
"must_change_password": user.get("must_change_password", False),
|
| 455 |
}
|
| 456 |
|
| 457 |
except Exception as e:
|
|
|
|
| 459 |
return None
|
| 460 |
|
| 461 |
def add_user(self, email: str, password: str, display_name: str = "",
|
| 462 |
+
is_admin: bool = False, must_change_password: bool = False) -> bool:
|
| 463 |
"""Add a new user. Returns True if successful."""
|
| 464 |
if not self.enabled:
|
| 465 |
return False
|
|
|
|
| 473 |
"is_active": True,
|
| 474 |
"created_at": datetime.utcnow().isoformat(),
|
| 475 |
}
|
| 476 |
+
if self._supports_must_change_password():
|
| 477 |
+
data["must_change_password"] = must_change_password
|
| 478 |
self.client.table("demoprep_users").insert(data).execute()
|
| 479 |
print(f"UserManager: Added user {email} (admin={is_admin})")
|
| 480 |
return True
|
|
|
|
| 488 |
return []
|
| 489 |
|
| 490 |
try:
|
| 491 |
+
fields = "email, display_name, is_admin, is_active, created_at, last_login"
|
| 492 |
+
if self._supports_must_change_password():
|
| 493 |
+
fields += ", must_change_password"
|
| 494 |
result = self.client.table("demoprep_users") \
|
| 495 |
+
.select(fields) \
|
| 496 |
.order("created_at") \
|
| 497 |
.execute()
|
| 498 |
return result.data or []
|
|
|
|
| 521 |
print(f"UserManager: Error updating user {email}: {e}")
|
| 522 |
return False
|
| 523 |
|
| 524 |
+
def reset_password(self, email: str, new_password: str, must_change_password: Optional[bool] = None) -> bool:
|
| 525 |
"""Reset a user's password."""
|
| 526 |
if not self.enabled:
|
| 527 |
return False
|
| 528 |
|
| 529 |
try:
|
| 530 |
+
update_data = {"password_hash": self._hash_password(new_password)}
|
| 531 |
+
if must_change_password is not None and self._supports_must_change_password():
|
| 532 |
+
update_data["must_change_password"] = must_change_password
|
| 533 |
self.client.table("demoprep_users") \
|
| 534 |
+
.update(update_data) \
|
| 535 |
.eq("email", email.lower().strip()) \
|
| 536 |
.execute()
|
| 537 |
return True
|
|
|
|
| 539 |
print(f"UserManager: Error resetting password for {email}: {e}")
|
| 540 |
return False
|
| 541 |
|
| 542 |
+
def must_change_password(self, email: str) -> bool:
|
| 543 |
+
"""Check whether a user must change a temporary password."""
|
| 544 |
+
if not self.enabled or not self._supports_must_change_password():
|
| 545 |
+
return False
|
| 546 |
+
try:
|
| 547 |
+
result = self.client.table("demoprep_users") \
|
| 548 |
+
.select("must_change_password") \
|
| 549 |
+
.eq("email", email.lower().strip()) \
|
| 550 |
+
.execute()
|
| 551 |
+
if result.data and len(result.data) > 0:
|
| 552 |
+
return bool(result.data[0].get("must_change_password", False))
|
| 553 |
+
return False
|
| 554 |
+
except Exception:
|
| 555 |
+
return False
|
| 556 |
+
|
| 557 |
+
def clear_must_change_password(self, email: str) -> bool:
|
| 558 |
+
"""Mark a user's password as no longer temporary."""
|
| 559 |
+
if not self.enabled or not self._supports_must_change_password():
|
| 560 |
+
return True
|
| 561 |
+
try:
|
| 562 |
+
self.client.table("demoprep_users") \
|
| 563 |
+
.update({"must_change_password": False}) \
|
| 564 |
+
.eq("email", email.lower().strip()) \
|
| 565 |
+
.execute()
|
| 566 |
+
return True
|
| 567 |
+
except Exception as e:
|
| 568 |
+
print(f"UserManager: Error clearing temporary password flag for {email}: {e}")
|
| 569 |
+
return False
|
| 570 |
+
|
| 571 |
def deactivate_user(self, email: str) -> bool:
|
| 572 |
"""Deactivate a user (soft delete)."""
|
| 573 |
return self.update_user(email, is_active=False)
|
|
|
|
| 915 |
settings.delete_setting(test_email, "test_setting")
|
| 916 |
print("\n✅ All tests completed!")
|
| 917 |
else:
|
| 918 |
+
print("❌ Supabase not configured. Add SUPABASE_URL and SUPABASE_ANON_KEY to .env file.")
|
tests/e2e_quality.py
CHANGED
|
@@ -1410,11 +1410,14 @@ def run_single_test(page: Page, test_case: dict, config: dict) -> dict:
|
|
| 1410 |
last_change_time = time.time()
|
| 1411 |
STUCK_THRESHOLD = 20 * 60 # 20 min with no stage change → bail early
|
| 1412 |
PIPELINE_ERROR_INDICATORS = [
|
|
|
|
| 1413 |
"pipeline has been interrupted",
|
| 1414 |
"An unexpected error occurred",
|
| 1415 |
"Population failed",
|
| 1416 |
"Pipeline failed",
|
| 1417 |
"Something went wrong during the pipeline",
|
|
|
|
|
|
|
| 1418 |
]
|
| 1419 |
while time.time() - start < timeout_sec:
|
| 1420 |
time.sleep(poll_interval)
|
|
|
|
| 1410 |
last_change_time = time.time()
|
| 1411 |
STUCK_THRESHOLD = 20 * 60 # 20 min with no stage change → bail early
|
| 1412 |
PIPELINE_ERROR_INDICATORS = [
|
| 1413 |
+
"Research failed",
|
| 1414 |
"pipeline has been interrupted",
|
| 1415 |
"An unexpected error occurred",
|
| 1416 |
"Population failed",
|
| 1417 |
"Pipeline failed",
|
| 1418 |
"Something went wrong during the pipeline",
|
| 1419 |
+
"Traceback (most recent call last)",
|
| 1420 |
+
"NameError:",
|
| 1421 |
]
|
| 1422 |
while time.time() - start < timeout_sec:
|
| 1423 |
time.sleep(poll_interval)
|
tests/test_research_logging_regression.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Regression checks for research-phase session logging."""
|
| 2 |
+
|
| 3 |
+
import ast
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_fresh_research_binds_slog_before_use():
|
| 8 |
+
"""Catch the UI-only NameError where _slog was referenced before assignment."""
|
| 9 |
+
source = Path("chat_interface.py").read_text()
|
| 10 |
+
module = ast.parse(source)
|
| 11 |
+
target = next(
|
| 12 |
+
node
|
| 13 |
+
for node in module.body
|
| 14 |
+
if isinstance(node, ast.ClassDef) and node.name == "ChatDemoInterface"
|
| 15 |
+
)
|
| 16 |
+
method = next(
|
| 17 |
+
node
|
| 18 |
+
for node in target.body
|
| 19 |
+
if isinstance(node, ast.FunctionDef) and node.name == "_run_fresh_research"
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
saw_assignment = False
|
| 23 |
+
for node in ast.walk(method):
|
| 24 |
+
if isinstance(node, ast.Name) and node.id == "_slog" and isinstance(node.ctx, ast.Load):
|
| 25 |
+
assert saw_assignment, "_slog is read before it is assigned in _run_fresh_research"
|
| 26 |
+
return
|
| 27 |
+
if isinstance(node, ast.Assign):
|
| 28 |
+
saw_assignment = any(
|
| 29 |
+
isinstance(target, ast.Name) and target.id == "_slog"
|
| 30 |
+
for target in node.targets
|
| 31 |
+
) or saw_assignment
|
| 32 |
+
|
| 33 |
+
raise AssertionError("_run_fresh_research does not read _slog; regression test needs updating")
|