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
|
||||
R2VideoBucket string
|
||||
TurnstileSecretKey string
|
||||
APIBaseURL string
|
||||
AppBaseURL string
|
||||
}
|
||||
|
||||
func LoadConfig() *Config {
|
||||
|
|
@ -78,6 +80,8 @@ func LoadConfig() *Config {
|
|||
R2MediaBucket: getEnv("R2_MEDIA_BUCKET", "sojorn-media"),
|
||||
R2VideoBucket: getEnv("R2_VIDEO_BUCKET", "sojorn-videos"),
|
||||
TurnstileSecretKey: getEnv("TURNSTILE_SECRET", ""),
|
||||
APIBaseURL: getEnv("API_BASE_URL", "https://api.sojorn.net"),
|
||||
AppBaseURL: getEnv("APP_BASE_URL", "https://sojorn.net"),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,8 +45,9 @@ type RegisterRequest struct {
|
|||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
TurnstileToken string `json:"turnstile_token" binding:"required"`
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
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) {
|
||||
rawToken := c.Query("token")
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -243,19 +261,19 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
|
|||
|
||||
userID, expiresAt, err := h.repo.GetVerificationToken(c.Request.Context(), hashString)
|
||||
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
|
||||
}
|
||||
|
||||
if time.Now().After(expiresAt) {
|
||||
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
|
||||
}
|
||||
|
||||
// Activate user
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -275,7 +293,7 @@ func (h *AuthHandler) VerifyEmail(c *gin.Context) {
|
|||
// Cleanup
|
||||
_ = 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) {
|
||||
|
|
|
|||
|
|
@ -559,7 +559,7 @@ func (r *UserRepository) DeleteUsedOTK(ctx context.Context, userID string, keyID
|
|||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -613,9 +613,7 @@ func (r *UserRepository) GetSignalKeyBundle(ctx context.Context, userID string)
|
|||
"key_id": otkID,
|
||||
"public_key": otkPub,
|
||||
}
|
||||
fmt.Printf("[KEYS] Retrieved OTK #%d for user %s\n", otkID, userID)
|
||||
} else {
|
||||
fmt.Printf("[KEYS] No OTKs available for user %s\n", userID)
|
||||
// OTK retrieved - not logging user ID for security
|
||||
}
|
||||
|
||||
// Handle NULL values properly
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -51,65 +52,55 @@ type sendPulseIdentity struct {
|
|||
|
||||
func (s *EmailService) SendVerificationEmail(toEmail, toName, token string) error {
|
||||
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(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<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)
|
||||
title := "Email Verification"
|
||||
header := fmt.Sprintf("Hey %s! 👋", toName)
|
||||
if toName == "" {
|
||||
header = "Hey there! 👋"
|
||||
}
|
||||
|
||||
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 {
|
||||
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(`
|
||||
<h2>Reset Password for %s</h2>
|
||||
<p>You requested a password reset. Click the link below to set a new password:</p>
|
||||
<p><a href="%s" style="padding: 10px 20px; background-color: #dc3545; color: white; text-decoration: none; border-radius: 5px;">Reset Password</a></p>
|
||||
<p>This link expires in 1 hour.</p>
|
||||
<p>If you did not request this, please ignore this email.</p>
|
||||
`, toName, resetURL)
|
||||
title := "Password Reset"
|
||||
header := "Reset your password"
|
||||
content := fmt.Sprintf(`
|
||||
<p>Hey %s,</p>
|
||||
<p>You requested a password reset for your Sojorn account. Click the button below to set a new password:</p>
|
||||
`, toName)
|
||||
|
||||
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 {
|
||||
|
|
@ -137,7 +128,15 @@ func (s *EmailService) sendEmail(toEmail, toName, subject, htmlBody, textBody st
|
|||
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 {
|
||||
log.Error().Err(err).Msg("Failed to send email via SMTP")
|
||||
return err
|
||||
|
|
@ -256,3 +255,51 @@ func (s *EmailService) AddSubscriber(email, name string) {
|
|||
// SendPulse Addressbook API implementation omitted for brevity, focusing on email first
|
||||
// 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 = @(
|
||||
'FIREBASE_WEB_VAPID_KEY',
|
||||
'SUPABASE_PUBLISHABLE_KEY',
|
||||
'SUPABASE_SECRET_KEY',
|
||||
'SUPABASE_JWT_KID',
|
||||
'SUPABASE_JWKS_URI'
|
||||
'TURNSTILE_SITE_KEY'
|
||||
)
|
||||
|
||||
foreach ($opt in $optionalDefines) {
|
||||
|
|
@ -57,6 +54,7 @@ foreach ($opt in $optionalDefines) {
|
|||
Push-Location (Join-Path $PSScriptRoot "sojorn_app")
|
||||
try {
|
||||
flutter run @defineArgs @Args
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ $values = Parse-Env $EnvPath
|
|||
|
||||
# Collect dart-defines we actually use on web.
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY')
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ $values = Parse-Env $EnvPath
|
|||
|
||||
# Collect dart-defines we actually use on web.
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY')
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($values[$k])) {
|
||||
$defineArgs += "--dart-define=$k=$($values[$k])"
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ $values = Parse-Env $EnvPath
|
|||
|
||||
# Collect dart-defines we actually use on Windows.
|
||||
$defineArgs = @()
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY')
|
||||
$keysOfInterest = @('API_BASE_URL', 'FIREBASE_WEB_VAPID_KEY', 'TURNSTILE_SITE_KEY')
|
||||
foreach ($k in $keysOfInterest) {
|
||||
if ($values.ContainsKey($k) -and -not [string]::IsNullOrWhiteSpace($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?,
|
||||
thumbnailUrl: json['thumbnail_url'] as String?,
|
||||
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?,
|
||||
|
||||
backgroundId: json['background_id'] as String?,
|
||||
tags: _parseTags(json['tags']),
|
||||
reactions: _parseReactions(
|
||||
|
|
|
|||
|
|
@ -87,9 +87,11 @@ class QuipUploadNotifier extends Notifier<QuipUploadState> {
|
|||
videoFile,
|
||||
onProgress: (p) => state = state.copyWith(progress: 0.1 + (p * 0.4)),
|
||||
);
|
||||
print('Video uploaded successfully: $videoUrl');
|
||||
|
||||
state = state.copyWith(progress: 0.5);
|
||||
|
||||
|
||||
// Upload thumbnail to Go Backend / R2
|
||||
String? thumbnailUrl;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -100,8 +100,11 @@ class AppRoutes {
|
|||
routes: [
|
||||
GoRoute(
|
||||
path: quips,
|
||||
builder: (_, __) => const QuipsFeedScreen(),
|
||||
builder: (_, state) => QuipsFeedScreen(
|
||||
initialPostId: state.uri.queryParameters['postId'],
|
||||
),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
|
|
@ -195,7 +198,16 @@ class AppRoutes {
|
|||
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
|
||||
static String getPostUrl(
|
||||
String postId, {
|
||||
|
|
@ -204,6 +216,7 @@ class AppRoutes {
|
|||
return '$baseUrl/p/$postId';
|
||||
}
|
||||
|
||||
|
||||
/// Get shareable URL for a beacon location
|
||||
/// Returns: https://sojorn.net/beacon?lat=...&long=...
|
||||
static String getBeaconUrl(
|
||||
|
|
|
|||
|
|
@ -31,7 +31,13 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||
bool _saveCredentials = true;
|
||||
String? _storedEmail;
|
||||
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 _savedPasswordKey = 'saved_login_password';
|
||||
|
|
@ -94,14 +100,13 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||
bool get _canUseBiometricLogin =>
|
||||
_supportsBiometric &&
|
||||
_hasStoredCredentials &&
|
||||
!_isBiometricAuthenticating;
|
||||
!_isBiometricAuthenticating &&
|
||||
_turnstileToken != null; // Require Turnstile for biometric too
|
||||
|
||||
Future<void> _signIn() async {
|
||||
final email = _emailController.text.trim();
|
||||
final password = _passwordController.text;
|
||||
|
||||
print('[SignIn] Attempting sign-in for $email');
|
||||
|
||||
if (email.isEmpty || !email.contains('@')) {
|
||||
setState(() {
|
||||
_errorMessage = 'Please enter a valid email address';
|
||||
|
|
@ -116,6 +121,14 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||
return;
|
||||
}
|
||||
|
||||
// Validate Turnstile token
|
||||
if (_turnstileToken == null || _turnstileToken!.isEmpty) {
|
||||
setState(() {
|
||||
_errorMessage = 'Please complete the security verification';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
|
|
@ -123,18 +136,18 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||
|
||||
try {
|
||||
final authService = ref.read(authServiceProvider);
|
||||
print('[SignIn] Calling signInWithGoBackend...');
|
||||
await authService.signInWithGoBackend(
|
||||
email: email,
|
||||
password: password,
|
||||
turnstileToken: _turnstileToken!,
|
||||
);
|
||||
print('[SignIn] Sign-in successful!');
|
||||
await _persistCredentials(email, password);
|
||||
} catch (e) {
|
||||
print('[SignIn] Error: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
// Reset Turnstile token on error so user must re-verify
|
||||
_turnstileToken = null;
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -382,7 +395,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||
obscureText: true,
|
||||
textInputAction: TextInputAction.done,
|
||||
prefixIcon: Icons.lock_outline,
|
||||
onEditingComplete: _signIn,
|
||||
onEditingComplete: _turnstileToken != null ? _signIn : null,
|
||||
autofillHints: const [AutofillHints.password],
|
||||
onChanged: (_) {
|
||||
if (_errorMessage != null) {
|
||||
|
|
@ -393,6 +406,46 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||
},
|
||||
),
|
||||
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),
|
||||
|
||||
if (_supportsBiometric) ...[
|
||||
|
|
@ -422,7 +475,7 @@ class _SignInScreenState extends ConsumerState<SignInScreen> {
|
|||
],
|
||||
sojornButton(
|
||||
label: 'Sign In',
|
||||
onPressed: isSubmitting ? null : _signIn,
|
||||
onPressed: (isSubmitting || _turnstileToken == null) ? null : _signIn,
|
||||
isLoading: isSubmitting,
|
||||
isFullWidth: true,
|
||||
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_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import 'profile_setup_screen.dart';
|
||||
import 'category_select_screen.dart';
|
||||
import '../../widgets/auth/turnstile_widget.dart';
|
||||
|
||||
class SignUpScreen extends ConsumerStatefulWidget {
|
||||
const SignUpScreen({super.key});
|
||||
|
|
@ -21,6 +23,22 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
final _confirmPasswordController = TextEditingController();
|
||||
bool _isLoading = false;
|
||||
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
|
||||
void dispose() {
|
||||
|
|
@ -34,6 +52,29 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
|
||||
Future<void> _signUp() async {
|
||||
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(() {
|
||||
_isLoading = true;
|
||||
|
|
@ -47,33 +88,37 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
password: _passwordController.text,
|
||||
handle: _handleController.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) {
|
||||
// Show success message and navigate to sign in
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Verify your email'),
|
||||
content: const Text(
|
||||
'A verification link has been sent to your email. Please check your inbox (and spam folder) to verify your account before logging in.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(); // dialog
|
||||
Navigator.of(context).pop(); // signup screen
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Verify your email'),
|
||||
content: const Text(
|
||||
'A verification link has been sent to your email. Please check your inbox (and spam folder) to verify your account before logging in.'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop(); // dialog
|
||||
Navigator.of(context).pop(); // signup screen
|
||||
},
|
||||
child: const Text('OK'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString().replaceAll('Exception: ', '');
|
||||
_turnstileToken = null; // Reset Turnstile on error
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
|
|
@ -84,8 +129,13 @@ 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
|
||||
Widget build(BuildContext context) {
|
||||
|
|
@ -113,31 +163,26 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
const SizedBox(height: AppTheme.spacingSm),
|
||||
|
||||
Text(
|
||||
'Your vibrant journey begins now', // Updated tagline
|
||||
'Your vibrant journey begins now',
|
||||
style: AppTheme.bodyMedium.copyWith(
|
||||
color: AppTheme.navyText.withOpacity(
|
||||
0.8), // Replaced AppTheme.textSecondary
|
||||
color: AppTheme.navyText.withOpacity(0.8),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(
|
||||
height: AppTheme.spacingLg *
|
||||
1.5), // Replaced AppTheme.spacing2xl
|
||||
const SizedBox(height: AppTheme.spacingLg * 1.5),
|
||||
|
||||
// Error message
|
||||
if (_errorMessage != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(AppTheme.spacingMd),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.error
|
||||
.withOpacity(0.1), // Replaced withValues
|
||||
color: AppTheme.error.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.error, width: 1),
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: AppTheme.textTheme.labelSmall?.copyWith(
|
||||
// Replaced AppTheme.bodySmall
|
||||
color: AppTheme.error,
|
||||
),
|
||||
),
|
||||
|
|
@ -151,10 +196,15 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: 'Handle (@username)',
|
||||
hintText: 'sojorn_user',
|
||||
prefixIcon: Icon(Icons.alternate_email),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Handle is required';
|
||||
return null;
|
||||
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;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
|
|
@ -165,10 +215,11 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: 'Display Name',
|
||||
hintText: 'Jane Doe',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return 'Display Name is required';
|
||||
return null;
|
||||
if (value == null || value.isEmpty) return 'Display Name is required';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
|
|
@ -180,12 +231,13 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
hintText: 'your@email.com',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Email is required';
|
||||
}
|
||||
if (!value.contains('@')) {
|
||||
if (!value.contains('@') || !value.contains('.')) {
|
||||
return 'Enter a valid email';
|
||||
}
|
||||
return null;
|
||||
|
|
@ -200,6 +252,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
decoration: const InputDecoration(
|
||||
labelText: 'Password',
|
||||
hintText: 'At least 6 characters',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
|
|
@ -219,6 +272,7 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Confirm Password',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
|
|
@ -232,30 +286,131 @@ class _SignUpScreenState extends ConsumerState<SignUpScreen> {
|
|||
),
|
||||
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
|
||||
ElevatedButton(
|
||||
onPressed: _isLoading ? null : _signUp,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation(Colors.white),
|
||||
valueColor: AlwaysStoppedAnimation(Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text('Continue'),
|
||||
: const Text('Create Account'),
|
||||
),
|
||||
const SizedBox(height: AppTheme.spacingMd),
|
||||
|
||||
// Terms and privacy note
|
||||
// Footer
|
||||
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(
|
||||
// Replaced AppTheme.bodySmall
|
||||
color: AppTheme
|
||||
.egyptianBlue, // Replaced AppTheme.textTertiary
|
||||
color: AppTheme.egyptianBlue,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -18,9 +18,11 @@ import '../secure_chat/secure_chat_full_screen.dart';
|
|||
import '../../services/notification_service.dart';
|
||||
import '../../widgets/post/post_body.dart';
|
||||
import '../../widgets/post/post_view_mode.dart';
|
||||
import '../../widgets/post/post_media.dart';
|
||||
import '../../providers/notification_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
|
||||
class ThreadedConversationScreen extends ConsumerStatefulWidget {
|
||||
final String rootPostId;
|
||||
final Post? rootPost;
|
||||
|
|
@ -495,10 +497,14 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
|||
_buildStageHeader(focalPost),
|
||||
const SizedBox(height: 16),
|
||||
_buildStageContent(focalPost),
|
||||
if (focalPost.imageUrl != null) ...[
|
||||
if (focalPost.imageUrl != null || focalPost.videoUrl != null || focalPost.thumbnailUrl != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildStageMedia(focalPost.imageUrl!),
|
||||
PostMedia(
|
||||
post: focalPost,
|
||||
mode: PostViewMode.detail,
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
_buildStageActions(focalPost),
|
||||
],
|
||||
|
|
@ -1190,7 +1196,13 @@ class _ThreadedConversationScreenState extends ConsumerState<ThreadedConversatio
|
|||
|
||||
Future<void> _sharePost(Post post) async {
|
||||
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 {
|
||||
await Share.share(text);
|
||||
|
|
|
|||
|
|
@ -67,7 +67,9 @@ class Quip {
|
|||
|
||||
class QuipsFeedScreen extends ConsumerStatefulWidget {
|
||||
final bool? isActive;
|
||||
const QuipsFeedScreen({super.key, this.isActive});
|
||||
final String? initialPostId;
|
||||
const QuipsFeedScreen({super.key, this.isActive, this.initialPostId});
|
||||
|
||||
|
||||
@override
|
||||
ConsumerState<QuipsFeedScreen> createState() => _QuipsFeedScreenState();
|
||||
|
|
@ -99,9 +101,15 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
|||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_isScreenActive = widget.isActive ?? false;
|
||||
_fetchQuips();
|
||||
if (widget.initialPostId != null) {
|
||||
_isUserPaused = false;
|
||||
}
|
||||
_fetchQuips(refresh: widget.initialPostId != null);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
void _checkFeedRefresh() {
|
||||
final refreshToken = ref.read(feedRefreshProvider);
|
||||
if (refreshToken != _lastRefreshToken) {
|
||||
|
|
@ -135,8 +143,14 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
|||
if (widget.isActive != oldWidget.isActive) {
|
||||
_handleScreenActive(_resolveActiveState());
|
||||
}
|
||||
if (widget.initialPostId != oldWidget.initialPostId && widget.initialPostId != null) {
|
||||
_isUserPaused = false; // Auto-play if user explicitly clicked a quip
|
||||
_fetchQuips(refresh: true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
|
|
@ -227,11 +241,43 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
|||
final posts =
|
||||
(data['posts'] as List? ?? []).whereType<Map<String, dynamic>>();
|
||||
|
||||
final items = posts
|
||||
List<Quip> items = posts
|
||||
.map(Quip.fromMap)
|
||||
.where((quip) => quip.videoUrl.isNotEmpty)
|
||||
.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;
|
||||
setState(() {
|
||||
if (refresh) {
|
||||
|
|
@ -419,10 +465,13 @@ class _QuipsFeedScreenState extends ConsumerState<QuipsFeedScreen>
|
|||
}
|
||||
|
||||
void _shareQuip(Quip quip) {
|
||||
final url = AppRoutes.getPostUrl(quip.id);
|
||||
Share.share(url);
|
||||
final url = AppRoutes.getQuipUrl(quip.id);
|
||||
final text = '${quip.caption}\n\n$url\n\n— @${quip.username} on Sojorn';
|
||||
Share.share(text);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null) {
|
||||
|
|
|
|||
|
|
@ -578,8 +578,14 @@ class ApiService {
|
|||
if (long != null && (long < -180 || long > 180)) {
|
||||
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(
|
||||
|
||||
'/posts',
|
||||
method: 'POST',
|
||||
body: {
|
||||
|
|
@ -588,9 +594,13 @@ class ApiService {
|
|||
'body_format': bodyFormat,
|
||||
'allow_chain': allowChain,
|
||||
if (chainParentId != null) 'chain_parent_id': chainParentId,
|
||||
if (sanitizedImageUrl != null) 'image_url': sanitizedImageUrl,
|
||||
if (sanitizedVideoUrl != null) 'video_url': sanitizedVideoUrl,
|
||||
if (sanitizedThumbnailUrl != null) 'thumbnail_url': sanitizedThumbnailUrl,
|
||||
if (sanitizedImageUrl != null || (imageUrl != null && imageUrl.isNotEmpty))
|
||||
'image_url': sanitizedImageUrl ?? imageUrl,
|
||||
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 (ttlHours != null) 'ttl_hours': ttlHours,
|
||||
if (isBeacon) 'is_beacon': true,
|
||||
|
|
@ -762,11 +772,17 @@ class ApiService {
|
|||
}
|
||||
|
||||
Future<void> blockUser(String userId) async {
|
||||
// Migrate to Go API
|
||||
await _callGoApi(
|
||||
'/users/$userId/block',
|
||||
method: 'POST',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> unblockUser(String userId) async {
|
||||
// Migrate to Go API
|
||||
await _callGoApi(
|
||||
'/users/$userId/block',
|
||||
method: 'DELETE',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> appreciatePost(String postId) async {
|
||||
|
|
@ -846,7 +862,7 @@ class ApiService {
|
|||
await _callGoApi('/conversations/$conversationId', method: 'DELETE');
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('[API] Failed to delete conversation: $e');
|
||||
if (kDebugMode) print('[API] Failed to delete conversation: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -856,7 +872,7 @@ class ApiService {
|
|||
await _callGoApi('/messages/$messageId', method: 'DELETE');
|
||||
return true;
|
||||
} catch (e) {
|
||||
print('[API] Failed to delete message: $e');
|
||||
if (kDebugMode) print('[API] Failed to delete message: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -867,7 +883,7 @@ class ApiService {
|
|||
|
||||
Future<Map<String, dynamic>> getKeyBundle(String userId) async {
|
||||
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.
|
||||
if (data.containsKey('identity_key') && data['identity_key'] is Map) {
|
||||
final identityKey = data['identity_key'] as Map<String, dynamic>;
|
||||
|
|
|
|||
|
|
@ -200,13 +200,18 @@ class AuthService {
|
|||
Future<Map<String, dynamic>> signInWithGoBackend({
|
||||
required String email,
|
||||
required String password,
|
||||
required String turnstileToken,
|
||||
}) async {
|
||||
try {
|
||||
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/login');
|
||||
final response = await http.post(
|
||||
uri,
|
||||
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);
|
||||
|
|
@ -253,6 +258,11 @@ class AuthService {
|
|||
required String password,
|
||||
required String handle,
|
||||
required String displayName,
|
||||
required String turnstileToken,
|
||||
required bool acceptTerms,
|
||||
required bool acceptPrivacy,
|
||||
bool emailNewsletter = false,
|
||||
bool emailContact = false,
|
||||
}) async {
|
||||
try {
|
||||
final uri = Uri.parse('${ApiConfig.baseUrl}/auth/register');
|
||||
|
|
@ -264,6 +274,11 @@ class AuthService {
|
|||
'password': password,
|
||||
'handle': handle,
|
||||
'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);
|
||||
}
|
||||
|
||||
// DEBUG: Set to true to force new key generation on startup (fixing bad keys)
|
||||
static const bool _FORCE_KEY_ROTATION = false;
|
||||
// Key rotation is now handled via initiateKeyRecovery() when needed
|
||||
// DO NOT add debug flags here - use resetAllKeys() method for intentional resets
|
||||
|
||||
Future<void> resetAllKeys() async {
|
||||
print('[E2EE] RESETTING ALL KEYS - fixing MAC errors');
|
||||
|
|
@ -202,11 +202,7 @@ class SimpleE2EEService {
|
|||
Future<void> _doInitialize(String userId) async {
|
||||
_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
|
||||
try {
|
||||
|
|
@ -640,7 +636,7 @@ class SimpleE2EEService {
|
|||
final secretBox = SecretBox(ciphertextBytes, nonce: nonce, mac: Mac(macBytes));
|
||||
final plaintextBytes = await _cipher.decrypt(secretBox, secretKey: SecretKey(rootSecret));
|
||||
final plaintext = utf8.decode(plaintextBytes);
|
||||
print('[DECRYPT] SUCCESS: Decrypted message: "$plaintext"');
|
||||
// Decryption successful - plaintext not logged for security
|
||||
return plaintext;
|
||||
} catch (e) {
|
||||
print('[DECRYPT] Failed: $e');
|
||||
|
|
|
|||
|
|
@ -62,8 +62,9 @@ class SecurityUtils {
|
|||
final dangerousPatterns = [
|
||||
'script', 'javascript', 'vbscript', 'onload', 'onerror', 'onclick',
|
||||
'eval', 'expression', 'alert', 'confirm', 'prompt',
|
||||
'<', '>', '"', "'", '\\', '/', '\n', '\r', '\t'
|
||||
'<', '>', '"', "'", '\\', '\n', '\r', '\t'
|
||||
];
|
||||
|
||||
|
||||
final lowerValue = value.toLowerCase();
|
||||
return dangerousPatterns.any((pattern) => lowerValue.contains(pattern));
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import '../../models/post.dart';
|
||||
import '../../routes/app_routes.dart';
|
||||
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../media/signed_media_image.dart';
|
||||
import 'post_view_mode.dart';
|
||||
|
|
@ -14,62 +17,74 @@ class PostMedia extends StatelessWidget {
|
|||
final Post? post;
|
||||
final Widget? child;
|
||||
final PostViewMode mode;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const PostMedia({
|
||||
super.key,
|
||||
this.post,
|
||||
this.child,
|
||||
this.mode = PostViewMode.feed,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
|
||||
/// Get image height based on view mode
|
||||
double get _imageHeight {
|
||||
switch (mode) {
|
||||
case PostViewMode.feed:
|
||||
return 300.0;
|
||||
return 450.0; // Taller for better resolution/ratio
|
||||
case PostViewMode.detail:
|
||||
return 500.0; // Full height for detail view
|
||||
return 600.0;
|
||||
case PostViewMode.compact:
|
||||
return 200.0; // Smaller for profile lists
|
||||
return 200.0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@override
|
||||
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(
|
||||
padding: const EdgeInsets.only(top: AppTheme.spacingSm),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(maxHeight: _imageHeight),
|
||||
child: SizedBox(
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppTheme.radiusMd),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
child: SignedMediaImage(
|
||||
url: post!.imageUrl!,
|
||||
fit: 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// For videos in feed mode, use a more vertical 4:5 aspect ratio
|
||||
// For other modes or non-videos, use the constrained height logic
|
||||
child: InkWell(
|
||||
onTap: isVideo
|
||||
? () {
|
||||
final url = '${AppRoutes.quips}?postId=${post!.id}';
|
||||
print('[PostMedia] Navigating to quips: $url');
|
||||
context.go(url);
|
||||
}
|
||||
: onTap,
|
||||
|
||||
|
||||
child: (isVideo && mode == PostViewMode.feed)
|
||||
? AspectRatio(
|
||||
aspectRatio: 4 / 5,
|
||||
child: _buildMediaContent(displayUrl, true),
|
||||
)
|
||||
: ConstrainedBox(
|
||||
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 '../models/post.dart';
|
||||
|
||||
import '../theme/app_theme.dart';
|
||||
import 'post/post_actions.dart';
|
||||
import 'post/post_body.dart';
|
||||
|
|
@ -195,18 +196,21 @@ class sojornPostCard extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
|
||||
// Media (if available) - clickable for post detail
|
||||
if (post.imageUrl != null && post.imageUrl!.isNotEmpty) ...[
|
||||
// Media (if available) - clickable for post detail (or quip player if video)
|
||||
if ((post.imageUrl != null && post.imageUrl!.isNotEmpty) ||
|
||||
(post.thumbnailUrl != null && post.thumbnailUrl!.isNotEmpty) ||
|
||||
(post.videoUrl != null && post.videoUrl!.isNotEmpty)) ...[
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
PostMedia(
|
||||
post: post,
|
||||
mode: mode,
|
||||
onTap: onTap,
|
||||
child: PostMedia(
|
||||
post: post,
|
||||
mode: mode,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
|
||||
|
||||
|
||||
// Actions section - with padding
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ dependencies:
|
|||
google_fonts: ^6.2.1
|
||||
share_plus: ^10.0.2
|
||||
timeago: ^3.7.0
|
||||
url_launcher: ^6.3.1
|
||||
url_launcher: ^6.3.2
|
||||
image_picker: ^1.1.2
|
||||
image: ^4.3.0
|
||||
flutter_image_compress: ^2.4.0
|
||||
|
|
|
|||
|
|
@ -40,12 +40,17 @@ $defineArgs = @(
|
|||
"--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 ""
|
||||
|
||||
Push-Location $PSScriptRoot
|
||||
try {
|
||||
flutter run @defineArgs @Args
|
||||
} finally {
|
||||
}
|
||||
finally {
|
||||
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