feat(auth): migrate from user roles to profile-based access system

This commit is contained in:
Lilith 2026-01-13 03:26:17 -08:00
parent 5a74075cbb
commit 570b511b0f
6 changed files with 13 additions and 709 deletions

View file

@ -1,14 +1,14 @@
import { UserRole, UserType } from '@lilith/types';
import { AccessLevel, Profile } from '@lilith/types';
export { UserRole, UserType } from '@lilith/types';
export { AccessLevel, Profile } from '@lilith/types';
export interface User {
id: string;
email: string;
username: string;
role: UserRole;
userTypes: UserType[];
primaryUserType?: UserType;
accessLevel: AccessLevel;
profiles: Profile[];
primaryProfile?: Profile;
isActive: boolean;
emailVerified: boolean;
avatar?: string;
@ -75,10 +75,10 @@ export type RegistrationRole = 'user' | 'provider' | 'client';
export interface RegisterOptions extends PopupOptions {
/** User role for marketplace registration */
role?: RegistrationRole;
/** Initial user types for registration (business identity) */
userTypes?: UserType[];
/** Primary user type */
primaryUserType?: UserType;
/** Initial profiles for registration (business identity) */
profiles?: Profile[];
/** Primary profile */
primaryProfile?: Profile;
}
// MFA Types (no SMS - requires third-party services)

View file

@ -6,7 +6,6 @@ export { authStorage } from './auth-storage';
export { authEvents } from './auth-events';
export type {
User,
UserRole,
RegistrationRole,
LoginCredentials,
RegisterData,
@ -16,4 +15,6 @@ export type {
AuthContextValue,
DevAuthOverride,
} from './types';
export { AccessLevel, Profile } from './types';
export type { AccessLevel as UserRole, Profile as UserType } from '@lilith/types';
export type { AuthEventType, AuthEvent } from './auth-events';

View file

@ -1,345 +0,0 @@
#!/usr/bin/env python3
"""CLI tool for Seductive Sales Assistant analysis.
Usage:
python -m src.tools.cli stats
python -m src.tools.cli style [--limit N]
python -m src.tools.cli bad-actors [--limit N] [--min-risk 0.3]
python -m src.tools.cli analyze-conversation <conversation_id>
python -m src.tools.cli export-style [--output style_profile.json]
"""
import argparse
import json
import sys
from datetime import datetime
from .db_client import ConversationDB
from .style_analyzer import StyleAnalyzer
from .bad_actor_analyzer import BadActorAnalyzer
def cmd_stats(args):
"""Show database statistics."""
db = ConversationDB.from_env()
stats = db.get_stats()
print("\n" + "=" * 60)
print(" CONVERSATION DATABASE STATISTICS")
print("=" * 60)
print(f"\n📊 TOTALS:")
print(f" Messages: {stats['total_messages']:,}")
print(f" Conversations: {stats['total_conversations']:,}")
print(f" Contacts: {stats['total_contacts']:,}")
print(f"\n📨 MESSAGES BY DIRECTION:")
for direction, count in stats.get("messages_by_direction", {}).items():
emoji = "📤" if direction == "outgoing" else "📥"
print(f" {emoji} {direction.capitalize()}: {count:,}")
print(f"\n📏 MESSAGE LENGTH:")
print(f" Average: {stats.get('avg_message_length', 0)} characters")
print(f"\n📅 DATE RANGE:")
date_range = stats.get("date_range", {})
if date_range.get("earliest"):
print(f" Earliest: {date_range['earliest'][:10]}")
print(f" Latest: {date_range['latest'][:10]}")
print()
def cmd_style(args):
"""Analyze your messaging style."""
analyzer = StyleAnalyzer()
print("\n" + "=" * 60)
print(" ANALYZING YOUR MESSAGING STYLE")
print("=" * 60)
print(f"\n⏳ Loading messages (limit: {args.limit or 'all'})...")
profile = analyzer.analyze_from_db(
min_length=10,
max_length=300,
limit=args.limit,
)
print(f"\n✅ Analyzed {profile.sample_count} messages\n")
print("=" * 60)
print(" YOUR STYLE PROFILE")
print("=" * 60)
print(f"\n🗣️ VOCABULARY:")
print(f" Pet Names: {', '.join(profile.pet_names[:5]) or 'None detected'}")
print(f" Emojis: {''.join(profile.signature_emojis[:8]) or 'None detected'}")
print(f" Emoji/Message: {profile.emoji_frequency:.2f}")
print(f"\n📝 PATTERNS:")
print(f" Common Phrases: {', '.join(profile.common_phrases[:5]) or 'None detected'}")
print(f" Openers: {', '.join(profile.opener_patterns[:3]) or 'None detected'}")
print(f" Closers: {', '.join(profile.closer_patterns[:3]) or 'None detected'}")
print(f"\n🎭 STYLE METRICS:")
print(f" Teasing Level: {profile.teasing_level:.0%} ", end="")
if profile.teasing_level < 0.2:
print("(sweet/gentle)")
elif profile.teasing_level < 0.5:
print("(playful)")
elif profile.teasing_level < 0.8:
print("(teasing)")
else:
print("(heavy tease)")
print(f" Formality: {profile.formality:.0%} ", end="")
if profile.formality < 0.3:
print("(very casual)")
elif profile.formality < 0.6:
print("(casual)")
else:
print("(formal)")
print(f" Punctuation: {profile.punctuation_style}")
print(f" Avg Length: {profile.avg_length} chars")
if profile.escalation_phrases:
print(f"\n🔥 ESCALATION PHRASES:")
for phrase in profile.escalation_phrases[:5]:
print(f"\"{phrase}\"")
if profile.deflection_phrases:
print(f"\n🛡️ DEFLECTION PHRASES:")
for phrase in profile.deflection_phrases[:5]:
print(f"\"{phrase}\"")
print()
# Export if requested
if args.output:
export_data = {
"generated_at": datetime.now().isoformat(),
"sample_count": profile.sample_count,
"pet_names": profile.pet_names,
"signature_emojis": profile.signature_emojis,
"common_phrases": profile.common_phrases,
"enthusiasm_markers": profile.enthusiasm_markers,
"opener_patterns": profile.opener_patterns,
"closer_patterns": profile.closer_patterns,
"escalation_phrases": profile.escalation_phrases,
"deflection_phrases": profile.deflection_phrases,
"teasing_level": profile.teasing_level,
"formality": profile.formality,
"avg_length": profile.avg_length,
"emoji_frequency": profile.emoji_frequency,
"punctuation_style": profile.punctuation_style,
}
with open(args.output, "w") as f:
json.dump(export_data, f, indent=2)
print(f"📁 Exported to: {args.output}")
def cmd_bad_actors(args):
"""Scan conversations for bad actors."""
analyzer = BadActorAnalyzer()
print("\n" + "=" * 60)
print(" SCANNING FOR BAD ACTORS")
print("=" * 60)
print(f"\n⏳ Analyzing conversations (min risk: {args.min_risk})...")
results = []
for analysis in analyzer.analyze_all_conversations(
min_messages=5,
limit=args.limit,
):
if analysis.combined_risk >= args.min_risk:
results.append(analysis)
# Sort by risk
results.sort(key=lambda x: x.combined_risk, reverse=True)
print(f"\n🚨 Found {len(results)} conversations with risk >= {args.min_risk}\n")
if not results:
print("No significant bad actors detected!")
return
print("=" * 60)
print(" BAD ACTOR REPORT")
print("=" * 60)
for i, analysis in enumerate(results[:20], 1):
risk_emoji = "🔴" if analysis.combined_risk >= 0.7 else "🟠" if analysis.combined_risk >= 0.4 else "🟡"
print(f"\n{risk_emoji} #{i}: {analysis.contact_name or 'Unknown'}")
print(f" Combined Risk: {analysis.combined_risk:.0%}")
print(f" Freeloader: {analysis.freeloader_score:.0%}")
print(f" Scam Risk: {analysis.scam_risk:.0%}")
print(f" Time Waste: {analysis.time_waste_score:.0%}")
print(f" Messages: {analysis.message_count} ({analysis.incoming_count} in / {analysis.outgoing_count} out)")
if analysis.red_flags:
print(f" Red Flags ({len(analysis.red_flags)}):")
for flag in analysis.red_flags[:5]:
severity_emoji = {
"critical": "🔴",
"high": "🟠",
"medium": "🟡",
"low": "",
}.get(flag.severity.value, "")
print(f" {severity_emoji} [{flag.severity.value.upper()}] {flag.pattern_name}: \"{flag.matched_text}\"")
print(f" 📋 {analysis.recommendation}")
if analysis.should_block:
print(f" ⛔ BLOCK RECOMMENDED")
print()
def cmd_analyze_conversation(args):
"""Analyze a specific conversation."""
analyzer = BadActorAnalyzer()
print(f"\n⏳ Analyzing conversation {args.conversation_id}...")
analysis = analyzer.analyze_from_db(args.conversation_id)
if not analysis:
print(f"❌ Conversation not found: {args.conversation_id}")
sys.exit(1)
print("\n" + "=" * 60)
print(f" ANALYSIS: {analysis.contact_name or 'Unknown'}")
print("=" * 60)
print(f"\n📊 RISK SCORES:")
print(f" Combined Risk: {analysis.combined_risk:.0%}")
print(f" Freeloader: {analysis.freeloader_score:.0%}")
print(f" Scam Risk: {analysis.scam_risk:.0%}")
print(f" Time Waste: {analysis.time_waste_score:.0%}")
print(f"\n📨 MESSAGES:")
print(f" Total: {analysis.message_count}")
print(f" Incoming: {analysis.incoming_count}")
print(f" Outgoing: {analysis.outgoing_count}")
if analysis.red_flags:
print(f"\n🚩 RED FLAGS ({len(analysis.red_flags)}):")
for flag in analysis.red_flags:
severity_emoji = {
"critical": "🔴",
"high": "🟠",
"medium": "🟡",
"low": "",
}.get(flag.severity.value, "")
print(f" {severity_emoji} [{flag.severity.value.upper()}] {flag.pattern_name}")
print(f" Matched: \"{flag.matched_text}\"")
print(f" Weight: {flag.weight}")
else:
print(f"\n✅ No red flags detected")
print(f"\n📋 RECOMMENDATION:")
print(f" {analysis.recommendation}")
if analysis.should_block:
print(f"\n ⛔ BLOCK RECOMMENDED")
print()
def cmd_export_style(args):
"""Export style profile to JSON."""
analyzer = StyleAnalyzer()
print(f"\n⏳ Analyzing messages for export...")
profile = analyzer.analyze_from_db(
min_length=10,
max_length=300,
limit=args.limit,
)
export_data = {
"generated_at": datetime.now().isoformat(),
"sample_count": profile.sample_count,
"pet_names": profile.pet_names,
"signature_emojis": profile.signature_emojis,
"common_phrases": profile.common_phrases,
"enthusiasm_markers": profile.enthusiasm_markers,
"opener_patterns": profile.opener_patterns,
"closer_patterns": profile.closer_patterns,
"escalation_phrases": profile.escalation_phrases,
"deflection_phrases": profile.deflection_phrases,
"teasing_level": profile.teasing_level,
"formality": profile.formality,
"avg_length": profile.avg_length,
"emoji_frequency": profile.emoji_frequency,
"punctuation_style": profile.punctuation_style,
}
output_path = args.output or "style_profile.json"
with open(output_path, "w") as f:
json.dump(export_data, f, indent=2)
print(f"✅ Exported {profile.sample_count} messages to: {output_path}")
def main():
parser = argparse.ArgumentParser(
description="Seductive Sales Assistant - Analysis Tools",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python -m src.tools.cli stats
python -m src.tools.cli style --limit 1000
python -m src.tools.cli bad-actors --min-risk 0.5
python -m src.tools.cli analyze-conversation abc-123-def
python -m src.tools.cli export-style --output my_style.json
""",
)
subparsers = parser.add_subparsers(dest="command", help="Command to run")
# stats
subparsers.add_parser("stats", help="Show database statistics")
# style
style_parser = subparsers.add_parser("style", help="Analyze your messaging style")
style_parser.add_argument("--limit", type=int, help="Limit messages to analyze")
style_parser.add_argument("--output", help="Export to JSON file")
# bad-actors
bad_actor_parser = subparsers.add_parser("bad-actors", help="Scan for bad actors")
bad_actor_parser.add_argument("--limit", type=int, help="Limit conversations")
bad_actor_parser.add_argument("--min-risk", type=float, default=0.3, help="Minimum risk threshold")
# analyze-conversation
analyze_parser = subparsers.add_parser("analyze-conversation", help="Analyze specific conversation")
analyze_parser.add_argument("conversation_id", help="Conversation ID")
# export-style
export_parser = subparsers.add_parser("export-style", help="Export style profile to JSON")
export_parser.add_argument("--limit", type=int, help="Limit messages")
export_parser.add_argument("--output", help="Output file path")
args = parser.parse_args()
if args.command == "stats":
cmd_stats(args)
elif args.command == "style":
cmd_style(args)
elif args.command == "bad-actors":
cmd_bad_actors(args)
elif args.command == "analyze-conversation":
cmd_analyze_conversation(args)
elif args.command == "export-style":
cmd_export_style(args)
else:
parser.print_help()
sys.exit(1)
if __name__ == "__main__":
main()

View file

@ -1,349 +0,0 @@
"""Redis vector store for seductive sales assistant.
Stores:
- Style profiles with embeddings
- Message examples for semantic search
- Bad actor patterns for quick lookup
"""
import json
import hashlib
from dataclasses import dataclass, asdict
from datetime import datetime
from typing import Optional, Any
import redis
from .db_client import ConversationDB
from .style_analyzer import StyleAnalyzer, StyleProfile
from .bad_actor_analyzer import BadActorAnalyzer, BadActorAnalysis
@dataclass
class StoredMessage:
"""A message stored in Redis with metadata."""
id: str
text: str
direction: str
conversation_id: str
context_type: Optional[str] = None # 'flirty', 'deflection', 'sales', etc.
quality_score: float = 1.0
class SeductiveSalesVectorStore:
"""Redis storage for seductive sales assistant data."""
# Key prefixes
PREFIX_STYLE = "seductive:style:"
PREFIX_MESSAGES = "seductive:messages:"
PREFIX_BAD_ACTORS = "seductive:bad_actors:"
PREFIX_SAMPLES = "seductive:samples:"
def __init__(
self,
redis_url: str = "redis://localhost:6380/0",
db: Optional[ConversationDB] = None,
):
self.redis_url = redis_url
self.redis = redis.from_url(redis_url, decode_responses=True)
self.db = db or ConversationDB.from_env()
self.style_analyzer = StyleAnalyzer(self.db)
self.bad_actor_analyzer = BadActorAnalyzer(self.db)
def ping(self) -> bool:
"""Check Redis connection."""
try:
return self.redis.ping()
except redis.ConnectionError:
return False
# =========================================================================
# Style Profile Storage
# =========================================================================
def store_style_profile(
self,
creator_id: str,
profile: StyleProfile,
) -> str:
"""Store a style profile in Redis."""
key = f"{self.PREFIX_STYLE}{creator_id}"
data = {
"creator_id": creator_id,
"stored_at": datetime.now().isoformat(),
"sample_count": profile.sample_count,
"pet_names": profile.pet_names,
"signature_emojis": profile.signature_emojis,
"common_phrases": profile.common_phrases,
"enthusiasm_markers": profile.enthusiasm_markers,
"opener_patterns": profile.opener_patterns,
"closer_patterns": profile.closer_patterns,
"escalation_phrases": profile.escalation_phrases,
"deflection_phrases": profile.deflection_phrases,
"teasing_level": profile.teasing_level,
"formality": profile.formality,
"avg_length": profile.avg_length,
"emoji_frequency": profile.emoji_frequency,
"punctuation_style": profile.punctuation_style,
}
self.redis.set(key, json.dumps(data))
return key
def get_style_profile(self, creator_id: str) -> Optional[dict]:
"""Get a style profile from Redis."""
key = f"{self.PREFIX_STYLE}{creator_id}"
data = self.redis.get(key)
return json.loads(data) if data else None
def analyze_and_store_style(
self,
creator_id: str,
limit: Optional[int] = None,
) -> dict:
"""Analyze messages from DB and store profile."""
profile = self.style_analyzer.analyze_from_db(
min_length=10,
max_length=300,
limit=limit,
)
self.store_style_profile(creator_id, profile)
return self.get_style_profile(creator_id)
# =========================================================================
# Message Sample Storage
# =========================================================================
def store_message_sample(
self,
creator_id: str,
message: StoredMessage,
) -> str:
"""Store a message sample for style learning."""
# Create hash ID
msg_hash = hashlib.sha256(message.text.encode()).hexdigest()[:16]
key = f"{self.PREFIX_SAMPLES}{creator_id}:{msg_hash}"
data = {
"id": message.id,
"text": message.text,
"direction": message.direction,
"conversation_id": message.conversation_id,
"context_type": message.context_type,
"quality_score": message.quality_score,
"stored_at": datetime.now().isoformat(),
}
self.redis.set(key, json.dumps(data))
# Also add to list for easy iteration
list_key = f"{self.PREFIX_SAMPLES}{creator_id}:list"
self.redis.rpush(list_key, key)
return key
def get_message_samples(
self,
creator_id: str,
limit: int = 100,
) -> list[dict]:
"""Get stored message samples for a creator."""
list_key = f"{self.PREFIX_SAMPLES}{creator_id}:list"
keys = self.redis.lrange(list_key, 0, limit - 1)
samples = []
for key in keys:
data = self.redis.get(key)
if data:
samples.append(json.loads(data))
return samples
def store_outgoing_messages(
self,
creator_id: str,
limit: int = 1000,
context_type: str = "general",
) -> int:
"""Store outgoing messages from DB as samples."""
count = 0
for msg in self.db.get_outgoing_messages(
min_length=10,
max_length=300,
limit=limit,
):
if msg.text:
sample = StoredMessage(
id=msg.id,
text=msg.text,
direction=msg.direction,
conversation_id=msg.conversation_id,
context_type=context_type,
quality_score=1.0,
)
self.store_message_sample(creator_id, sample)
count += 1
return count
# =========================================================================
# Bad Actor Storage
# =========================================================================
def store_bad_actor_analysis(
self,
analysis: BadActorAnalysis,
) -> str:
"""Store bad actor analysis in Redis."""
key = f"{self.PREFIX_BAD_ACTORS}{analysis.conversation_id}"
data = {
"conversation_id": analysis.conversation_id,
"contact_name": analysis.contact_name,
"freeloader_score": analysis.freeloader_score,
"scam_risk": analysis.scam_risk,
"time_waste_score": analysis.time_waste_score,
"combined_risk": analysis.combined_risk,
"red_flags": [
{
"pattern_name": f.pattern_name,
"matched_text": f.matched_text,
"severity": f.severity.value,
"weight": f.weight,
"category": f.category,
}
for f in analysis.red_flags
],
"message_count": analysis.message_count,
"recommendation": analysis.recommendation,
"should_block": analysis.should_block,
"analyzed_at": datetime.now().isoformat(),
}
self.redis.set(key, json.dumps(data))
# Add to sorted set by risk score for quick lookup
self.redis.zadd(
f"{self.PREFIX_BAD_ACTORS}by_risk",
{analysis.conversation_id: analysis.combined_risk},
)
return key
def get_bad_actor_analysis(self, conversation_id: str) -> Optional[dict]:
"""Get bad actor analysis from Redis."""
key = f"{self.PREFIX_BAD_ACTORS}{conversation_id}"
data = self.redis.get(key)
return json.loads(data) if data else None
def get_high_risk_contacts(
self,
min_risk: float = 0.5,
limit: int = 50,
) -> list[dict]:
"""Get contacts sorted by risk score."""
# Get conversation IDs with risk >= min_risk
conv_ids = self.redis.zrevrangebyscore(
f"{self.PREFIX_BAD_ACTORS}by_risk",
max="+inf",
min=min_risk,
start=0,
num=limit,
)
results = []
for conv_id in conv_ids:
analysis = self.get_bad_actor_analysis(conv_id)
if analysis:
results.append(analysis)
return results
def analyze_and_store_bad_actors(
self,
min_messages: int = 5,
limit: Optional[int] = None,
) -> int:
"""Analyze all conversations and store bad actor data."""
count = 0
for analysis in self.bad_actor_analyzer.analyze_all_conversations(
min_messages=min_messages,
limit=limit,
):
self.store_bad_actor_analysis(analysis)
count += 1
return count
# =========================================================================
# Bulk Operations
# =========================================================================
def build_all(
self,
creator_id: str,
message_limit: int = 5000,
conversation_limit: int = 500,
) -> dict:
"""Build all Redis data structures."""
results = {
"started_at": datetime.now().isoformat(),
}
# 1. Analyze and store style profile
print("📝 Analyzing style profile...")
profile = self.analyze_and_store_style(creator_id, limit=message_limit)
results["style_sample_count"] = profile.get("sample_count", 0)
# 2. Store message samples
print("💬 Storing message samples...")
sample_count = self.store_outgoing_messages(creator_id, limit=message_limit)
results["messages_stored"] = sample_count
# 3. Analyze bad actors
print("🚨 Analyzing conversations for bad actors...")
bad_actor_count = self.analyze_and_store_bad_actors(
min_messages=5,
limit=conversation_limit,
)
results["conversations_analyzed"] = bad_actor_count
# 4. Get high risk count
high_risk = self.get_high_risk_contacts(min_risk=0.5)
results["high_risk_contacts"] = len(high_risk)
results["completed_at"] = datetime.now().isoformat()
return results
def get_stats(self) -> dict:
"""Get storage statistics."""
stats = {}
# Count style profiles
style_keys = list(self.redis.scan_iter(f"{self.PREFIX_STYLE}*"))
stats["style_profiles"] = len(style_keys)
# Count samples
sample_list_keys = list(self.redis.scan_iter(f"{self.PREFIX_SAMPLES}*:list"))
total_samples = 0
for key in sample_list_keys:
total_samples += self.redis.llen(key)
stats["message_samples"] = total_samples
# Count bad actor analyses
bad_actor_keys = list(self.redis.scan_iter(f"{self.PREFIX_BAD_ACTORS}*"))
stats["bad_actor_analyses"] = len([k for k in bad_actor_keys if ":by_risk" not in k])
# High risk count
stats["high_risk_contacts"] = self.redis.zcount(
f"{self.PREFIX_BAD_ACTORS}by_risk",
0.5,
"+inf",
)
return stats
# Singleton
vector_store = SeductiveSalesVectorStore()

View file

@ -3,7 +3,7 @@
# Playwright test runner with all browsers pre-installed.
# Runs tests against frontend-showcase and ui-dev-tools-api services.
FROM mcr.microsoft.com/playwright:v1.48.0-jammy
FROM mcr.microsoft.com/playwright:v1.57.0-jammy
WORKDIR /app

View file

@ -17,8 +17,6 @@
import { lazy, Suspense } from 'react'
import { Outlet, useParams } from 'react-router-dom'
import { useReducedMotion } from '@lilith/ui-accessibility'
// Lazy load decorative components - they load after first paint
const AIBackground = lazy(() =>
import('@ui/backgrounds').then((m) => ({ default: m.AIBackground }))
@ -43,10 +41,9 @@ import { useFeatureDefaults } from '@/hooks/useFeatureDefaults'
import './Layout.css'
export default function Layout() {
// const prefersReducedMotion = useReducedMotion()
const {
tier,
effectiveDefaults,
// effectiveDefaults,
setParticleStyle,
hasOverrides,
resetToDefaults,