File size: 5,300 Bytes
5d4959a
76b596f
 
5d4959a
 
 
 
 
 
 
 
7d74b4c
 
2f83d3e
76b596f
40f139d
76b596f
7d74b4c
 
40f139d
 
76b596f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69c9e26
 
 
 
 
 
 
 
76b596f
 
 
 
69c9e26
76b596f
 
69c9e26
76b596f
 
 
 
 
 
 
fe83ebb
76b596f
fe83ebb
76b596f
 
 
fe83ebb
 
 
 
 
 
 
69c9e26
 
76b596f
 
 
 
69c9e26
76b596f
 
 
 
 
 
 
69c9e26
 
76b596f
 
 
 
 
 
 
 
 
fe83ebb
 
69c9e26
76b596f
 
69c9e26
76b596f
 
 
 
 
fe83ebb
 
69c9e26
 
76b596f
5be8e7e
76b596f
69c9e26
76b596f
5d4959a
 
 
 
 
 
 
 
69c9e26
5d4959a
69c9e26
 
 
 
 
7d74b4c
69c9e26
 
 
7d74b4c
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
import sys
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent / "src"))

from bootstrap_secrets import ensure_secrets_file

ensure_secrets_file()

import streamlit as st

import cookie_token_store
import google_oauth
from analytics import inject_umami

st.set_page_config(page_title="Board Game Tracker", layout="wide")

inject_umami()


def _drive_redirect_uri() -> str:
    return st.secrets["auth"]["redirect_uri"].removesuffix("/oauth2callback")


def _store_access_token(tokens: dict) -> None:
    st.session_state["google_access_token"] = tokens["access_token"]
    expires_in = tokens.get("expires_in", 3600)
    st.session_state["google_access_token_expires_at"] = datetime.now(
        timezone.utc
    ) + timedelta(seconds=expires_in - 60)


def _access_token_valid() -> bool:
    expires_at = st.session_state.get("google_access_token_expires_at")
    return (
        bool(st.session_state.get("google_access_token"))
        and expires_at is not None
        and datetime.now(timezone.utc) < expires_at
    )


def _handle_drive_oauth_callback() -> None:
    """Completes the one-time "Connect Google Drive" exchange, if we're mid-flow.

    st.link_button always opens the authorize URL in a new tab, so this runs
    in a session that never had st.session_state populated by the tab that
    started the flow -- the CSRF state has to be compared against a cookie
    (shared across tabs), not session_state (tab-scoped).
    """
    if not st.user.is_logged_in:
        return
    code = st.query_params.get("code")
    state = st.query_params.get("state")
    if not code or not state or state != cookie_token_store.get_oauth_state():
        return

    cookie_token_store.clear_oauth_state()
    try:
        tokens = google_oauth.exchange_code(
            st.secrets["auth"]["client_id"],
            st.secrets["auth"]["client_secret"],
            _drive_redirect_uri(),
            code,
        )
    except Exception as e:
        st.query_params.clear()
        st.error(f"Failed to connect Google Drive: {e}")
        return

    refresh_token = tokens.get("refresh_token")
    if not refresh_token:
        st.warning(
            "Google didn't return a refresh_token (this happens if the app was "
            "already authorized without prompt=consent taking effect). "
            "Drive access will work for this session only -- try disconnecting "
            "the app at myaccount.google.com/permissions and reconnecting."
        )
    elif "cookies" in st.secrets:
        cookie_token_store.save_refresh_token(st.user.email, refresh_token)
    _store_access_token(tokens)
    st.query_params.clear()


def _ensure_drive_access() -> None:
    """Populates session_state's Drive access token, silently refreshing from a
    stored refresh_token when possible. Shows a one-time "Connect Google Drive"
    link only when neither a live token nor a stored refresh_token exists.
    """
    if not st.user.is_logged_in or _access_token_valid():
        return

    if "cookies" in st.secrets:
        refresh_token = cookie_token_store.get_refresh_token(st.user.email)
        if refresh_token:
            try:
                tokens = google_oauth.refresh_access_token(
                    st.secrets["auth"]["client_id"],
                    st.secrets["auth"]["client_secret"],
                    refresh_token,
                )
                _store_access_token(tokens)
                return
            except Exception as e:
                st.warning(f"Stored Drive access expired or was revoked ({e}); reconnecting.")
                cookie_token_store.delete_refresh_token(st.user.email)

    state = uuid.uuid4().hex
    cookie_token_store.save_oauth_state(state)
    authorize_url = google_oauth.build_authorize_url(
        st.secrets["auth"]["client_id"], _drive_redirect_uri(), state
    )
    st.link_button("Connect Google Drive", authorize_url)

    with st.expander("Drive connection diagnostics"):
        st.write("Logged in as:", st.user.email)
        st.write("Cookie storage configured:", "cookies" in st.secrets)
        st.write("Raw cookies seen by the app:", list(st.context.cookies.keys()))


if "auth" in st.secrets:
    _handle_drive_oauth_callback()

with st.sidebar:
    if "auth" not in st.secrets:
        pass  # no auth configured in this environment; app stays anonymous-only
    elif not st.user.is_logged_in:
        st.button("Log in with Google", on_click=st.login)
    else:
        st.caption(f"Logged in as {st.user.email}")
        st.button("Log out", on_click=st.logout)
        _ensure_drive_access()

home_page = st.Page("app_pages/home.py", title="Home", url_path="home", default=True)
skull_king_page = st.Page("app_pages/skull_king.py", title="Skull King", url_path="skull_king")
dungeon_draft_page = st.Page("app_pages/dungeon_draft.py", title="Dungeon Draft", url_path="dungeon_draft")
points_tracker_page = st.Page("app_pages/points_tracker.py", title="Points Tracker", url_path="points_tracker")
game_detail_page = st.Page("app_pages/game_detail.py", title="Game Detail", url_path="game_detail")

pg = st.navigation(
    [home_page, skull_king_page, dungeon_draft_page, points_tracker_page, game_detail_page]
)
pg.run()