From ffe96c8c65d0c2bb831c0175019ed3d71def8b1a Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 20 Jan 2026 10:10:50 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=ED=97=AC=EC=8A=A4=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/cmd/server/main.go | 69 ++++++++++++++++--- backend/internal/handler/auth_handler.go | 8 +-- .../internal/repository/clickhouse_repo.go | 7 ++ backend/internal/service/redis_service.go | 7 ++ docker-compose.yaml | 12 +++- docker/docker-compose.deploy.yaml | 9 ++- 6 files changed, 94 insertions(+), 18 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 8951cb40..f31f80d5 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -6,12 +6,21 @@ import ( "strconv" "time" - "github.com/bwmarrin/snowflake" - "baron-sso-backend/internal/handler" - "baron-sso-backend/internal/logger" - "baron-sso-backend/internal/repository" - - "github.com/gofiber/fiber/v2" + "github.com/bwmarrin/snowflake" + + "baron-sso-backend/internal/handler" + + "baron-sso-backend/internal/logger" + + "baron-sso-backend/internal/repository" + + "baron-sso-backend/internal/service" + + + + "github.com/gofiber/fiber/v2" + + "github.com/gofiber/fiber/v2/middleware/cors" "github.com/gofiber/fiber/v2/middleware/encryptcookie" "github.com/gofiber/fiber/v2/middleware/recover" @@ -58,9 +67,14 @@ func main() { slog.Warn("Failed to connect to ClickHouse. Audit logs will fail.", "error", err) } + redisService, err := service.NewRedisService() + if err != nil { + slog.Warn("Failed to connect to Redis. Auth features may fail.", "error", err) + } + // 2. Initialize Handlers auditHandler := handler.NewAuditHandler(auditRepo) - authHandler := handler.NewAuthHandler() + authHandler := handler.NewAuthHandler(redisService) adminHandler := handler.NewAdminHandler() // 3. Initialize Fiber @@ -118,7 +132,46 @@ func main() { }) app.Get("/health", func(c *fiber.Ctx) error { - return c.JSON(fiber.Map{"status": "ok"}) + status := "ok" + checks := make(map[string]string) + + // Check ClickHouse + if auditRepo != nil { + if err := auditRepo.Ping(c.Context()); err != nil { + checks["clickhouse"] = "error: " + err.Error() + status = "error" + } else { + checks["clickhouse"] = "ok" + } + } else { + checks["clickhouse"] = "not_initialized" + status = "degraded" + } + + // Check Redis + if redisService != nil { + if err := redisService.Ping(c.Context()); err != nil { + checks["redis"] = "error: " + err.Error() + status = "error" + } else { + checks["redis"] = "ok" + } + } else { + checks["redis"] = "not_initialized" + status = "degraded" + } + + if status == "error" { + return c.Status(fiber.StatusServiceUnavailable).JSON(fiber.Map{ + "status": status, + "checks": checks, + }) + } + + return c.JSON(fiber.Map{ + "status": status, + "checks": checks, + }) }) // API Group diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 5b8700d2..df498cb9 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -49,16 +49,12 @@ func GenerateSecureToken(length int) string { return hex.EncodeToString(b) } -func NewAuthHandler() *AuthHandler { - redisService, err := service.NewRedisService() - if err != nil { - log.Fatalf("Failed to connect to Redis: %v", err) - } - +func NewAuthHandler(redisService *service.RedisService) *AuthHandler { projectID := os.Getenv("DESCOPE_PROJECT_ID") managementKey := os.Getenv("DESCOPE_MANAGEMENT_KEY") var descopeClient *client.DescopeClient + var err error if projectID != "" { descopeClient, err = client.NewWithConfig(&client.Config{ ProjectID: projectID, diff --git a/backend/internal/repository/clickhouse_repo.go b/backend/internal/repository/clickhouse_repo.go index e980259e..278a9d9d 100644 --- a/backend/internal/repository/clickhouse_repo.go +++ b/backend/internal/repository/clickhouse_repo.go @@ -79,3 +79,10 @@ func (r *ClickHouseRepository) Create(log *domain.AuditLog) error { log.Details, ) } + +func (r *ClickHouseRepository) Ping(ctx context.Context) error { + if r.conn == nil { + return fmt.Errorf("clickhouse connection is nil") + } + return r.conn.Ping(ctx) +} diff --git a/backend/internal/service/redis_service.go b/backend/internal/service/redis_service.go index 08e60a62..1686e5bb 100644 --- a/backend/internal/service/redis_service.go +++ b/backend/internal/service/redis_service.go @@ -33,6 +33,13 @@ func NewRedisService() (*RedisService, error) { return &RedisService{Client: rdb}, nil } +func (s *RedisService) Ping(ctx context.Context) error { + if s.Client == nil { + return os.ErrInvalid + } + return s.Client.Ping(ctx).Err() +} + // StoreVerificationCode saves the SMS verification code with a 3-minute expiration func (s *RedisService) StoreVerificationCode(phone, code string) error { // Key format: "sms_verify:01012345678" diff --git a/docker-compose.yaml b/docker-compose.yaml index 18eb507f..68328c1c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,3 @@ -version: "3.8" - services: backend: build: @@ -35,6 +33,13 @@ services: - ./backend:/app command: ["go", "run", "./cmd/server/main.go"] + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + frontend: build: context: ./frontend @@ -52,7 +57,8 @@ services: networks: - baron_net depends_on: - - backend + backend: + condition: service_healthy command: > /bin/sh -c "mkdir -p /usr/share/nginx/html/assets && echo \"DESCOPE_PROJECT_ID=$${DESCOPE_PROJECT_ID}\" > /usr/share/nginx/html/assets/.env && diff --git a/docker/docker-compose.deploy.yaml b/docker/docker-compose.deploy.yaml index 48b4671e..4544acfb 100644 --- a/docker/docker-compose.deploy.yaml +++ b/docker/docker-compose.deploy.yaml @@ -20,6 +20,12 @@ services: - "${BACKEND_PORT:-3000}:3000" depends_on: - infra_check + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s networks: - baron_net @@ -30,7 +36,8 @@ services: ports: - "${FRONTEND_PORT:-80}:80" depends_on: - - backend + backend: + condition: service_healthy networks: - baron_net From 16552dac549ba79262698eb004e705f84f597f74 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 20 Jan 2026 10:17:17 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=EB=82=B4=EC=9A=A9=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.sample | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.env.sample b/.env.sample index 783920b6..3f12797e 100644 --- a/.env.sample +++ b/.env.sample @@ -3,7 +3,7 @@ # ========================================== # --- General System --- -APP_ENV=dev +APP_ENV=dev # 애플리케이션 실행 환경 (deve, production) TZ=Asia/Seoul # --- Infrastructure Ports --- @@ -23,7 +23,6 @@ DB_NAME=baron_sso COOKIE_SECRET=super-secret-key-must-be-32-bytes! REDIS_ADDR=redis:6379 -# --- Frontend Configuration --- # Descope Project ID (Required for Auth) DESCOPE_PROJECT_ID=P2t...your_descope_project_id DESCOPE_MANAGEMENT_KEY=your_descope_management_key_here @@ -34,10 +33,15 @@ NAVER_CLOUD_SECRET_KEY=ncp_iam_... NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:...:... NAVER_SENDER_PHONE_NUMBER=... -# --- AWS SES Configuration --- + # --- AWS SES (이메일 발송용) --- AWS_REGION=ap-northeast-2 AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=... AWS_SES_SENDER=no-reply@baron.co.kr -ADMIN_PASSWORD=admin \ No newline at end of file +# --- 관리자 page pw --- +ADMIN_PASSWORD=admin + +# --- URLs for Proxy/Handoff --- +FRONTEND_URL=https://ssologin.hmac.kr # 프론트엔드 접속 주소 (이메일/SMS 링크 생성 시 사용) +BACKEND_URL=https://ssologin.hmac.kr # 프론트엔드에서 참조할 백엔드 API 주소 \ No newline at end of file From 8c5d87a5d2e911e99536edc33535a123e54c2697 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 20 Jan 2026 10:56:15 +0900 Subject: [PATCH 3/6] 200 log x --- backend/cmd/server/main.go | 26 ++++++++++++++++--- .../internal/repository/clickhouse_repo.go | 2 +- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index f31f80d5..522852b1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -3,8 +3,9 @@ package main import ( "log/slog" "os" - "strconv" - "time" + "strings" + "strconv" + "time" "github.com/bwmarrin/snowflake" @@ -99,6 +100,13 @@ func main() { // Log after request latency := time.Since(start) + status := c.Response().StatusCode() + path := c.Path() + + // Skip logging for all successful requests (status < 400) + if status < 400 { + return err + } msg := "http_request" if err != nil { @@ -106,9 +114,9 @@ func main() { } slog.Info(msg, - "status", c.Response().StatusCode(), + "status", status, "method", c.Method(), - "path", c.Path(), + "path", path, "latency", latency.String(), "ip", c.IP(), "req_id", c.GetRespHeader(fiber.HeaderXRequestID), @@ -241,6 +249,16 @@ func main() { level = slog.LevelInfo } + // Filter out noisy client navigation logs + if level == slog.LevelInfo { + msg := strings.ToLower(req.Message) + if strings.Contains(msg, "navigating to") || + strings.Contains(msg, "going to") || + strings.Contains(msg, "redirecting to") { + return c.SendStatus(fiber.StatusOK) + } + } + slog.Log(c.Context(), level, req.Message, attrs...) return c.SendStatus(fiber.StatusOK) }) diff --git a/backend/internal/repository/clickhouse_repo.go b/backend/internal/repository/clickhouse_repo.go index 278a9d9d..4b3fe03d 100644 --- a/backend/internal/repository/clickhouse_repo.go +++ b/backend/internal/repository/clickhouse_repo.go @@ -23,7 +23,7 @@ func NewClickHouseRepository(host string, port int, user, password, db string) ( Username: user, Password: password, }, - Debug: true, + Debug: false, }) if err != nil { From 431e3a7e05cb8d4872ad525869a60501e16b76ff Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 20 Jan 2026 11:22:07 +0900 Subject: [PATCH 4/6] =?UTF-8?q?root=20page=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/create_user_screen.dart | 6 +-- .../presentation/user_management_screen.dart | 6 +-- .../auth/presentation/approve_qr_screen.dart | 8 ++-- .../auth/presentation/login_screen.dart | 2 +- .../presentation/login_success_screen.dart | 4 +- .../presentation/dashboard_screen.dart | 2 +- frontend/lib/main.dart | 39 +++++++++++-------- 7 files changed, 37 insertions(+), 30 deletions(-) diff --git a/frontend/lib/features/admin/presentation/create_user_screen.dart b/frontend/lib/features/admin/presentation/create_user_screen.dart index 35891870..da5337ea 100644 --- a/frontend/lib/features/admin/presentation/create_user_screen.dart +++ b/frontend/lib/features/admin/presentation/create_user_screen.dart @@ -67,7 +67,7 @@ class _CreateUserScreenState extends State { // If cancelled or empty if (inputPassword == null || inputPassword.isEmpty) { - if (mounted) context.go('/dashboard'); // Kick out + if (mounted) context.go('/'); // Kick out return; } @@ -88,7 +88,7 @@ class _CreateUserScreenState extends State { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Invalid Password. Access Denied.'), backgroundColor: Colors.red), ); - context.go('/dashboard'); // Kick out + context.go('/'); // Kick out } } } @@ -152,7 +152,7 @@ class _CreateUserScreenState extends State { title: const Text('Create User'), leading: IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/dashboard'), + onPressed: () => context.go('/'), ), ), body: Center( diff --git a/frontend/lib/features/admin/presentation/user_management_screen.dart b/frontend/lib/features/admin/presentation/user_management_screen.dart index 07e8ed4b..f779e779 100644 --- a/frontend/lib/features/admin/presentation/user_management_screen.dart +++ b/frontend/lib/features/admin/presentation/user_management_screen.dart @@ -78,7 +78,7 @@ class _UserManagementScreenState extends State with Single ); if (inputPassword == null || inputPassword.isEmpty) { - if (mounted) context.go('/dashboard'); + if (mounted) context.go('/'); return; } @@ -97,7 +97,7 @@ class _UserManagementScreenState extends State with Single } else { if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('Invalid Password'), backgroundColor: Colors.red)); - context.go('/dashboard'); + context.go('/'); } } } @@ -277,7 +277,7 @@ class _UserManagementScreenState extends State with Single title: const Text('User Management'), leading: IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => context.go('/dashboard'), + onPressed: () => context.go('/'), ), bottom: TabBar( controller: _tabController, diff --git a/frontend/lib/features/auth/presentation/approve_qr_screen.dart b/frontend/lib/features/auth/presentation/approve_qr_screen.dart index c60a31be..2b4d2d5a 100644 --- a/frontend/lib/features/auth/presentation/approve_qr_screen.dart +++ b/frontend/lib/features/auth/presentation/approve_qr_screen.dart @@ -22,7 +22,7 @@ class _ApproveQrScreenState extends State { final session = Descope.sessionManager.session; if (session == null || session.refreshToken.isExpired) { setState(() => _message = "Please log in on your phone first."); - context.go('/'); // Redirect to login + context.go('/login'); // Redirect to login return; } @@ -43,7 +43,7 @@ class _ApproveQrScreenState extends State { // Automatically go to dashboard after a short delay Future.delayed(const Duration(seconds: 1), () { - if (mounted) context.go('/dashboard'); + if (mounted) context.go('/'); }); } catch (e) { setState(() => _message = "Error: $e"); @@ -103,14 +103,14 @@ class _ApproveQrScreenState extends State { Padding( padding: const EdgeInsets.only(top: 16), child: TextButton( - onPressed: () => context.go('/'), + onPressed: () => context.go('/login'), child: const Text("Login on this device first"), ), ), if (_success) FilledButton( - onPressed: () => context.go('/dashboard'), + onPressed: () => context.go('/'), child: const Text("Go to My Dashboard"), ), ], diff --git a/frontend/lib/features/auth/presentation/login_screen.dart b/frontend/lib/features/auth/presentation/login_screen.dart index bd805a65..8914dc6e 100644 --- a/frontend/lib/features/auth/presentation/login_screen.dart +++ b/frontend/lib/features/auth/presentation/login_screen.dart @@ -409,7 +409,7 @@ class _LoginScreenState extends ConsumerState // We call notify() to update the router's state, and go() to ensure navigation. AuthNotifier.instance.notify(); if (mounted) { - context.go('/dashboard'); + context.go('/'); } } diff --git a/frontend/lib/features/auth/presentation/login_success_screen.dart b/frontend/lib/features/auth/presentation/login_success_screen.dart index 7ca7293a..a3b97896 100644 --- a/frontend/lib/features/auth/presentation/login_success_screen.dart +++ b/frontend/lib/features/auth/presentation/login_success_screen.dart @@ -34,7 +34,7 @@ class LoginSuccessScreen extends StatelessWidget { // 이 버튼이 QR 카메라를 켜는 버튼입니다. FilledButton.icon( onPressed: () { - context.push('/qr-scan'); + context.push('/scan'); }, icon: const Icon(Icons.camera_alt, size: 28), label: const Text("QR 인증 (카메라 켜기)"), @@ -50,7 +50,7 @@ class LoginSuccessScreen extends StatelessWidget { const SizedBox(height: 24), TextButton( onPressed: () { - context.go('/dashboard'); + context.go('/'); }, child: const Text("나중에 하기 (대시보드로 이동)", style: TextStyle(color: Colors.grey)), ), diff --git a/frontend/lib/features/dashboard/presentation/dashboard_screen.dart b/frontend/lib/features/dashboard/presentation/dashboard_screen.dart index 12de5b48..fe6c56ff 100644 --- a/frontend/lib/features/dashboard/presentation/dashboard_screen.dart +++ b/frontend/lib/features/dashboard/presentation/dashboard_screen.dart @@ -25,7 +25,7 @@ class DashboardScreen extends StatelessWidget { return Scaffold( backgroundColor: Colors.grey[50], appBar: AppBar( - title: Text('Baron Launcher', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)), + title: Text('Baron SSO', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)), elevation: 0, backgroundColor: Colors.white, foregroundColor: Colors.black, diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index f235ba21..735acff6 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -69,9 +69,16 @@ final _router = GoRouter( refreshListenable: AuthNotifier.instance, routes: [ GoRoute( - path: '/', + path: '/', builder: (context, state) { - _routerLogger.info("Navigating to root (LoginScreen)"); + _routerLogger.info("Navigating to root (DashboardScreen)"); + return const DashboardScreen(); + }, + ), + GoRoute( + path: '/login', + builder: (context, state) { + _routerLogger.info("Navigating to /login"); return const LoginScreen(); } ), @@ -91,13 +98,6 @@ final _router = GoRouter( return ApproveQrScreen(pendingRef: ref); }, ), - GoRoute( - path: '/dashboard', - builder: (context, state) { - _routerLogger.info("Navigating to /dashboard"); - return const DashboardScreen(); - }, - ), GoRoute( path: '/scan', builder: (context, state) { @@ -117,17 +117,24 @@ final _router = GoRouter( final isLoggedIn = Descope.sessionManager.session?.refreshToken.isExpired == false; final path = state.uri.path; - final isLoggingIn = path == '/' || path.startsWith('/verify/') || path.startsWith('/admin/') || path == '/approve'; + + // Public paths that don't require login + final isPublicPath = path == '/login' || + path.startsWith('/verify/') || + path == '/approve'; _routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn"); - if (!isLoggedIn && !isLoggingIn) { - _routerLogger.info("Not logged in, redirecting to /"); - return '/'; + // If not logged in and trying to access a protected page, redirect to /login + if (!isLoggedIn && !isPublicPath) { + _routerLogger.info("Not logged in, redirecting to /login"); + return '/login'; } - if (isLoggedIn && path == '/') { - _routerLogger.info("Logged in, redirecting to /dashboard"); - return '/dashboard'; + + // If logged in and trying to access login page, redirect to root (dashboard) + if (isLoggedIn && (path == '/login' || path.startsWith('/verify/'))) { + _routerLogger.info("Logged in, redirecting to /"); + return '/'; } return null; From 48568017567c28cd778f25a24d1f97dd64bb7abb Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 20 Jan 2026 15:25:56 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=EC=9E=90=EC=8B=9D=20=EC=B0=BD=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/login_screen.dart | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/frontend/lib/features/auth/presentation/login_screen.dart b/frontend/lib/features/auth/presentation/login_screen.dart index 8914dc6e..df81d0a2 100644 --- a/frontend/lib/features/auth/presentation/login_screen.dart +++ b/frontend/lib/features/auth/presentation/login_screen.dart @@ -391,22 +391,27 @@ class _LoginScreenState extends ConsumerState details: "User logged in via Baron SSO", ); - // 1. Handle Redirect Flow (Redirect to another app) - if (_redirectUrl != null && _redirectUrl!.isNotEmpty) { - final target = "$_redirectUrl?token=$token"; - launchUrlString(target, webOnlyWindowName: '_self'); - return; + // 1. Handle Popup Flow (Highest Priority for child windows) + // If opened as a popup (has opener), we notify and try to close. + if (WebAuthIntegration.isPopup()) { + debugPrint("[Auth] Popup detected. Notifying opener and attempting to close."); + WebAuthIntegration.sendLoginSuccess(token); + + // We don't 'return' here to allow a fallback if window.close() is blocked, + // but in most cases WebAuthIntegration.sendLoginSuccess will close the window. + } else { + // 2. Handle Redirect Flow (Only if NOT a popup) + if (_redirectUrl != null && _redirectUrl!.isNotEmpty) { + debugPrint("[Auth] Redirecting standalone window to: $_redirectUrl"); + final target = "$_redirectUrl?token=$token"; + launchUrlString(target, webOnlyWindowName: '_self'); + return; + } } - // 2. Handle Popup Flow (Send message to opener) - if (WebAuthIntegration.isPopup()) { - WebAuthIntegration.sendLoginSuccess(token); - // If this window was truly a popup for another app, it should close now. - // If it's still here, we allow it to fall through to the dashboard. - } - - // 3. Standalone mode: Go to dashboard - // We call notify() to update the router's state, and go() to ensure navigation. + // 3. Standalone mode / Fallback + // If it's a standard login, or if a popup's window.close() was blocked by the browser. + debugPrint("[Auth] Login success. Navigating to root."); AuthNotifier.instance.notify(); if (mounted) { context.go('/'); From 20e848deb6a9d7c1d0c087d933a7da9ebf4ac796 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 20 Jan 2026 15:27:35 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=EB=A6=AC=EB=8B=A4=EC=9D=B4=EB=A0=89?= =?UTF-8?q?=ED=8A=B8=20=EB=82=A9=EC=B9=98=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/main.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 735acff6..b63cfb0f 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -125,6 +125,11 @@ final _router = GoRouter( _routerLogger.fine("Redirect check - Path: $path, IsLoggedIn: $isLoggedIn"); + // 0. ALWAYS allow /verify/ to proceed so it can signal the backend + if (path.startsWith('/verify/')) { + return null; + } + // If not logged in and trying to access a protected page, redirect to /login if (!isLoggedIn && !isPublicPath) { _routerLogger.info("Not logged in, redirecting to /login"); @@ -132,7 +137,7 @@ final _router = GoRouter( } // If logged in and trying to access login page, redirect to root (dashboard) - if (isLoggedIn && (path == '/login' || path.startsWith('/verify/'))) { + if (isLoggedIn && path == '/login') { _routerLogger.info("Logged in, redirecting to /"); return '/'; }