feat: add Turnstile to login, improve email templates, and security cleanup
- Add Cloudflare Turnstile verification to login flow - Add API_BASE_URL and APP_BASE_URL to config for environment flexibility - Redesign verification and password reset emails with modern HTML templates - Use config URLs instead of hardcoded domains in auth handlers - Remove sensitive logging from OTK operations for security - Delete unused deployment and draft inspection scripts - Add TURNSTILE_SITE_KEY to Flutter run
This commit is contained in:
parent
c9d8e0c7e6
commit
0954c1e2a3
|
|
@ -1,68 +0,0 @@
|
||||||
# Deploy all Edge Functions to Supabase
|
|
||||||
# Run this after updating supabase-js version
|
|
||||||
|
|
||||||
Write-Host "=== Deploying All Edge Functions ===" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "This will deploy all functions with --no-verify-jwt (default for this script)" -ForegroundColor Yellow
|
|
||||||
Write-Host ""
|
|
||||||
|
|
||||||
$functions = @(
|
|
||||||
"appreciate",
|
|
||||||
"block",
|
|
||||||
"calculate-harmony",
|
|
||||||
"cleanup-expired-content",
|
|
||||||
"consume_one_time_prekey",
|
|
||||||
"create-beacon",
|
|
||||||
"deactivate-account",
|
|
||||||
"delete-account",
|
|
||||||
"e2ee_session_manager",
|
|
||||||
"feed-personal",
|
|
||||||
"feed-sojorn",
|
|
||||||
"follow",
|
|
||||||
"manage-post",
|
|
||||||
"notifications",
|
|
||||||
"profile",
|
|
||||||
"profile-posts",
|
|
||||||
"public-config",
|
|
||||||
"publish-comment",
|
|
||||||
"publish-post",
|
|
||||||
"push-notification",
|
|
||||||
"report",
|
|
||||||
"save",
|
|
||||||
"search",
|
|
||||||
"sign-media",
|
|
||||||
"signup",
|
|
||||||
"tone-check",
|
|
||||||
"trending",
|
|
||||||
"upload-image"
|
|
||||||
)
|
|
||||||
|
|
||||||
$totalFunctions = $functions.Count
|
|
||||||
$currentFunction = 0
|
|
||||||
$noVerifyJwt = "--no-verify-jwt"
|
|
||||||
|
|
||||||
foreach ($func in $functions) {
|
|
||||||
$currentFunction++
|
|
||||||
Write-Host "[$currentFunction/$totalFunctions] Deploying $func..." -ForegroundColor Yellow
|
|
||||||
|
|
||||||
try {
|
|
||||||
supabase functions deploy $func $noVerifyJwt 2>&1 | Out-Null
|
|
||||||
if ($LASTEXITCODE -eq 0) {
|
|
||||||
Write-Host " OK $func deployed successfully" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host " FAILED to deploy $func" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch {
|
|
||||||
Write-Host " ERROR deploying $func : $_" -ForegroundColor Red
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "=== Deployment Complete ===" -ForegroundColor Cyan
|
|
||||||
Write-Host ""
|
|
||||||
Write-Host "Next steps:" -ForegroundColor Yellow
|
|
||||||
Write-Host "1. Restart your Flutter app" -ForegroundColor Yellow
|
|
||||||
Write-Host "2. Sign in again" -ForegroundColor Yellow
|
|
||||||
Write-Host "3. The JWT 401 errors should be gone!" -ForegroundColor Green
|
|
||||||
Write-Host ""
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
// Load .env manualy since config might rely on it
|
|
||||||
godotenv.Load()
|
|
||||||
|
|
||||||
dbURL := os.Getenv("DATABASE_URL")
|
|
||||||
if dbURL == "" {
|
|
||||||
// Fallback for local dev if .env not loaded or empty
|
|
||||||
dbURL = "postgresql://postgres:password@localhost:5432/sojorn_db" // Guessing name?
|
|
||||||
// Wait, user used 'postgres' db in psql command attempts.
|
|
||||||
// Let's assume standard postgres connection string.
|
|
||||||
// The psql command failed so I don't know the DB Name.
|
|
||||||
// But main.go loads config. Let's rely on that if possible, but importing main's config might be circular or complex if not in a lib.
|
|
||||||
// I'll try to use the one from config.LoadConfig() by importing github.com/patbritton/sojorn-backend/internal/config
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -36,6 +36,8 @@ type Config struct {
|
||||||
R2MediaBucket string
|
R2MediaBucket string
|
||||||
R2VideoBucket string
|
R2VideoBucket string
|
||||||
TurnstileSecretKey string
|
TurnstileSecretKey string
|
||||||
|
APIBaseURL string
|
||||||
|
AppBaseURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadConfig() *Config {
|
func LoadConfig() *Config {
|
||||||
|
|
@ -78,6 +80,8 @@ func LoadConfig() *Config {
|
||||||
R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"),
|
R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"),
|
||||||
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
|
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
|
||||||
TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""),
|
TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""),
|
||||||
|
APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"),
|
||||||
|
AppBaseURL: getEnv("APP_BASE_URL", "https://sojorn.net"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ type RegisterRequest struct {
|
||||||
type LoginRequest struct {
|
type LoginRequest struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email"`
|
||||||
Password string `json:"password" binding:"required"`
|
Password string `json:"password" binding:"required"`
|
||||||
|
TurnstileToken string `json:"turnstile_token" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) Register(c *gin.Context) {
|
func (h *AuthHandler) Register(c *gin.Context) {
|
||||||
|
|
@ -160,6 +161,23 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
}
|
}
|
||||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
|
||||||
|
// Validate Turnstile token
|
||||||
|
turnstileService := services.NewTurnstileService(h.config.TurnstileSecretKey)
|
||||||
|
remoteIP := c.ClientIP()
|
||||||
|
turnstileResp, err := turnstileService.VerifyToken(req.TurnstileToken, remoteIP)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[Auth] Login Turnstile verification failed: %v", err)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Security verification failed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !turnstileResp.Success {
|
||||||
|
errorMsg := turnstileService.GetErrorMessage(turnstileResp.ErrorCodes)
|
||||||
|
log.Printf("[Auth] Login Turnstile validation failed: %s", errorMsg)
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": errorMsg})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
user, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email)
|
user, err := h.repo.GetUserByEmail(c.Request.Context(), req.Email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[Auth] Login failed for %s: user not found", req.Email)
|
log.Printf("[Auth] Login failed for %s: user not found", req.Email)
|
||||||
|
|
@ -234,7 +252,7 @@ func (h *AuthHandler) CompleteOnboarding(c *gin.Context) {
|
||||||
func (h *AuthHandler) VerifyEmail(c *gin.Context) {
|
func (h *AuthHandler) VerifyEmail(c *gin.Context) {
|
||||||
rawToken := c.Query("token")
|
rawToken := c.Query("token")
|
||||||
if rawToken == "" {
|
if rawToken == "" {
|
||||||
c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=invalid_token")
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=invalid_token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,19 +261,19 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
|
||||||
|
|
||||||
userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString)
|
userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=invalid_token")
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=invalid_token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if time.Now().After(expiresAt) {
|
if time.Now().After(expiresAt) {
|
||||||
h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
|
h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
|
||||||
c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=expired")
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=expired")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activate user
|
// Activate user
|
||||||
if err := h.repo.UpdateUserStatus(c.Request.Context(), userID, models.UserStatusActive); err != nil {
|
if err := h.repo.UpdateUserStatus(c.Request.Context(), userID, models.UserStatusActive); err != nil {
|
||||||
c.Redirect(http.StatusFound, "https://sojorn.net/verify-error?reason=server_error")
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verify-error?reason=server_error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -275,7 +293,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
|
||||||
// Cleanup
|
// Cleanup
|
||||||
_ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
|
_ = h.repo.DeleteVerificationToken(c.Request.Context(), hashString)
|
||||||
|
|
||||||
c.Redirect(http.StatusFound, "https://sojorn.net/verified")
|
c.Redirect(http.StatusFound, h.config.AppBaseURL+"/verified?status=success")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) ResendVerificationEmail(c *gin.Context) {
|
func (h *AuthHandler) ResendVerificationEmail(c *gin.Context) {
|
||||||
|
|
|
||||||
|
|
@ -559,7 +559,7 @@ func (r *UserRepository) DeleteUsedOTK(ctx context.Context, userID string, keyID
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to delete used OTK: %w", err)
|
return fmt.Errorf("failed to delete used OTK: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("[KEYS] Deleted used OTK #%d for user %s\n", keyID, userID)
|
// OTK deleted successfully
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -613,9 +613,7 @@ func (r *UserRepository) GetSignalKeyBundle(ctx context.Context, userID string)
|
||||||
"key_id": otkID,
|
"key_id": otkID,
|
||||||
"public_key": otkPub,
|
"public_key": otkPub,
|
||||||
}
|
}
|
||||||
fmt.Printf("[KEYS] Retrieved OTK #%d for user %s\n", otkID, userID)
|
// OTK retrieved - not logging user ID for security
|
||||||
} else {
|
|
||||||
fmt.Printf("[KEYS] No OTKs available for user %s\n", userID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle NULL values properly
|
// Handle NULL values properly
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -51,65 +52,55 @@ type sendPulseIdentity struct {
|
||||||
|
|
||||||
func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error {
|
func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error {
|
||||||
subject := "Verify your Sojorn account"
|
subject := "Verify your Sojorn account"
|
||||||
verifyURL := fmt.Sprintf("https://api.sojorn.net/api/v1/auth/verify?token=%s", token)
|
// Ensure we don't double up on /api/v1 if it's already in the config
|
||||||
|
apiBase := strings.TrimSuffix(s.config.APIBaseURL, "/api/v1")
|
||||||
|
verifyURL := fmt.Sprintf("%s/api/v1/auth/verify?token=%s", apiBase, token)
|
||||||
|
|
||||||
body := fmt.Sprintf(`
|
title := "Email Verification"
|
||||||
<!DOCTYPE html>
|
header := fmt.Sprintf("Hey %s! 👋", toName)
|
||||||
<html>
|
if toName == "" {
|
||||||
<head>
|
header = "Hey there! 👋"
|
||||||
<style>
|
}
|
||||||
body { font-family: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; background-color: #f4f4f5; }
|
|
||||||
.wrapper { padding: 40px 20px; }
|
|
||||||
.container { max-width: 500px; margin: 0 auto; background: #ffffff; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); }
|
|
||||||
.header { background: #09090b; padding: 30px; text-align: center; }
|
|
||||||
.content { padding: 40px; text-align: center; color: #3f3f46; line-height: 1.6; }
|
|
||||||
.logo { width: 120px; height: auto; margin-bottom: 10px; }
|
|
||||||
h1 { color: #18181b; font-size: 24px; font-weight: 700; margin-bottom: 16px; }
|
|
||||||
p { margin-bottom: 24px; font-size: 16px; }
|
|
||||||
.button { display: inline-block; padding: 14px 32px; background-color: #10B981; color: #ffffff !important; text-decoration: none; border-radius: 12px; font-weight: 600; font-size: 16px; transition: background-color 0.2s; }
|
|
||||||
.footer { padding: 24px; text-align: center; font-size: 14px; color: #71717a; border-top: 1px solid #f4f4f5; }
|
|
||||||
.link { color: #10B981; text-decoration: none; word-break: break-all; font-size: 12px; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="wrapper">
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<img src="https://sojorn.net/web.png" alt="Sojorn" class="logo">
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<h1>Welcome to Sojorn, %s</h1>
|
|
||||||
<p>Thanks for signing up! To get started, please verify your email address by clicking the button below.</p>
|
|
||||||
<a href="%s" class="button">Verify Email Address</a>
|
|
||||||
<p style="margin-top: 32px; font-size: 14px; color: #a1a1aa;">If the button doesn't work, copy and paste this link into your browser:</p>
|
|
||||||
<a href="%s" class="link">%s</a>
|
|
||||||
</div>
|
|
||||||
<div class="footer">
|
|
||||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
|
||||||
<p>© 2026 Sojorn. All rights reserved.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`, toName, verifyURL, verifyURL, verifyURL)
|
|
||||||
|
|
||||||
return s.sendEmail(toEmail, toName, subject, body, "Verify your Sojorn account: "+verifyURL)
|
content := `
|
||||||
|
<p>Welcome to Sojorn — your vibrant new social space. We're thrilled to have you join our community!</p>
|
||||||
|
<p>To get started in the app, please verify your email address by clicking the button below:</p>
|
||||||
|
`
|
||||||
|
|
||||||
|
footer := `
|
||||||
|
<div style="background: #F9FAFB; border-radius: 12px; padding: 20px; margin-top: 24px; text-align: left;">
|
||||||
|
<p style="font-size: 13px; color: #9CA3AF; margin-bottom: 8px;">If the button doesn't work, copy and paste this link into your browser:</p>
|
||||||
|
<a href="%s" style="color: #4338CA; text-decoration: underline; word-break: break-all; font-size: 12px; font-weight: 500;">%s</a>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
footer = fmt.Sprintf(footer, verifyURL, verifyURL)
|
||||||
|
|
||||||
|
htmlBody := s.buildHTMLEmail(title, header, content, verifyURL, "Verify My Email", footer)
|
||||||
|
|
||||||
|
textBody := fmt.Sprintf("Welcome to Sojorn! Please verify your email by clicking here: %s", verifyURL)
|
||||||
|
|
||||||
|
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error {
|
func (s *EmailService) SendPasswordResetEmail(toEmail, toName, token string) error {
|
||||||
subject := "Reset your Sojorn password"
|
subject := "Reset your Sojorn password"
|
||||||
resetURL := fmt.Sprintf("https://sojorn.net/reset-password?token=%s", token)
|
resetURL := fmt.Sprintf("%s/reset-password?token=%s", s.config.AppBaseURL, token)
|
||||||
|
|
||||||
body := fmt.Sprintf(`
|
title := "Password Reset"
|
||||||
<h2>Reset Password for %s</h2>
|
header := "Reset your password"
|
||||||
<p>You requested a password reset. Click the link below to set a new password:</p>
|
content := fmt.Sprintf(`
|
||||||
<p><a href="%s" style="padding: 10px 20px; background-color: #dc3545; color: white; text-decoration: none; border-radius: 5px;">Reset Password</a></p>
|
<p>Hey %s,</p>
|
||||||
<p>This link expires in 1 hour.</p>
|
<p>You requested a password reset for your Sojorn account. Click the button below to set a new password:</p>
|
||||||
<p>If you did not request this, please ignore this email.</p>
|
`, toName)
|
||||||
`, toName, resetURL)
|
|
||||||
|
|
||||||
return s.sendEmail(toEmail, toName, subject, body, "Reset your password: "+resetURL)
|
footer := `
|
||||||
|
<p style="color: #9CA3AF; font-size: 13px; margin-top: 24px;">This link expires in 1 hour. If you did not request this, you can safely ignore this email.</p>
|
||||||
|
`
|
||||||
|
|
||||||
|
htmlBody := s.buildHTMLEmail(title, header, content, resetURL, "Reset Password", footer)
|
||||||
|
textBody := fmt.Sprintf("Reset your Sojorn password: %s", resetURL)
|
||||||
|
|
||||||
|
return s.sendEmail(toEmail, toName, subject, htmlBody, textBody)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *EmailService) sendEmail(toEmail, toName, subject, htmlBody, textBody string) error {
|
func (s *EmailService) sendEmail(toEmail, toName, subject, htmlBody, textBody string) error {
|
||||||
|
|
@ -137,7 +128,15 @@ func (s *EmailService) sendEmail(toEmail, toName, subject, htmlBody, textBody st
|
||||||
s.config.SMTPPort,
|
s.config.SMTPPort,
|
||||||
)
|
)
|
||||||
|
|
||||||
err := emailSender.SendPlainEmail([]string{toEmail}, nil, subject, htmlBody, nil)
|
// SMTP Fallback - Send HTML email
|
||||||
|
err := emailSender.SendHTMLEmail(
|
||||||
|
"Sojorn", // from name
|
||||||
|
[]string{toEmail}, // recipients
|
||||||
|
nil, // cc
|
||||||
|
subject, // subject
|
||||||
|
htmlBody, // html body
|
||||||
|
nil, // attachments
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to send email via SMTP")
|
log.Error().Err(err).Msg("Failed to send email via SMTP")
|
||||||
return err
|
return err
|
||||||
|
|
@ -256,3 +255,51 @@ func (s *EmailService) AddSubscriber(email, name string) {
|
||||||
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first
|
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first
|
||||||
// Endpoint: POST /addressbooks/{id}/emails
|
// Endpoint: POST /addressbooks/{id}/emails
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *EmailService) buildHTMLEmail(title, header, content, buttonURL, buttonText, footer string) string {
|
||||||
|
return fmt.Sprintf(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>%s</title>
|
||||||
|
</head>
|
||||||
|
<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; background-color: #F3F4F6;">
|
||||||
|
<div style="padding: 40px 20px; background-color: #F3F4F6;">
|
||||||
|
<div style="max-width: 520px; margin: 0 auto; background-color: #ffffff; border-radius: 24px; overflow: hidden; box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);">
|
||||||
|
<!-- Header -->
|
||||||
|
<div style="background: linear-gradient(135deg, #4338CA 0%%, #6366F1 100%%); padding: 40px; text-align: center;">
|
||||||
|
<img src="https://sojorn.net/web.png" alt="Sojorn" style="width: 80px; height: 80px; border-radius: 20px; margin-bottom: 16px;">
|
||||||
|
<div style="color: #ffffff; font-size: 12px; font-weight: 600; letter-spacing: 1px; text-transform: uppercase; opacity: 0.9;">%s</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div style="padding: 40px; text-align: center; color: #374151;">
|
||||||
|
<h1 style="color: #1F2937; font-size: 24px; font-weight: 700; margin-bottom: 16px;">%s</h1>
|
||||||
|
<div style="font-size: 16px; line-height: 1.6; color: #4B5563; margin-bottom: 32px;">
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="%s" style="display: inline-block; padding: 16px 40px; background-color: #4338CA; color: #ffffff; text-decoration: none; border-radius: 12px; font-weight: 600; font-size: 16px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
|
||||||
|
%s
|
||||||
|
</a>
|
||||||
|
|
||||||
|
%s
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div style="padding: 32px; text-align: center; background-color: #F9FAFB; border-top: 1px solid #E5E7EB;">
|
||||||
|
<p style="font-size: 12px; color: #9CA3AF; margin-bottom: 8px;">© 2026 Sojorn by MPLS LLC. All rights reserved.</p>
|
||||||
|
<div style="font-size: 12px; color: #9CA3AF;">
|
||||||
|
<a href="https://sojorn.net" style="color: #9CA3AF; text-decoration: none; margin: 0 8px;">Website</a> •
|
||||||
|
<a href="https://sojorn.net/privacy" style="color: #9CA3AF; text-decoration: none; margin: 0 8px;">Privacy</a> •
|
||||||
|
<a href="https://sojorn.net/terms" style="color: #9CA3AF; text-decoration: none; margin: 0 8px;">Terms</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`, title, title, header, content, buttonURL, buttonText, footer)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,7 @@ $defineArgs = @(
|
||||||
|
|
||||||
$optionalDefines = @(
|
$optionalDefines = @(
|
||||||
'FIREBASE_WEB_VAPID_KEY',
|
'FIREBASE_WEB_VAPID_KEY',
|
||||||
'SUPABASE_PUBLISHABLE_KEY',
|
'TURNSTILE_SITE_KEY'
|
||||||
'SUPABASE_SECRET_KEY',
|
|
||||||
'SUPABASE_JWT_KID',
|
|
||||||
'SUPABASE_JWKS_URI'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
foreach ($opt in $optionalDefines) {
|
foreach ($opt in $optionalDefines) {
|
||||||
|
|
@ -57,6 +54,7 @@ foreach ($opt in $optionalDefines) {
|
||||||
Push-Location (Join-Path $PSScriptRoot "sojorn_app")
|
Push-Location (Join-Path $PSScriptRoot "sojorn_app")
|
||||||
try {
|
try {
|
||||||
flutter run @defineArgs @Args
|
flutter run @defineArgs @Args
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ $values = Parse-Env $EnvPath
|
||||||
|
|
||||||
# Collect dart-defines we actually use on web.
|
# Collect dart-defines we actually use on web.
|
||||||
$defineArgs = @()
|
$defineArgs = @()
|
||||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY')
|
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||||
foreach ($k in $keysOfInterest) {
|
foreach ($k in $keysOfInterest) {
|
||||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ $values = Parse-Env $EnvPath
|
||||||
|
|
||||||
# Collect dart-defines we actually use on web.
|
# Collect dart-defines we actually use on web.
|
||||||
$defineArgs = @()
|
$defineArgs = @()
|
||||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY')
|
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||||
foreach ($k in $keysOfInterest) {
|
foreach ($k in $keysOfInterest) {
|
||||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ $values = Parse-Env $EnvPath
|
||||||
|
|
||||||
# Collect dart-defines we actually use on Windows.
|
# Collect dart-defines we actually use on Windows.
|
||||||
$defineArgs = @()
|
$defineArgs = @()
|
||||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY')
|
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||||
foreach ($k in $keysOfInterest) {
|
foreach ($k in $keysOfInterest) {
|
||||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||||
|
|
|
||||||
BIN
sojorn_app/assets/images/mplslarge.png
Normal file
BIN
sojorn_app/assets/images/mplslarge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
sojorn_app/assets/images/mplsmedium.png
Normal file
BIN
sojorn_app/assets/images/mplsmedium.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.9 KiB |
BIN
sojorn_app/assets/images/mplssmall.png
Normal file
BIN
sojorn_app/assets/images/mplssmall.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
|
|
@ -251,8 +251,11 @@ class Post {
|
||||||
videoUrl: json['video_url'] as String?,
|
videoUrl: json['video_url'] as String?,
|
||||||
thumbnailUrl: json['thumbnail_url'] as String?,
|
thumbnailUrl: json['thumbnail_url'] as String?,
|
||||||
durationMs: _parseInt(json['duration_ms']),
|
durationMs: _parseInt(json['duration_ms']),
|
||||||
hasVideoContent: json['has_video_content'] as bool?,
|
hasVideoContent: json['has_video_content'] as bool? ??
|
||||||
|
((json['video_url'] as String?)?.isNotEmpty == true ||
|
||||||
|
(json['image_url'] as String?)?.toLowerCase().endsWith('.mp4') == true),
|
||||||
bodyFormat: json['body_format'] as String?,
|
bodyFormat: json['body_format'] as String?,
|
||||||
|
|
||||||
backgroundId: json['background_id'] as String?,
|
backgroundId: json['background_id'] as String?,
|
||||||
tags: _parseTags(json['tags']),
|
tags: _parseTags(json['tags']),
|
||||||
reactions: _parseReactions(
|
reactions: _parseReactions(
|
||||||
|
|
|
||||||
|
|
@ -87,9 +87,11 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
|
||||||
videoFile,
|
videoFile,
|
||||||
onProgress: (p) => state = state.copyWith(progress: 0.1 + (p * 0.4)),
|
onProgress: (p) => state = state.copyWith(progress: 0.1 + (p * 0.4)),
|
||||||
);
|
);
|
||||||
|
print('Video uploaded successfully: $videoUrl');
|
||||||
|
|
||||||
state = state.copyWith(progress: 0.5);
|
state = state.copyWith(progress: 0.5);
|
||||||
|
|
||||||
|
|
||||||
// Upload thumbnail to Go Backend / R2
|
// Upload thumbnail to Go Backend / R2
|
||||||
String? thumbnailUrl;
|
String? thumbnailUrl;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -100,8 +100,11 @@ class AppRoutes {
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: quips,
|
path: quips,
|
||||||
builder: (_, __) => const QuipsFeedScreen(),
|
builder: (_, state) => QuipsFeedScreen(
|
||||||
|
initialPostId: state.uri.queryParameters['postId'],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
StatefulShellBranch(
|
StatefulShellBranch(
|
||||||
|
|
@ -195,7 +198,16 @@ class AppRoutes {
|
||||||
return '$baseUrl/u/$username';
|
return '$baseUrl/u/$username';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get shareable URL for a post (future implementation)
|
/// Get shareable URL for a quip
|
||||||
|
/// Returns: https://sojorn.net/quips?postId=postid
|
||||||
|
static String getQuipUrl(
|
||||||
|
String postId, {
|
||||||
|
String baseUrl = 'https://sojorn.net',
|
||||||
|
}) {
|
||||||
|
return '$baseUrl/quips?postId=$postId';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get shareable URL for a post
|
||||||
/// Returns: https://sojorn.net/p/postid
|
/// Returns: https://sojorn.net/p/postid
|
||||||
static String getPostUrl(
|
static String getPostUrl(
|
||||||
String postId, {
|
String postId, {
|
||||||
|
|
@ -204,6 +216,7 @@ class AppRoutes {
|
||||||
return '$baseUrl/p/$postId';
|
return '$baseUrl/p/$postId';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Get shareable URL for a beacon location
|
/// Get shareable URL for a beacon location
|
||||||
/// Returns: https://sojorn.net/beacon?lat=...&long=...
|
/// Returns: https://sojorn.net/beacon?lat=...&long=...
|
||||||
static String getBeaconUrl(
|
static String getBeaconUrl(
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,13 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
bool _saveCredentials = true;
|
bool _saveCredentials = true;
|
||||||
String? _storedEmail;
|
String? _storedEmail;
|
||||||
String? _storedPassword;
|
String? _storedPassword;
|
||||||
String? _captchaToken;
|
String? _turnstileToken;
|
||||||
|
|
||||||
|
// Turnstile site key from environment or default production key
|
||||||
|
static const String _turnstileSiteKey = String.fromEnvironment(
|
||||||
|
'TURNSTILE_SITE_KEY',
|
||||||
|
defaultValue: '0x4AAAAAACYFlz_g513d6xAf', // Cloudflare production key
|
||||||
|
);
|
||||||
|
|
||||||
static const _savedEmailKey = 'saved_login_email';
|
static const _savedEmailKey = 'saved_login_email';
|
||||||
static const _savedPasswordKey = 'saved_login_password';
|
static const _savedPasswordKey = 'saved_login_password';
|
||||||
|
|
@ -94,14 +100,13 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
bool get _canUseBiometricLogin =>
|
bool get _canUseBiometricLogin =>
|
||||||
_supportsBiometric &&
|
_supportsBiometric &&
|
||||||
_hasStoredCredentials &&
|
_hasStoredCredentials &&
|
||||||
!_isBiometricAuthenticating;
|
!_isBiometricAuthenticating &&
|
||||||
|
_turnstileToken != null; // Require Turnstile for biometric too
|
||||||
|
|
||||||
Future<void> _signIn() async {
|
Future<void> _signIn() async {
|
||||||
final email = _emailController.text.trim();
|
final email = _emailController.text.trim();
|
||||||
final password = _passwordController.text;
|
final password = _passwordController.text;
|
||||||
|
|
||||||
print('[SignIn] Attempting sign-in for $email');
|
|
||||||
|
|
||||||
if (email.isEmpty || !email.contains('@')) {
|
if (email.isEmpty || !email.contains('@')) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = 'Please enter a valid email address';
|
_errorMessage = 'Please enter a valid email address';
|
||||||
|
|
@ -116,6 +121,14 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate Turnstile token
|
||||||
|
if (_turnstileToken == null || _turnstileToken!.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Please complete the security verification';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
|
@ -123,18 +136,18 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final authService = ref.read(authServiceProvider);
|
final authService = ref.read(authServiceProvider);
|
||||||
print('[SignIn] Calling signInWithGoBackend...');
|
|
||||||
await authService.signInWithGoBackend(
|
await authService.signInWithGoBackend(
|
||||||
email: email,
|
email: email,
|
||||||
password: password,
|
password: password,
|
||||||
|
turnstileToken: _turnstileToken!,
|
||||||
);
|
);
|
||||||
print('[SignIn] Sign-in successful!');
|
|
||||||
await _persistCredentials(email, password);
|
await _persistCredentials(email, password);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[SignIn] Error: $e');
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||||
|
// Reset Turnstile token on error so user must re-verify
|
||||||
|
_turnstileToken = null;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -382,7 +395,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
prefixIcon: Icons.lock_outline,
|
prefixIcon: Icons.lock_outline,
|
||||||
onEditingComplete: _signIn,
|
onEditingComplete: _turnstileToken != null ? _signIn : null,
|
||||||
autofillHints: const [AutofillHints.password],
|
autofillHints: const [AutofillHints.password],
|
||||||
onChanged: (_) {
|
onChanged: (_) {
|
||||||
if (_errorMessage != null) {
|
if (_errorMessage != null) {
|
||||||
|
|
@ -393,6 +406,46 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppTheme.spacingLg),
|
const SizedBox(height: AppTheme.spacingLg),
|
||||||
|
|
||||||
|
// Turnstile CAPTCHA
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: _turnstileToken != null
|
||||||
|
? AppTheme.success
|
||||||
|
: AppTheme.egyptianBlue.withOpacity(0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (_turnstileToken == null) ...[
|
||||||
|
TurnstileWidget(
|
||||||
|
siteKey: _turnstileSiteKey,
|
||||||
|
onToken: (token) {
|
||||||
|
setState(() {
|
||||||
|
_turnstileToken = token;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: AppTheme.success, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Security verified',
|
||||||
|
style: TextStyle(color: AppTheme.success, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: AppTheme.spacingLg),
|
const SizedBox(height: AppTheme.spacingLg),
|
||||||
|
|
||||||
if (_supportsBiometric) ...[
|
if (_supportsBiometric) ...[
|
||||||
|
|
@ -422,7 +475,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
||||||
],
|
],
|
||||||
sojornButton(
|
sojornButton(
|
||||||
label: 'Sign In',
|
label: 'Sign In',
|
||||||
onPressed: isSubmitting ? null : _signIn,
|
onPressed: (isSubmitting || _turnstileToken == null) ? null : _signIn,
|
||||||
isLoading: isSubmitting,
|
isLoading: isSubmitting,
|
||||||
isFullWidth: true,
|
isFullWidth: true,
|
||||||
variant: sojornButtonVariant.primary,
|
variant: sojornButtonVariant.primary,
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import 'profile_setup_screen.dart';
|
import '../../widgets/auth/turnstile_widget.dart';
|
||||||
import 'category_select_screen.dart';
|
|
||||||
|
|
||||||
class SignUpScreen extends ConsumerStatefulWidget {
|
class SignUpScreen extends ConsumerStatefulWidget {
|
||||||
const SignUpScreen({super.key});
|
const SignUpScreen({super.key});
|
||||||
|
|
@ -22,6 +24,22 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
bool _isLoading = false;
|
bool _isLoading = false;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
|
|
||||||
|
// Turnstile token
|
||||||
|
String? _turnstileToken;
|
||||||
|
|
||||||
|
// Legal consent
|
||||||
|
bool _acceptTerms = false;
|
||||||
|
bool _acceptPrivacy = false;
|
||||||
|
|
||||||
|
// Email preferences (single combined option)
|
||||||
|
bool _emailUpdates = false;
|
||||||
|
|
||||||
|
// Turnstile site key from environment or default production key
|
||||||
|
static const String _turnstileSiteKey = String.fromEnvironment(
|
||||||
|
'TURNSTILE_SITE_KEY',
|
||||||
|
defaultValue: '0x4AAAAAACYFlz_g513d6xAf', // Cloudflare production key
|
||||||
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_emailController.dispose();
|
_emailController.dispose();
|
||||||
|
|
@ -35,6 +53,29 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
Future<void> _signUp() async {
|
Future<void> _signUp() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
// Validate Turnstile token
|
||||||
|
if (_turnstileToken == null || _turnstileToken!.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'Please complete the security verification';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate legal consent
|
||||||
|
if (!_acceptTerms) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'You must accept the Terms of Service';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_acceptPrivacy) {
|
||||||
|
setState(() {
|
||||||
|
_errorMessage = 'You must accept the Privacy Policy';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
|
|
@ -47,11 +88,14 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
handle: _handleController.text.trim(),
|
handle: _handleController.text.trim(),
|
||||||
displayName: _displayNameController.text.trim(),
|
displayName: _displayNameController.text.trim(),
|
||||||
|
turnstileToken: _turnstileToken!,
|
||||||
|
acceptTerms: _acceptTerms,
|
||||||
|
acceptPrivacy: _acceptPrivacy,
|
||||||
|
emailNewsletter: _emailUpdates,
|
||||||
|
emailContact: _emailUpdates,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Navigate to category selection (skip profile setup as it's done)
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Show success message and navigate to sign in
|
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => AlertDialog(
|
builder: (context) => AlertDialog(
|
||||||
|
|
@ -74,6 +118,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||||
|
_turnstileToken = null; // Reset Turnstile on error
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -85,7 +130,12 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _launchUrl(String url) async {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
if (await canLaunchUrl(uri)) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
@ -113,31 +163,26 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
const SizedBox(height: AppTheme.spacingSm),
|
const SizedBox(height: AppTheme.spacingSm),
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
'Your vibrant journey begins now', // Updated tagline
|
'Your vibrant journey begins now',
|
||||||
style: AppTheme.bodyMedium.copyWith(
|
style: AppTheme.bodyMedium.copyWith(
|
||||||
color: AppTheme.navyText.withOpacity(
|
color: AppTheme.navyText.withOpacity(0.8),
|
||||||
0.8), // Replaced AppTheme.textSecondary
|
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(
|
const SizedBox(height: AppTheme.spacingLg * 1.5),
|
||||||
height: AppTheme.spacingLg *
|
|
||||||
1.5), // Replaced AppTheme.spacing2xl
|
|
||||||
|
|
||||||
// Error message
|
// Error message
|
||||||
if (_errorMessage != null) ...[
|
if (_errorMessage != null) ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.error
|
color: AppTheme.error.withOpacity(0.1),
|
||||||
.withOpacity(0.1), // Replaced withValues
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: AppTheme.error, width: 1),
|
border: Border.all(color: AppTheme.error, width: 1),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
_errorMessage!,
|
_errorMessage!,
|
||||||
style: AppTheme.textTheme.labelSmall?.copyWith(
|
style: AppTheme.textTheme.labelSmall?.copyWith(
|
||||||
// Replaced AppTheme.bodySmall
|
|
||||||
color: AppTheme.error,
|
color: AppTheme.error,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -151,9 +196,14 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Handle (@username)',
|
labelText: 'Handle (@username)',
|
||||||
hintText: 'sojorn_user',
|
hintText: 'sojorn_user',
|
||||||
|
prefixIcon: Icon(Icons.alternate_email),
|
||||||
),
|
),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) return 'Handle is required';
|
if (value == null || value.isEmpty) return 'Handle is required';
|
||||||
|
if (value.length < 3) return 'Handle must be at least 3 characters';
|
||||||
|
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(value)) {
|
||||||
|
return 'Handle can only contain letters, numbers, and underscores';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -165,6 +215,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Display Name',
|
labelText: 'Display Name',
|
||||||
hintText: 'Jane Doe',
|
hintText: 'Jane Doe',
|
||||||
|
prefixIcon: Icon(Icons.person_outline),
|
||||||
),
|
),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) return 'Display Name is required';
|
if (value == null || value.isEmpty) return 'Display Name is required';
|
||||||
|
|
@ -180,12 +231,13 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Email',
|
labelText: 'Email',
|
||||||
hintText: 'your@email.com',
|
hintText: 'your@email.com',
|
||||||
|
prefixIcon: Icon(Icons.email_outlined),
|
||||||
),
|
),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return 'Email is required';
|
return 'Email is required';
|
||||||
}
|
}
|
||||||
if (!value.contains('@')) {
|
if (!value.contains('@') || !value.contains('.')) {
|
||||||
return 'Enter a valid email';
|
return 'Enter a valid email';
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -200,6 +252,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Password',
|
labelText: 'Password',
|
||||||
hintText: 'At least 6 characters',
|
hintText: 'At least 6 characters',
|
||||||
|
prefixIcon: Icon(Icons.lock_outline),
|
||||||
),
|
),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
|
|
@ -219,6 +272,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
obscureText: true,
|
obscureText: true,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Confirm Password',
|
labelText: 'Confirm Password',
|
||||||
|
prefixIcon: Icon(Icons.lock_outline),
|
||||||
),
|
),
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
|
|
@ -232,30 +286,131 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppTheme.spacingLg),
|
const SizedBox(height: AppTheme.spacingLg),
|
||||||
|
|
||||||
|
// Turnstile CAPTCHA
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: _turnstileToken != null
|
||||||
|
? AppTheme.success
|
||||||
|
: AppTheme.egyptianBlue.withOpacity(0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
if (_turnstileToken == null) ...[
|
||||||
|
TurnstileWidget(
|
||||||
|
siteKey: _turnstileSiteKey,
|
||||||
|
onToken: (token) {
|
||||||
|
setState(() {
|
||||||
|
_turnstileToken = token;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
] else ...[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.check_circle, color: AppTheme.success, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Security verified',
|
||||||
|
style: TextStyle(color: AppTheme.success, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppTheme.spacingLg),
|
||||||
|
|
||||||
|
// Terms of Service checkbox
|
||||||
|
CheckboxListTile(
|
||||||
|
value: _acceptTerms,
|
||||||
|
onChanged: (value) => setState(() => _acceptTerms = value ?? false),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
title: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: AppTheme.textTheme.bodySmall?.copyWith(color: AppTheme.navyText),
|
||||||
|
children: [
|
||||||
|
const TextSpan(text: 'I agree to the '),
|
||||||
|
TextSpan(
|
||||||
|
text: 'Terms of Service',
|
||||||
|
style: TextStyle(color: AppTheme.brightNavy, fontWeight: FontWeight.w600),
|
||||||
|
recognizer: TapGestureRecognizer()
|
||||||
|
..onTap = () => _launchUrl('https://sojorn.net/terms'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Privacy Policy checkbox
|
||||||
|
CheckboxListTile(
|
||||||
|
value: _acceptPrivacy,
|
||||||
|
onChanged: (value) => setState(() => _acceptPrivacy = value ?? false),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
title: RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: AppTheme.textTheme.bodySmall?.copyWith(color: AppTheme.navyText),
|
||||||
|
children: [
|
||||||
|
const TextSpan(text: 'I agree to the '),
|
||||||
|
TextSpan(
|
||||||
|
text: 'Privacy Policy',
|
||||||
|
style: TextStyle(color: AppTheme.brightNavy, fontWeight: FontWeight.w600),
|
||||||
|
recognizer: TapGestureRecognizer()
|
||||||
|
..onTap = () => _launchUrl('https://sojorn.net/privacy'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Email updates preference (part of agreement section)
|
||||||
|
CheckboxListTile(
|
||||||
|
value: _emailUpdates,
|
||||||
|
onChanged: (value) => setState(() => _emailUpdates = value ?? false),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
title: Text(
|
||||||
|
'Please send me email updates about sojorn and MPLS LLC',
|
||||||
|
style: AppTheme.textTheme.bodySmall?.copyWith(color: AppTheme.navyText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: AppTheme.spacingMd),
|
||||||
|
|
||||||
// Sign up button
|
// Sign up button
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _isLoading ? null : _signUp,
|
onPressed: _isLoading ? null : _signUp,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
valueColor:
|
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||||
AlwaysStoppedAnimation(Colors.white),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: const Text('Continue'),
|
: const Text('Create Account'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: AppTheme.spacingMd),
|
const SizedBox(height: AppTheme.spacingMd),
|
||||||
|
|
||||||
// Terms and privacy note
|
// Footer
|
||||||
Text(
|
Text(
|
||||||
'By continuing, you agree to our vibrant community guidelines.\nA product of MPLS LLC.', // Updated text
|
'A product of MPLS LLC.',
|
||||||
style: AppTheme.textTheme.labelSmall?.copyWith(
|
style: AppTheme.textTheme.labelSmall?.copyWith(
|
||||||
// Replaced AppTheme.bodySmall
|
color: AppTheme.egyptianBlue,
|
||||||
color: AppTheme
|
|
||||||
.egyptianBlue, // Replaced AppTheme.textTertiary
|
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,11 @@ import '../secure_chat/secure_chat_full_screen.dart';
|
||||||
import '../../services/notification_service.dart';
|
import '../../services/notification_service.dart';
|
||||||
import '../../widgets/post/post_body.dart';
|
import '../../widgets/post/post_body.dart';
|
||||||
import '../../widgets/post/post_view_mode.dart';
|
import '../../widgets/post/post_view_mode.dart';
|
||||||
|
import '../../widgets/post/post_media.dart';
|
||||||
import '../../providers/notification_provider.dart';
|
import '../../providers/notification_provider.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
|
||||||
|
|
||||||
class ThreadedConversationScreen extends ConsumerStatefulWidget {
|
class ThreadedConversationScreen extends ConsumerStatefulWidget {
|
||||||
final String rootPostId;
|
final String rootPostId;
|
||||||
final Post? rootPost;
|
final Post? rootPost;
|
||||||
|
|
@ -495,10 +497,14 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
_buildStageHeader(focalPost),
|
_buildStageHeader(focalPost),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildStageContent(focalPost),
|
_buildStageContent(focalPost),
|
||||||
if (focalPost.imageUrl != null) ...[
|
if (focalPost.imageUrl != null || focalPost.videoUrl != null || focalPost.thumbnailUrl != null) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildStageMedia(focalPost.imageUrl!),
|
PostMedia(
|
||||||
|
post: focalPost,
|
||||||
|
mode: PostViewMode.detail,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildStageActions(focalPost),
|
_buildStageActions(focalPost),
|
||||||
],
|
],
|
||||||
|
|
@ -1190,7 +1196,13 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
||||||
|
|
||||||
Future<void> _sharePost(Post post) async {
|
Future<void> _sharePost(Post post) async {
|
||||||
final handle = post.author?.handle ?? 'sojorn';
|
final handle = post.author?.handle ?? 'sojorn';
|
||||||
final text = '${post.body}\n\n— @$handle on sojorn';
|
final shareUrl = post.hasVideoContent == true
|
||||||
|
? AppRoutes.getQuipUrl(post.id)
|
||||||
|
: AppRoutes.getPostUrl(post.id);
|
||||||
|
|
||||||
|
|
||||||
|
final text = '${post.body}\n\n$shareUrl\n\n— @$handle on Sojorn';
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Share.share(text);
|
await Share.share(text);
|
||||||
|
|
|
||||||
|
|
@ -67,7 +67,9 @@ class Quip {
|
||||||
|
|
||||||
class QuipsFeedScreen extends ConsumerStatefulWidget {
|
class QuipsFeedScreen extends ConsumerStatefulWidget {
|
||||||
final bool? isActive;
|
final bool? isActive;
|
||||||
const QuipsFeedScreen({super.key, this.isActive});
|
final String? initialPostId;
|
||||||
|
const QuipsFeedScreen({super.key, this.isActive, this.initialPostId});
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<QuipsFeedScreen> createState() => _QuipsFeedScreenState();
|
ConsumerState<QuipsFeedScreen> createState() => _QuipsFeedScreenState();
|
||||||
|
|
@ -99,8 +101,14 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
super.initState();
|
super.initState();
|
||||||
WidgetsBinding.instance.addObserver(this);
|
WidgetsBinding.instance.addObserver(this);
|
||||||
_isScreenActive = widget.isActive ?? false;
|
_isScreenActive = widget.isActive ?? false;
|
||||||
_fetchQuips();
|
if (widget.initialPostId != null) {
|
||||||
|
_isUserPaused = false;
|
||||||
}
|
}
|
||||||
|
_fetchQuips(refresh: widget.initialPostId != null);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
void _checkFeedRefresh() {
|
void _checkFeedRefresh() {
|
||||||
final refreshToken = ref.read(feedRefreshProvider);
|
final refreshToken = ref.read(feedRefreshProvider);
|
||||||
|
|
@ -135,8 +143,14 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
if (widget.isActive != oldWidget.isActive) {
|
if (widget.isActive != oldWidget.isActive) {
|
||||||
_handleScreenActive(_resolveActiveState());
|
_handleScreenActive(_resolveActiveState());
|
||||||
}
|
}
|
||||||
|
if (widget.initialPostId != oldWidget.initialPostId && widget.initialPostId != null) {
|
||||||
|
_isUserPaused = false; // Auto-play if user explicitly clicked a quip
|
||||||
|
_fetchQuips(refresh: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
|
|
@ -227,11 +241,43 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
final posts =
|
final posts =
|
||||||
(data['posts'] as List? ?? []).whereType<Map<String, dynamic>>();
|
(data['posts'] as List? ?? []).whereType<Map<String, dynamic>>();
|
||||||
|
|
||||||
final items = posts
|
List<Quip> items = posts
|
||||||
.map(Quip.fromMap)
|
.map(Quip.fromMap)
|
||||||
.where((quip) => quip.videoUrl.isNotEmpty)
|
.where((quip) => quip.videoUrl.isNotEmpty)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
// If we have an initialPostId, ensure it's at the top
|
||||||
|
// If we have an initialPostId, ensure it's at the top
|
||||||
|
if (refresh && widget.initialPostId != null) {
|
||||||
|
print('[Quips] Handling initialPostId: ${widget.initialPostId}');
|
||||||
|
final existingIndex = items.indexWhere((q) => q.id == widget.initialPostId);
|
||||||
|
if (existingIndex != -1) {
|
||||||
|
print('[Quips] Found initialPostId in feed at index $existingIndex, moving to top');
|
||||||
|
final initial = items.removeAt(existingIndex);
|
||||||
|
items.insert(0, initial);
|
||||||
|
} else {
|
||||||
|
print('[Quips] initialPostId NOT in feed, fetching specifically...');
|
||||||
|
try {
|
||||||
|
final postData = await api.callGoApi('/posts/${widget.initialPostId}', method: 'GET');
|
||||||
|
if (postData['post'] != null) {
|
||||||
|
final quip = Quip.fromMap(postData['post'] as Map<String, dynamic>);
|
||||||
|
if (quip.videoUrl.isNotEmpty) {
|
||||||
|
print('[Quips] Successfully fetched initial quip: ${quip.videoUrl}');
|
||||||
|
items.insert(0, quip);
|
||||||
|
} else {
|
||||||
|
print('[Quips] Fetched post is not a video: ${quip.videoUrl}');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print('[Quips] No post found for initialPostId: ${widget.initialPostId}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
print('Initial quip fetch error: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
if (refresh) {
|
if (refresh) {
|
||||||
|
|
@ -419,10 +465,13 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
||||||
}
|
}
|
||||||
|
|
||||||
void _shareQuip(Quip quip) {
|
void _shareQuip(Quip quip) {
|
||||||
final url = AppRoutes.getPostUrl(quip.id);
|
final url = AppRoutes.getQuipUrl(quip.id);
|
||||||
Share.share(url);
|
final text = '${quip.caption}\n\n$url\n\n— @${quip.username} on Sojorn';
|
||||||
|
Share.share(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
|
|
|
||||||
|
|
@ -579,7 +579,13 @@ class ApiService {
|
||||||
throw ArgumentError('Invalid longitude range');
|
throw ArgumentError('Invalid longitude range');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('[Post] Publishing: body=$body, video=$videoUrl, thumb=$thumbnailUrl');
|
||||||
|
print('[Post] Sanitized: video=$sanitizedVideoUrl, thumb=$sanitizedThumbnailUrl');
|
||||||
|
}
|
||||||
|
|
||||||
final data = await _callGoApi(
|
final data = await _callGoApi(
|
||||||
|
|
||||||
'/posts',
|
'/posts',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
|
|
@ -588,9 +594,13 @@ class ApiService {
|
||||||
'body_format': bodyFormat,
|
'body_format': bodyFormat,
|
||||||
'allow_chain': allowChain,
|
'allow_chain': allowChain,
|
||||||
if (chainParentId != null) 'chain_parent_id': chainParentId,
|
if (chainParentId != null) 'chain_parent_id': chainParentId,
|
||||||
if (sanitizedImageUrl != null) 'image_url': sanitizedImageUrl,
|
if (sanitizedImageUrl != null || (imageUrl != null && imageUrl.isNotEmpty))
|
||||||
if (sanitizedVideoUrl != null) 'video_url': sanitizedVideoUrl,
|
'image_url': sanitizedImageUrl ?? imageUrl,
|
||||||
if (sanitizedThumbnailUrl != null) 'thumbnail_url': sanitizedThumbnailUrl,
|
if (sanitizedVideoUrl != null || (videoUrl != null && videoUrl.isNotEmpty))
|
||||||
|
'video_url': sanitizedVideoUrl ?? videoUrl,
|
||||||
|
if (sanitizedThumbnailUrl != null || (thumbnailUrl != null && thumbnailUrl.isNotEmpty))
|
||||||
|
'thumbnail_url': sanitizedThumbnailUrl ?? thumbnailUrl,
|
||||||
|
|
||||||
if (durationMs != null) 'duration_ms': durationMs,
|
if (durationMs != null) 'duration_ms': durationMs,
|
||||||
if (ttlHours != null) 'ttl_hours': ttlHours,
|
if (ttlHours != null) 'ttl_hours': ttlHours,
|
||||||
if (isBeacon) 'is_beacon': true,
|
if (isBeacon) 'is_beacon': true,
|
||||||
|
|
@ -762,11 +772,17 @@ class ApiService {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> blockUser(String userId) async {
|
Future<void> blockUser(String userId) async {
|
||||||
// Migrate to Go API
|
await _callGoApi(
|
||||||
|
'/users/$userId/block',
|
||||||
|
method: 'POST',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> unblockUser(String userId) async {
|
Future<void> unblockUser(String userId) async {
|
||||||
// Migrate to Go API
|
await _callGoApi(
|
||||||
|
'/users/$userId/block',
|
||||||
|
method: 'DELETE',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> appreciatePost(String postId) async {
|
Future<void> appreciatePost(String postId) async {
|
||||||
|
|
@ -846,7 +862,7 @@ class ApiService {
|
||||||
await _callGoApi('/conversations/$conversationId', method: 'DELETE');
|
await _callGoApi('/conversations/$conversationId', method: 'DELETE');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[API] Failed to delete conversation: $e');
|
if (kDebugMode) print('[API] Failed to delete conversation: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -856,7 +872,7 @@ class ApiService {
|
||||||
await _callGoApi('/messages/$messageId', method: 'DELETE');
|
await _callGoApi('/messages/$messageId', method: 'DELETE');
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[API] Failed to delete message: $e');
|
if (kDebugMode) print('[API] Failed to delete message: $e');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -867,7 +883,7 @@ class ApiService {
|
||||||
|
|
||||||
Future<Map<String, dynamic>> getKeyBundle(String userId) async {
|
Future<Map<String, dynamic>> getKeyBundle(String userId) async {
|
||||||
final data = await callGoApi('/keys/$userId', method: 'GET');
|
final data = await callGoApi('/keys/$userId', method: 'GET');
|
||||||
print('[API] Raw Key Bundle for $userId: ${jsonEncode(data)}');
|
// Key bundle fetched - contents not logged for security
|
||||||
// Go returns nested structure. We normalize to flat keys here.
|
// Go returns nested structure. We normalize to flat keys here.
|
||||||
if (data.containsKey('identity_key') && data['identity_key'] is Map) {
|
if (data.containsKey('identity_key') && data['identity_key'] is Map) {
|
||||||
final identityKey = data['identity_key'] as Map<String, dynamic>;
|
final identityKey = data['identity_key'] as Map<String, dynamic>;
|
||||||
|
|
|
||||||
|
|
@ -200,13 +200,18 @@ class AuthService {
|
||||||
Future<Map<String, dynamic>> signInWithGoBackend({
|
Future<Map<String, dynamic>> signInWithGoBackend({
|
||||||
required String email,
|
required String email,
|
||||||
required String password,
|
required String password,
|
||||||
|
required String turnstileToken,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login');
|
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login');
|
||||||
final response = await http.post(
|
final response = await http.post(
|
||||||
uri,
|
uri,
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: jsonEncode({'email': email, 'password': password}),
|
body: jsonEncode({
|
||||||
|
'email': email,
|
||||||
|
'password': password,
|
||||||
|
'turnstile_token': turnstileToken,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
final data = jsonDecode(response.body);
|
final data = jsonDecode(response.body);
|
||||||
|
|
@ -253,6 +258,11 @@ class AuthService {
|
||||||
required String password,
|
required String password,
|
||||||
required String handle,
|
required String handle,
|
||||||
required String displayName,
|
required String displayName,
|
||||||
|
required String turnstileToken,
|
||||||
|
required bool acceptTerms,
|
||||||
|
required bool acceptPrivacy,
|
||||||
|
bool emailNewsletter = false,
|
||||||
|
bool emailContact = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/register');
|
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/register');
|
||||||
|
|
@ -264,6 +274,11 @@ class AuthService {
|
||||||
'password': password,
|
'password': password,
|
||||||
'handle': handle,
|
'handle': handle,
|
||||||
'display_name': displayName,
|
'display_name': displayName,
|
||||||
|
'turnstile_token': turnstileToken,
|
||||||
|
'accept_terms': acceptTerms,
|
||||||
|
'accept_privacy': acceptPrivacy,
|
||||||
|
'email_newsletter': emailNewsletter,
|
||||||
|
'email_contact': emailContact,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,8 +73,8 @@ class SimpleE2EEService {
|
||||||
return _initFuture = _doInitialize(userId);
|
return _initFuture = _doInitialize(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DEBUG: Set to true to force new key generation on startup (fixing bad keys)
|
// Key rotation is now handled via initiateKeyRecovery() when needed
|
||||||
static const bool _FORCE_KEY_ROTATION = false;
|
// DO NOT add debug flags here - use resetAllKeys() method for intentional resets
|
||||||
|
|
||||||
Future<void> resetAllKeys() async {
|
Future<void> resetAllKeys() async {
|
||||||
print('[E2EE] RESETTING ALL KEYS - fixing MAC errors');
|
print('[E2EE] RESETTING ALL KEYS - fixing MAC errors');
|
||||||
|
|
@ -202,11 +202,7 @@ class SimpleE2EEService {
|
||||||
Future<void> _doInitialize(String userId) async {
|
Future<void> _doInitialize(String userId) async {
|
||||||
_initializedForUserId = userId;
|
_initializedForUserId = userId;
|
||||||
|
|
||||||
if (_FORCE_KEY_ROTATION) {
|
|
||||||
print('[E2EE] FORCE_KEY_ROTATION is true. Skipping load/restore and generating NEW identity.');
|
|
||||||
await generateNewIdentity();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Try Local Storage
|
// 1. Try Local Storage
|
||||||
try {
|
try {
|
||||||
|
|
@ -640,7 +636,7 @@ class SimpleE2EEService {
|
||||||
final secretBox = SecretBox(ciphertextBytes, nonce: nonce, mac: Mac(macBytes));
|
final secretBox = SecretBox(ciphertextBytes, nonce: nonce, mac: Mac(macBytes));
|
||||||
final plaintextBytes = await _cipher.decrypt(secretBox, secretKey: SecretKey(rootSecret));
|
final plaintextBytes = await _cipher.decrypt(secretBox, secretKey: SecretKey(rootSecret));
|
||||||
final plaintext = utf8.decode(plaintextBytes);
|
final plaintext = utf8.decode(plaintextBytes);
|
||||||
print('[DECRYPT] SUCCESS: Decrypted message: "$plaintext"');
|
// Decryption successful - plaintext not logged for security
|
||||||
return plaintext;
|
return plaintext;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[DECRYPT] Failed: $e');
|
print('[DECRYPT] Failed: $e');
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,10 @@ class SecurityUtils {
|
||||||
final dangerousPatterns = [
|
final dangerousPatterns = [
|
||||||
'script', 'javascript', 'vbscript', 'onload', 'onerror', 'onclick',
|
'script', 'javascript', 'vbscript', 'onload', 'onerror', 'onclick',
|
||||||
'eval', 'expression', 'alert', 'confirm', 'prompt',
|
'eval', 'expression', 'alert', 'confirm', 'prompt',
|
||||||
'<', '>', '"', "'", '\\', '/', '\n', '\r', '\t'
|
'<', '>', '"', "'", '\\', '\n', '\r', '\t'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
final lowerValue = value.toLowerCase();
|
final lowerValue = value.toLowerCase();
|
||||||
return dangerousPatterns.any((pattern) => lowerValue.contains(pattern));
|
return dangerousPatterns.any((pattern) => lowerValue.contains(pattern));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../models/post.dart';
|
import '../../models/post.dart';
|
||||||
|
import '../../routes/app_routes.dart';
|
||||||
|
|
||||||
import '../../theme/app_theme.dart';
|
import '../../theme/app_theme.dart';
|
||||||
import '../media/signed_media_image.dart';
|
import '../media/signed_media_image.dart';
|
||||||
import 'post_view_mode.dart';
|
import 'post_view_mode.dart';
|
||||||
|
|
@ -14,62 +17,74 @@ class PostMedia extends StatelessWidget {
|
||||||
final Post? post;
|
final Post? post;
|
||||||
final Widget? child;
|
final Widget? child;
|
||||||
final PostViewMode mode;
|
final PostViewMode mode;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const PostMedia({
|
const PostMedia({
|
||||||
super.key,
|
super.key,
|
||||||
this.post,
|
this.post,
|
||||||
this.child,
|
this.child,
|
||||||
this.mode = PostViewMode.feed,
|
this.mode = PostViewMode.feed,
|
||||||
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
/// Get image height based on view mode
|
/// Get image height based on view mode
|
||||||
double get _imageHeight {
|
double get _imageHeight {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
case PostViewMode.feed:
|
case PostViewMode.feed:
|
||||||
return 300.0;
|
return 450.0; // Taller for better resolution/ratio
|
||||||
case PostViewMode.detail:
|
case PostViewMode.detail:
|
||||||
return 500.0; // Full height for detail view
|
return 600.0;
|
||||||
case PostViewMode.compact:
|
case PostViewMode.compact:
|
||||||
return 200.0; // Smaller for profile lists
|
return 200.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (post != null && post!.imageUrl != null && post!.imageUrl!.isNotEmpty) {
|
// Determine which URL to display as the cover
|
||||||
|
final String? displayUrl = (post?.imageUrl?.isNotEmpty == true)
|
||||||
|
? post!.imageUrl
|
||||||
|
: (post?.thumbnailUrl?.isNotEmpty == true)
|
||||||
|
? post!.thumbnailUrl
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (displayUrl != null) {
|
||||||
|
final bool isVideo = post?.hasVideoContent == true;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: AppTheme.spacingSm),
|
padding: const EdgeInsets.only(top: AppTheme.spacingSm),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
ConstrainedBox(
|
ClipRRect(
|
||||||
constraints: BoxConstraints(maxHeight: _imageHeight),
|
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||||
child: SizedBox(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: SignedMediaImage(
|
// For videos in feed mode, use a more vertical 4:5 aspect ratio
|
||||||
url: post!.imageUrl!,
|
// For other modes or non-videos, use the constrained height logic
|
||||||
fit: BoxFit.cover,
|
child: InkWell(
|
||||||
loadingBuilder: (context) => Container(
|
onTap: isVideo
|
||||||
color: AppTheme.queenPink.withValues(alpha: 0.3),
|
? () {
|
||||||
child: const Center(child: CircularProgressIndicator()),
|
final url = '${AppRoutes.quips}?postId=${post!.id}';
|
||||||
),
|
print('[PostMedia] Navigating to quips: $url');
|
||||||
errorBuilder: (context, error, stackTrace) => Container(
|
context.go(url);
|
||||||
color: Colors.red.withValues(alpha: 0.3),
|
}
|
||||||
child: Center(
|
: onTap,
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
child: (isVideo && mode == PostViewMode.feed)
|
||||||
const Icon(Icons.broken_image,
|
? AspectRatio(
|
||||||
size: 48, color: Colors.white),
|
aspectRatio: 4 / 5,
|
||||||
const SizedBox(height: 8),
|
child: _buildMediaContent(displayUrl, true),
|
||||||
Text('Error: $error',
|
)
|
||||||
style: const TextStyle(
|
: ConstrainedBox(
|
||||||
color: Colors.white, fontSize: 10)),
|
constraints: BoxConstraints(maxHeight: _imageHeight),
|
||||||
],
|
child: _buildMediaContent(displayUrl, isVideo),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -89,4 +104,52 @@ class PostMedia extends StatelessWidget {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildMediaContent(String displayUrl, bool isVideo) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
SignedMediaImage(
|
||||||
|
url: displayUrl,
|
||||||
|
fit: (isVideo && mode == PostViewMode.feed) ? BoxFit.cover : BoxFit.cover,
|
||||||
|
loadingBuilder: (context) => Container(
|
||||||
|
color: AppTheme.queenPink.withValues(alpha: 0.3),
|
||||||
|
child: const Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
errorBuilder: (context, error, stackTrace) => Container(
|
||||||
|
color: Colors.red.withValues(alpha: 0.3),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.broken_image, size: 48, color: Colors.white),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text('Error: $error',
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 10)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Play Button Overlay for Video
|
||||||
|
if (isVideo)
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 2),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.play_arrow,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 40,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/post.dart';
|
import '../models/post.dart';
|
||||||
|
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'post/post_actions.dart';
|
import 'post/post_actions.dart';
|
||||||
import 'post/post_body.dart';
|
import 'post/post_body.dart';
|
||||||
|
|
@ -195,18 +196,21 @@ class sojornPostCard extends StatelessWidget {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Media (if available) - clickable for post detail
|
// Media (if available) - clickable for post detail (or quip player if video)
|
||||||
if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[
|
if ((post.imageUrl != null && post.imageUrl!.isNotEmpty) ||
|
||||||
|
(post.thumbnailUrl != null && post.thumbnailUrl!.isNotEmpty) ||
|
||||||
|
(post.videoUrl != null && post.videoUrl!.isNotEmpty)) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
InkWell(
|
PostMedia(
|
||||||
onTap: onTap,
|
|
||||||
child: PostMedia(
|
|
||||||
post: post,
|
post: post,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
),
|
onTap: onTap,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Actions section - with padding
|
// Actions section - with padding
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Padding(
|
Padding(
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ dependencies:
|
||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
share_plus: ^10.0.2
|
share_plus: ^10.0.2
|
||||||
timeago: ^3.7.0
|
timeago: ^3.7.0
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.2
|
||||||
image_picker: ^1.1.2
|
image_picker: ^1.1.2
|
||||||
image: ^4.3.0
|
image: ^4.3.0
|
||||||
flutter_image_compress: ^2.4.0
|
flutter_image_compress: ^2.4.0
|
||||||
|
|
|
||||||
|
|
@ -40,12 +40,17 @@ $defineArgs = @(
|
||||||
"--dart-define=API_BASE_URL=$($values['API_BASE_URL'])"
|
"--dart-define=API_BASE_URL=$($values['API_BASE_URL'])"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if ($values.ContainsKey('TURNSTILE_SITE_KEY') -and -not [string]::IsNullOrWhiteSpace($values['TURNSTILE_SITE_KEY'])) {
|
||||||
|
$defineArgs += "--dart-define=TURNSTILE_SITE_KEY=$($values['TURNSTILE_SITE_KEY'])"
|
||||||
|
}
|
||||||
|
|
||||||
Write-Host "Starting Sojorn in development mode..." -ForegroundColor Green
|
Write-Host "Starting Sojorn in development mode..." -ForegroundColor Green
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|
||||||
Push-Location $PSScriptRoot
|
Push-Location $PSScriptRoot
|
||||||
try {
|
try {
|
||||||
flutter run @defineArgs @Args
|
flutter run @defineArgs @Args
|
||||||
} finally {
|
}
|
||||||
|
finally {
|
||||||
Pop-Location
|
Pop-Location
|
||||||
}
|
}
|
||||||
|
|
|
||||||
197
verified_fixed.html
Normal file
197
verified_fixed.html
Normal file
|
|
@ -0,0 +1,197 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Email Verification - Sojorn</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary: #10B981;
|
||||||
|
--primary-dark: #059669;
|
||||||
|
--warning: #F59E0B;
|
||||||
|
--bg: #09090b;
|
||||||
|
--card: #18181b;
|
||||||
|
--text: #ffffff;
|
||||||
|
--text-muted: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
background: var(--card);
|
||||||
|
padding: 3rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
|
max-width: 440px;
|
||||||
|
width: 90%;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
position: relative;
|
||||||
|
z-index: 10;
|
||||||
|
animation: slideUp 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 1.5rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-wrapper.success { background: rgba(16, 185, 129, 0.1); }
|
||||||
|
.icon-wrapper.pending { background: rgba(245, 158, 11, 0.1); }
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
.icon.success { color: var(--primary); }
|
||||||
|
.icon.pending { color: var(--warning); }
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
background: linear-gradient(to bottom right, #ffffff, #a1a1aa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 2.5rem;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
background: var(--primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: blink 1.4s infinite both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 80%, 100% { opacity: 0; }
|
||||||
|
40% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient {
|
||||||
|
position: fixed;
|
||||||
|
top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: radial-gradient(circle at 50% 50%, rgba(16, 185, 129, 0.05) 0%, rgba(9, 9, 11, 1) 70%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="bg-gradient"></div>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Success State -->
|
||||||
|
<div id="success-state" class="hidden">
|
||||||
|
<div class="icon-wrapper success">
|
||||||
|
<svg class="icon success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>Email Verified</h1>
|
||||||
|
<p>Your email has been successfully verified. You're all set to experience Sojorn.</p>
|
||||||
|
<a href="sojorn://verified" class="btn">Open Sojorn App</a>
|
||||||
|
<div class="loader">
|
||||||
|
<span>Redirecting to app</span>
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
<div class="dot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pending State (Accessing URL directly) -->
|
||||||
|
<div id="pending-state">
|
||||||
|
<div class="icon-wrapper pending">
|
||||||
|
<svg class="icon pending" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1>Verification Required</h1>
|
||||||
|
<p>We couldn't confirm your verification status. Please use the link sent to your email address.</p>
|
||||||
|
<a href="sojorn://login" class="btn" style="background-color: #3f3f46; box-shadow: none;">Return to App</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get('status') === 'success') {
|
||||||
|
document.getElementById('pending-state').classList.add('hidden');
|
||||||
|
document.getElementById('success-state').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Auto-redirect to app
|
||||||
|
setTimeout(function () {
|
||||||
|
window.location.href = "sojorn://verified";
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue