File size: 25,916 Bytes
02ee0b7
 
52279ac
02ee0b7
 
 
 
 
 
 
ebeaf07
 
fd2d573
02ee0b7
a4bd58d
 
d39cf95
02ee0b7
 
 
 
 
 
 
175d049
02ee0b7
 
 
52279ac
02ee0b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a87173d
d39cf95
 
 
 
02ee0b7
 
a6b8602
 
 
b1b13d2
 
 
00c6951
 
 
 
 
e236466
 
 
 
 
 
 
 
 
 
 
02ee0b7
00c6951
 
 
175d049
00c6951
 
 
 
175d049
 
 
 
00c6951
 
175d049
00c6951
175d049
00c6951
 
175d049
 
02ee0b7
 
 
 
 
 
 
 
 
 
 
175d049
 
 
00c6951
 
175d049
02ee0b7
 
 
 
 
 
 
 
e236466
 
 
 
 
 
 
 
02ee0b7
 
 
 
 
26c5f33
 
02ee0b7
26c5f33
02ee0b7
 
 
 
175d049
02ee0b7
 
 
7799184
02ee0b7
 
 
 
 
40c9a85
a6b8602
 
 
 
 
7799184
40c9a85
7799184
40c9a85
7799184
 
40c9a85
 
7799184
40c9a85
7799184
 
 
 
 
 
40c9a85
 
7799184
 
 
 
 
 
 
 
 
a6b8602
 
02ee0b7
 
 
 
 
 
 
175d049
02ee0b7
 
175d049
 
 
b1b13d2
02ee0b7
 
 
 
 
 
26c5f33
175d049
b1b13d2
02ee0b7
 
 
 
 
 
 
 
 
26c5f33
175d049
 
b1b13d2
 
02ee0b7
 
 
 
 
7799184
02ee0b7
 
 
da94ae5
e236466
 
02ee0b7
 
 
 
7799184
02ee0b7
7799184
 
 
 
 
02ee0b7
 
 
 
7799184
02ee0b7
7799184
 
 
 
 
 
02ee0b7
7799184
02ee0b7
 
 
da94ae5
26c5f33
02ee0b7
 
 
 
 
 
 
 
 
 
a67abac
02ee0b7
 
 
 
 
 
 
 
 
 
 
27d95c8
175d049
27d95c8
 
 
 
 
 
175d049
 
 
 
b1b13d2
 
02ee0b7
 
 
 
 
 
aba0609
7799184
aba0609
7799184
aba0609
 
72ef929
 
 
 
 
 
 
 
 
 
 
 
 
 
7799184
aba0609
7799184
72ef929
aba0609
7799184
aba0609
02ee0b7
 
5c4c3fd
 
 
 
 
 
 
02ee0b7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
da94ae5
 
 
 
02ee0b7
da94ae5
 
 
 
 
 
 
 
 
 
 
 
 
02ee0b7
da94ae5
02ee0b7
 
 
 
 
 
 
 
 
 
 
 
 
a4bd58d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175d049
 
 
 
 
 
 
a4bd58d
 
 
175d049
a4bd58d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175d049
 
 
a4bd58d
 
 
 
02ee0b7
 
 
 
 
 
 
 
 
 
5c4c3fd
 
e575259
5c4c3fd
 
 
 
02ee0b7
 
 
 
 
 
 
 
 
 
 
52279ac
02ee0b7
 
 
 
 
 
 
 
 
 
 
 
 
 
05f79ec
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
#!/usr/bin/env python3
"""
mbok_dev - Main entry point
Public Space that loads private ver20 app dynamically
"""

import os
import sys
import time
import traceback
import logging

logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
from pathlib import Path
from fastapi import FastAPI, Request, Depends, HTTPException, Form
from fastapi.responses import RedirectResponse, JSONResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
import gradio as gr
from supabase import create_client, Client

# Import bootstrap to download private app
from bootstrap import download_private_app
from login import create_login_ui
from supabase_logger import init_logger, log_event, set_user_context, get_user_context, set_request_source

# --- Startup Meta Info ---
print("=" * 80)
print("🚀 Starting mbok_dev")
print("=" * 80)
print(f"[STARTUP_META] Python version: {sys.version}")
print(f"[STARTUP_META] CWD: {os.getcwd()}")
print(f"[STARTUP_META] PORT: {os.environ.get('PORT', 'not set')}")
print(f"[STARTUP_META] SPACE_ID: {os.environ.get('SPACE_ID', 'not set')}")
print(f"[STARTUP_META] SPACE_HOST: {os.environ.get('SPACE_HOST', 'not set')}")
print(f"[STARTUP_META] GRADIO_SERVER_NAME: {os.environ.get('GRADIO_SERVER_NAME', 'not set')}")
print(f"[STARTUP_META] GRADIO_SERVER_PORT: {os.environ.get('GRADIO_SERVER_PORT', 'not set')}")
print(f"[STARTUP_META] HF_TOKEN: {'***set***' if os.environ.get('HF_TOKEN') else 'NOT SET'}")
print(f"[STARTUP_META] SUPABASE_URL: {'***set***' if os.environ.get('SUPABASE_URL') else 'NOT SET'}")
print(f"[STARTUP_META] SUPABASE_KEY: {'***set***' if os.environ.get('SUPABASE_KEY') else 'NOT SET'}")
print("=" * 80)

# --- Bootstrap: Download private app at startup ---
print("[PHASE] bootstrap_start")
try:
    private_app_dir = download_private_app()
    
    # Add private app to Python path so we can import it
    private_app_path = str(private_app_dir.resolve())
    if private_app_path not in sys.path:
        sys.path.insert(0, private_app_path)
    print(f"[PHASE] bootstrap_end success=true path={private_app_path}")
    
except Exception as e:
    print(f"[PHASE] bootstrap_end success=false")
    print(f"[ERROR] Bootstrap failed: {e}")
    print(f"[TRACEBACK]\n{traceback.format_exc()}")
    print("⚠️  Application will start but /app/ route will not work")
    private_app_dir = None

# --- Supabase Setup ---
print("[PHASE] supabase_init_start")
SUPABASE_URL = os.environ.get("SUPABASE_URL")
SUPABASE_KEY = os.environ.get("SUPABASE_KEY")

if not SUPABASE_URL or not SUPABASE_KEY:
    print("[ERROR] SUPABASE_URL and/or SUPABASE_KEY not set")
    raise ValueError(
        "SUPABASE_URL and SUPABASE_KEY must be set in environment variables. "
        "Please configure them in HF Space Secrets."
    )

try:
    supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY)
    init_logger(supabase)
    print(f"[PHASE] supabase_init_end success=true")
except Exception as e:
    print(f"[PHASE] supabase_init_end success=false")
    print(f"[ERROR] Supabase init failed: {e}")
    print(f"[TRACEBACK]\n{traceback.format_exc()}")
    raise

# --- FastAPI App ---
print("[PHASE] fastapi_init_start")
app = FastAPI()

# /static で静的ファイルを配信(logo.png などのアセット用)
_static_dir = Path(__file__).parent
app.mount("/static", StaticFiles(directory=str(_static_dir)), name="static")

print("[PHASE] fastapi_init_end")

# user_id -> profile dict のキャッシュ(プロセス内で保持、ログイン情報は変わらない前提)
_user_profile_cache: dict = {}

# 最後に解決された source を保持(Gradio WebSocket スレッドは ContextVar が伝播しないため)
_last_known_source: dict = {}

# 内部プロキシホスト(ログ対象外)
_INTERNAL_PROXY_DOMAINS = {
    "proxy.spaces.internal.huggingface.tech",
}

# Gradio バックグラウンド通信のパス(未認証ノイズを抑制)
def _is_gradio_background_path(path: str) -> bool:
    """Gradio が自動送信するバックグラウンドリクエストかどうかを判定する。
    これらは未認証時でも大量に飛んでくるためログ対象外とする。
    """
    return (
        path.startswith("/app/gradio_api/heartbeat/")
        or path == "/app/gradio_api/queue/join"
        or path.startswith("/app/gradio_api/queue/join/")
    )

# --- Request Logging Middleware ---
def _resolve_source(request: Request) -> dict | None:
    """リクエストヘッダから流入元を判定して source_domain を返す。
    内部プロキシホストの場合は None を返してログを除外する。
    判定優先順:
      1. ホストが内部プロキシ → None(ログ除外)
      2. Referer に huggingface.co/spaces/ を含む → source_domain="huggingface.co"
      3. ホストが *.hf.space → source_domain=host
      4. それ以外 → source_domain=host or None
    """
    headers = request.headers
    referer = (headers.get("referer") or "").lower()
    host = (headers.get("x-forwarded-host") or headers.get("host") or "").lower()
    if host in _INTERNAL_PROXY_DOMAINS:
        return None
    if "huggingface.co/spaces/" in referer:
        return {"source_domain": "huggingface.co"}
    if host.endswith(".hf.space"):
        return {"source_domain": host}
    return {"source_domain": host or None}


class RequestLoggingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        start_time = time.time()
        path = request.url.path
        method = request.method

        # Resolve user from cookie and store in contextvars
        user_info = self._resolve_user(request)
        set_user_context(user_info)
        user_tag = f" user={user_info['email']}" if user_info else ""

        # Resolve source (flow-in channel) and store in contextvars
        source_info = _resolve_source(request)
        set_request_source(source_info)
        if source_info is not None:
            _last_known_source.update(source_info)

        # print(f"[REQUEST] method={method} path={path}{user_tag}")
        # log_event("request", f"{method} {path}", metadata={"method": method, "path": path})

        try:
            response = await call_next(request)
            duration = time.time() - start_time
            # print(f"[RESPONSE] method={method} path={path} status={response.status_code} duration={duration:.3f}s{user_tag}")
            if response.status_code >= 400:
                # Gradio バックグラウンド通信の 401 は未認証ノイズなので記録しない
                if not (response.status_code == 401 and _is_gradio_background_path(path)):
                    log_event(
                        "error",
                        "http_response_error",
                        level="WARNING",
                        metadata={"method": method, "path": path, "status": response.status_code, "duration": round(duration, 3)},
                    )
            return response
        except Exception as e:
            duration = time.time() - start_time
            print(f"[RESPONSE] method={method} path={path} status=500 duration={duration:.3f}s error={e}{user_tag}")
            log_event(
                "error",
                "http_response_error",
                level="ERROR",
                metadata={"method": method, "path": path, "status": 500, "duration": round(duration, 3), "error": str(e)},
            )
            raise
        finally:
            set_user_context(None)
            set_request_source(None)

    @staticmethod
    def _resolve_user(request: Request):
        """User resolution from cookie. Full profile (incl. org_id) fetched once and cached per user_id."""
        token = request.cookies.get("sb_access_token")
        if not token:
            return None
        try:
            res = supabase.auth.get_user(token)
            user_id = str(res.user.id)

            # キャッシュヒットならプロフィール取得をスキップ
            if user_id in _user_profile_cache:
                return _user_profile_cache[user_id]

            # 初回のみ profiles から org_id/org_name/role/display_name を全取得
            email = res.user.email
            org_id = None
            org_name = None
            role = None
            display_name = None
            try:
                profile_res = supabase.from_("profiles").select(
                    "email, org_id, role, display_name, organizations(name)"
                ).eq("id", user_id).single().execute()
                d = profile_res.data or {}
                org_id = d.get("org_id")
                org_name = (d.get("organizations") or {}).get("name")
                role = d.get("role")
                display_name = d.get("display_name")
                email = d.get("email") or email
            except Exception as pe:
                print(f"[ORG_CONTEXT] _resolve_user: profile fetch failed: {pe}")
            print(f"[ORG_CONTEXT] _resolve_user: first fetch user_id={user_id} email={email} org_id={org_id!r} org_name={org_name!r}")
            user_info = {
                "user_id": user_id,
                "email": email,
                "display_name": display_name,
                "role": role,
                "org_id": org_id,
                "org_name": org_name,
            }
            _user_profile_cache[user_id] = user_info
            return user_info
        except Exception:
            return None

app.add_middleware(RequestLoggingMiddleware)
print("[MIDDLEWARE] RequestLoggingMiddleware added")

# --- Authentication Handler (for login UI) ---
def handle_login(request: gr.Request, email, password):
    """Handle login attempt via Supabase"""
    print(f"[AUTH] Login attempt for: {email}")
    source = _resolve_source(request)
    log_event("auth", "login_attempt",
              user_override={"email": email},
              source=source)
    try:
        res = supabase.auth.sign_in_with_password({"email": email, "password": password})
        if res.session:
            print(f"[AUTH] Login successful: {email}")
            user_ctx = {"user_id": str(res.user.id), "email": email}
            log_event(
                "auth", "login_success",
                user_override=user_ctx,
                source=source,
            )
            return (
                gr.update(visible=False), 
                gr.update(visible=True, value=f"### ✅ ログイン成功: {email}"),
                res.session.access_token
            )
    except Exception as e:
        print(f"[AUTH] Login failed for {email}: {e}")
        log_event(
            "auth", "login_failure",
            level="WARNING",
            user_override={"email": email},
            metadata={"error": str(e)},
            source=source,
        )
        return gr.update(), gr.update(value=f"❌ エラー: {str(e)}"), None

# --- Authentication Dependency ---
def get_current_user(request: Request):
    """Verify token from cookie and fetch user profile (uses _user_profile_cache)"""
    token = request.cookies.get("sb_access_token")

    if not token:
        print("[AUTH_CHECK] No sb_access_token cookie – unauthenticated access")
        if not _is_gradio_background_path(str(request.url.path)):
            log_event("auth", "unauthenticated_access", level="INFO", metadata={"path": str(request.url.path)})
        return None
    
    try:
        res = supabase.auth.get_user(token)
        user_id = str(res.user.id)

        # キャッシュがあればそれを返す(ミドルウェアが先に取得済みのはず)
        if user_id in _user_profile_cache:
            return _user_profile_cache[user_id]

        # キャッシュ未作成の場合(直接アクセス等)はここで取得してキャッシュする
        profile_res = supabase.from_("profiles").select(
            "email, org_id, role, display_name, organizations(name)"
        ).eq("id", user_id).single().execute()
        
        d = profile_res.data or {}
        user_dict = {
            "user_id": user_id,
            "email": d.get("email"),
            "display_name": d.get("display_name"),
            "role": d.get("role"),
            "org_id": d.get("org_id"),
            "org_name": (d.get("organizations") or {}).get("name"),
        }
        _user_profile_cache[user_id] = user_dict
        return user_dict
        
    except Exception as e:
        print(f"[AUTH_CHECK] Token verify failed: {e}")
        log_event("auth", "token_verify_fail", level="WARNING", metadata={"error": str(e)})
        return None

# --- Create UI instances ---
print("[PHASE] create_ui_start")
login_ui = create_login_ui(handle_login)
print("[PHASE] create_ui_end component=login_ui")

# Import ver20 app (Gradio Blocks)
print("[PHASE] import_ver20_start")
ver20_app = None
VER20_CSS = None
if private_app_dir:
    try:
        # Import app module from downloaded private app
        from app import app as ver20_blocks
        
        # --- Inject Logging Callback ---
        try:
            from lib.logging import set_logger_callback
            
            def bridge_logger(event_type: str, message: str, metadata=None):
                """Ver20からのログイベントをSupabaseに転送"""
                user_override = None
                session_id = None
                clean_metadata = None
                if metadata:
                    clean_metadata = dict(metadata)
                    user_ctx = clean_metadata.pop("_user_context", None)
                    if user_ctx and isinstance(user_ctx, dict):
                        user_override = user_ctx
                    session_id = clean_metadata.pop("session_id", None)
                log_event(event_type, message,
                          metadata=clean_metadata,
                          user_override=user_override,
                          session_id=session_id,
                          source=dict(_last_known_source) if _last_known_source else None)
                
            set_logger_callback(bridge_logger)
            print("[LOGGING] Connected ver20 logging to Supabase")
        except ImportError as e:
            print(f"[LOGGING] Could not import lib.logging or set_logger_callback: {e}")
        # -------------------------------

        # --- Inject Org Context Getter (for HF dataset namespace) ---
        try:
            from lib.hf_storage import set_org_context_getter

            def get_org_for_storage():
                """プロセスレベルの _user_profile_cache から org_id/org_name を返す。

                ContextVar (get_user_context) は FastAPI リクエストスレッドでのみ有効で
                Gradio WebSocket キュースレッドでは伝播しないため、プロセス共有の
                _user_profile_cache(ログイン時にセットされる)を参照する。
                シングルユーザー運用前提; session_org_map が優先されるため
                マルチユーザー時もStep実行後は正しいorgが使われる。
                """
                if _user_profile_cache:
                    last_user = next(iter(_user_profile_cache.values()))
                    org_id = last_user.get("org_id")
                    org_name = last_user.get("org_name")
                    if org_id or org_name:
                        return {"org_id": org_id, "org_name": org_name}
                return None

            set_org_context_getter(get_org_for_storage)
            print("[ORG_CONTEXT] Connected org_context getter to hf_storage (cache-based)")
        except ImportError as e:
            print(f"[ORG_CONTEXT] Could not inject org_context getter: {e}")
        # ---------------------------------------------------------
        
        ver20_app = ver20_blocks
        
        # theme/css を mount_gradio_app に渡すために ver20 の定数を取得
        try:
            from app import CUSTOM_CSS as VER20_CSS
        except ImportError:
            VER20_CSS = None
        
        print(f"[PHASE] import_ver20_end success=true type={type(ver20_app)}")
    except Exception as e:
        print(f"[PHASE] import_ver20_end success=false")
        print(f"[ERROR] Failed to import ver20 app: {e}")
        print(f"[TRACEBACK]\n{traceback.format_exc()}")
else:
    print(f"[PHASE] import_ver20_end success=false reason=bootstrap_failed")

# --- Routes ---
@app.get("/")
async def root(user=Depends(get_current_user)):
    """Root route - redirect to login or app based on auth status"""
    print(f"[ROUTE] / accessed, user_authenticated={isinstance(user, dict) and user.get('user_id')}")
    if isinstance(user, dict) and user.get("user_id"):
        return RedirectResponse(url="/app/")
    return RedirectResponse(url="/login/")

@app.get("/logout")
async def logout(request: Request):
    """Logout route - clear cookie and redirect to login.
    Also serves as force-logout endpoint when session is expired/invalid.
    """
    user = get_user_context()
    token = request.cookies.get("sb_access_token")
    forced = request.query_params.get("forced", "0")
    print(f"[ROUTE] /logout accessed forced={forced}")
    if forced == "1":
        log_event("auth", "force_logout", user_override=user, metadata={"reason": "session_expired"})
    else:
        log_event("auth", "logout", user_override=user)
    # Supabase セッション失効(トークンがある場合)
    if token:
        try:
            supabase.auth.sign_out()
        except Exception:
            pass
    response = RedirectResponse(url="/login/")
    response.delete_cookie("sb_access_token", path="/", samesite="none")
    return response

@app.get("/healthz")
async def healthz():
    """Health check endpoint"""
    status = {
        "ok": True,
        "ver20_loaded": ver20_app is not None,
        "private_app_dir": str(private_app_dir) if private_app_dir else None
    }
    print(f"[HEALTHZ] {status}")
    return JSONResponse(content=status)

_RESET_PASSWORD_HTML = """<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>パスワード再設定</title>
  <style>
    * {{ box-sizing: border-box; margin: 0; padding: 0; }}
    body {{
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
      background: #f5f5f5;
      display: flex;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
    }}
    .card {{
      background: #fff;
      border-radius: 12px;
      box-shadow: 0 2px 16px rgba(0,0,0,0.1);
      padding: 40px;
      width: 100%;
      max-width: 400px;
    }}
    h1 {{ font-size: 1.4rem; margin-bottom: 24px; color: #333; }}
    label {{ display: block; font-size: 0.85rem; color: #555; margin-bottom: 6px; }}
    input[type=password] {{
      width: 100%;
      padding: 10px 14px;
      border: 1px solid #ddd;
      border-radius: 8px;
      font-size: 1rem;
      margin-bottom: 16px;
      outline: none;
      transition: border 0.2s;
    }}
    input[type=password]:focus {{ border-color: #f97316; }}
    button {{
      width: 100%;
      padding: 12px;
      background: #f97316;
      color: #fff;
      border: none;
      border-radius: 8px;
      font-size: 1rem;
      cursor: pointer;
      transition: background 0.2s;
    }}
    button:hover {{ background: #ea6c0a; }}
    .msg {{ margin-top: 16px; font-size: 0.9rem; color: #e53e3e; text-align: center; }}
    .msg.success {{ color: #38a169; }}
  </style>
</head>
<body>
  <div class="card">
    <h1>🔑 パスワード再設定</h1>
    <form method="post" action="/reset-password" id="resetForm">
      <input type="hidden" name="access_token" id="access_token">
      <input type="hidden" name="refresh_token" id="refresh_token">
      <label for="new_password">新しいパスワード</label>
      <input type="password" name="new_password" id="new_password" placeholder="8文字以上" required minlength="8">
      <label for="confirm_password">確認(再入力)</label>
      <input type="password" id="confirm_password" placeholder="同じパスワードを入力" required minlength="8">
      <button type="submit">パスワードを変更する</button>
    </form>
    <div class="msg" id="msg">{message}</div>
  </div>
  <script>
    // URLフラグメントからSupabaseのトークンを取得してhidden inputにセット
    (function() {{
      var hash = window.location.hash.substring(1);
      var params = {{}};
      hash.split('&').forEach(function(part) {{
        var kv = part.split('=');
        if (kv.length === 2) params[decodeURIComponent(kv[0])] = decodeURIComponent(kv[1]);
      }});
      if (params.access_token) {{
        document.getElementById('access_token').value = params.access_token;
      }}
      if (params.refresh_token) {{
        document.getElementById('refresh_token').value = params.refresh_token;
      }}
      if (!params.access_token && !params.refresh_token) {{
        document.getElementById('msg').textContent = '⚠️ 無効なリンクです。パスワードリセットメールを再送してください。';
      }}
    }})();

    // パスワード一致チェック
    document.getElementById('resetForm').addEventListener('submit', function(e) {{
      var pw = document.getElementById('new_password').value;
      var cpw = document.getElementById('confirm_password').value;
      if (pw !== cpw) {{
        e.preventDefault();
        document.getElementById('msg').textContent = '❌ パスワードが一致しません。';
      }}
    }});
  </script>
</body>
</html>"""

@app.get("/reset-password")
async def reset_password_page():
    """パスワード再設定フォームを表示"""
    return HTMLResponse(_RESET_PASSWORD_HTML.format(message=""))


@app.post("/reset-password")
async def reset_password_submit(
    access_token: str = Form(default=""),
    refresh_token: str = Form(default=""),
    new_password: str = Form(...),
):
    """パスワード再設定を実行"""
    if not access_token:
        html = _RESET_PASSWORD_HTML.format(message="❌ トークンが取得できませんでした。メールのリンクを再度クリックしてください。")
        return HTMLResponse(html, status_code=400)

    if len(new_password) < 8:
        html = _RESET_PASSWORD_HTML.format(message="❌ パスワードは8文字以上で設定してください。")
        return HTMLResponse(html, status_code=400)

    reset_user_ctx = None
    try:
        _res = supabase.auth.get_user(access_token)
        reset_user_ctx = {"user_id": str(_res.user.id), "email": _res.user.email}
    except Exception:
        pass

    try:
        supabase.auth.set_session(access_token, refresh_token)
        supabase.auth.update_user({"password": new_password})
        log_event("auth", "password_reset_success", user_override=reset_user_ctx)
        success_html = """<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="refresh" content="3;url=/login/">
  <title>パスワード変更完了</title>
  <style>
    body {{ font-family: -apple-system, sans-serif; display: flex; align-items: center;
           justify-content: center; min-height: 100vh; background: #f5f5f5; }}
    .card {{ background: #fff; border-radius: 12px; padding: 40px; text-align: center;
             box-shadow: 0 2px 16px rgba(0,0,0,0.1); max-width: 360px; width: 100%; }}
    h1 {{ color: #38a169; margin-bottom: 12px; }}
    p {{ color: #555; font-size: 0.95rem; }}
  </style>
</head>
<body>
  <div class="card">
    <h1>✅ パスワードを変更しました</h1>
    <p>3秒後にログイン画面に移動します...</p>
    <p><a href="/login/">今すぐログイン画面へ</a></p>
  </div>
</body>
</html>"""
        return HTMLResponse(success_html)
    except Exception as e:
        print(f"[AUTH] Password reset failed: {e}")
        log_event("auth", "password_reset_failure",
                  level="WARNING", user_override=reset_user_ctx,
                  metadata={"error": str(e)})
        html = _RESET_PASSWORD_HTML.format(message=f"❌ エラーが発生しました: {str(e)}")
        return HTMLResponse(html, status_code=400)


print("[ROUTES] Root, logout, and healthz routes registered")

# --- Mount Gradio UIs ---
print("[PHASE] mount_login_start")
app = gr.mount_gradio_app(app, login_ui, path="/login")
print("[PHASE] mount_login_end path=/login")

# Ver20 App (protected)
print("[PHASE] mount_app_start")
if ver20_app:
    app = gr.mount_gradio_app(
        app, ver20_app, path="/app",
        root_path="/app",
        auth_dependency=get_current_user,
        theme=gr.themes.Citrus(),
        css=VER20_CSS,
    )
    print("[PHASE] mount_app_end path=/app protected=true ver20=true")
else:
    # Fallback: simple placeholder if ver20 failed to load
    with gr.Blocks() as fallback_ui:
        gr.Markdown("# ⚠️ Application Not Available")
        gr.Markdown("The private application failed to load. Please check logs.")
    
    app = gr.mount_gradio_app(app, fallback_ui, path="/app", auth_dependency=get_current_user)
    print("[PHASE] mount_app_end path=/app protected=true ver20=false fallback=true")

print("=" * 80)
print("🎉 mbok_dev Ready!")
print("=" * 80)
print(f"[STARTUP_COMPLETE] All phases completed successfully")
print(f"[STARTUP_COMPLETE] Access URLs:")
print(f"  - Root: /")
print(f"  - Login: /login/")
print(f"  - App: /app/")
print(f"  - Health: /healthz")
print(f"  - Logout: /logout")
print("=" * 80)

if __name__ == "__main__":
    import uvicorn
    port = int(os.environ.get("PORT", 7860))
    print(f"[ENTRYPOINT] Starting uvicorn on 0.0.0.0:{port}")
    uvicorn.run(app, host="0.0.0.0", port=port, log_level="warning")