RandomCatLover Claude Sonnet 5 commited on
Commit
76b596f
·
1 Parent(s): 62e957f

Add saved-players/game-history tabs and persistent Drive access via Supabase

Browse files

- Points Tracker setup now has New Game / Saved Players / Game History
tabs. Game name is picked from history (with a + New game name
fallback) instead of always free-typed; Saved Players and Game
History are read-only browsers backed by sheets_backend.list_players
and the new list_game_names helper.

- st.login() alone can never give us a refresh_token -- Streamlit's
own _auth_callback discards anything but id_token/access_token
before user code runs. Added a separate, one-time Connect Google
Drive flow (src/google_oauth.py) using access_type=offline plus
prompt=consent to force Google to issue one, and store it Fernet-
encrypted in a Supabase Postgres table (src/token_store.py). On
every subsequent login, app.py silently redeems a fresh access
token from the stored refresh_token -- no repeat consent screen.
Falls back to showing the Connect Drive button only when no valid
session token and no usable stored refresh_token exist, and clears
a revoked/invalid stored token instead of looping.

- Simplified [auth] secrets back to identity-only defaults now that
Drive scope/consent lives entirely in the new flow; added a
[supabase] secrets section (db_url, encryption_key).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

app.py CHANGED
@@ -1,4 +1,6 @@
1
  import sys
 
 
2
  from pathlib import Path
3
 
4
  sys.path.insert(0, str(Path(__file__).parent / "src"))
@@ -9,8 +11,96 @@ ensure_secrets_file()
9
 
10
  import streamlit as st
11
 
 
 
 
12
  st.set_page_config(page_title="Board Game Tracker", layout="wide")
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  with st.sidebar:
15
  if "auth" not in st.secrets:
16
  pass # no auth configured in this environment; app stays anonymous-only
@@ -19,6 +109,7 @@ with st.sidebar:
19
  else:
20
  st.caption(f"Logged in as {st.user.email}")
21
  st.button("Log out", on_click=st.logout)
 
22
 
23
  home_page = st.Page("pages/home.py", title="Home", url_path="home", default=True)
24
  skull_king_page = st.Page("pages/skull_king.py", title="Skull King", url_path="skull_king")
 
1
  import sys
2
+ import uuid
3
+ from datetime import datetime, timedelta, timezone
4
  from pathlib import Path
5
 
6
  sys.path.insert(0, str(Path(__file__).parent / "src"))
 
11
 
12
  import streamlit as st
13
 
14
+ import google_oauth
15
+ import token_store
16
+
17
  st.set_page_config(page_title="Board Game Tracker", layout="wide")
18
 
19
+
20
+ def _drive_redirect_uri() -> str:
21
+ return st.secrets["auth"]["redirect_uri"].removesuffix("/oauth2callback")
22
+
23
+
24
+ def _store_access_token(tokens: dict) -> None:
25
+ st.session_state["google_access_token"] = tokens["access_token"]
26
+ expires_in = tokens.get("expires_in", 3600)
27
+ st.session_state["google_access_token_expires_at"] = datetime.now(
28
+ timezone.utc
29
+ ) + timedelta(seconds=expires_in - 60)
30
+
31
+
32
+ def _access_token_valid() -> bool:
33
+ expires_at = st.session_state.get("google_access_token_expires_at")
34
+ return (
35
+ bool(st.session_state.get("google_access_token"))
36
+ and expires_at is not None
37
+ and datetime.now(timezone.utc) < expires_at
38
+ )
39
+
40
+
41
+ def _handle_drive_oauth_callback() -> None:
42
+ """Completes the one-time "Connect Google Drive" exchange, if we're mid-flow."""
43
+ if not st.user.is_logged_in:
44
+ return
45
+ code = st.query_params.get("code")
46
+ state = st.query_params.get("state")
47
+ if not code or not state or state != st.session_state.get("drive_oauth_state"):
48
+ return
49
+
50
+ st.session_state.pop("drive_oauth_state", None)
51
+ try:
52
+ tokens = google_oauth.exchange_code(
53
+ st.secrets["auth"]["client_id"],
54
+ st.secrets["auth"]["client_secret"],
55
+ _drive_redirect_uri(),
56
+ code,
57
+ )
58
+ except Exception:
59
+ st.query_params.clear()
60
+ st.error("Failed to connect Google Drive. Please try again.")
61
+ return
62
+
63
+ refresh_token = tokens.get("refresh_token")
64
+ if refresh_token and "supabase" in st.secrets:
65
+ token_store.save_refresh_token(st.user.email, refresh_token)
66
+ _store_access_token(tokens)
67
+ st.query_params.clear()
68
+ st.rerun()
69
+
70
+
71
+ def _ensure_drive_access() -> None:
72
+ """Populates session_state's Drive access token, silently refreshing from a
73
+ stored refresh_token when possible. Shows a one-time "Connect Google Drive"
74
+ link only when neither a live token nor a stored refresh_token exists.
75
+ """
76
+ if not st.user.is_logged_in or _access_token_valid():
77
+ return
78
+
79
+ if "supabase" in st.secrets:
80
+ refresh_token = token_store.get_refresh_token(st.user.email)
81
+ if refresh_token:
82
+ try:
83
+ tokens = google_oauth.refresh_access_token(
84
+ st.secrets["auth"]["client_id"],
85
+ st.secrets["auth"]["client_secret"],
86
+ refresh_token,
87
+ )
88
+ _store_access_token(tokens)
89
+ return
90
+ except Exception:
91
+ token_store.delete_refresh_token(st.user.email)
92
+
93
+ state = uuid.uuid4().hex
94
+ st.session_state["drive_oauth_state"] = state
95
+ authorize_url = google_oauth.build_authorize_url(
96
+ st.secrets["auth"]["client_id"], _drive_redirect_uri(), state
97
+ )
98
+ st.link_button("Connect Google Drive", authorize_url)
99
+
100
+
101
+ if "auth" in st.secrets:
102
+ _handle_drive_oauth_callback()
103
+
104
  with st.sidebar:
105
  if "auth" not in st.secrets:
106
  pass # no auth configured in this environment; app stays anonymous-only
 
109
  else:
110
  st.caption(f"Logged in as {st.user.email}")
111
  st.button("Log out", on_click=st.logout)
112
+ _ensure_drive_access()
113
 
114
  home_page = st.Page("pages/home.py", title="Home", url_path="home", default=True)
115
  skull_king_page = st.Page("pages/skull_king.py", title="Skull King", url_path="skull_king")
pages/points_tracker.py CHANGED
@@ -26,7 +26,7 @@ def _get_workbook():
26
  """Returns the logged-in user's Sheets workbook, or None for anonymous sessions."""
27
  if "auth" not in st.secrets or not st.user.is_logged_in:
28
  return None
29
- access_token = st.user.tokens.get("access")
30
  if not access_token:
31
  return None
32
  if "pt_workbook" not in st.session_state:
@@ -36,14 +36,37 @@ def _get_workbook():
36
  return st.session_state.pt_workbook
37
 
38
 
39
- def _setup_phase():
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  st.subheader("Game Setup")
41
 
42
- st.session_state.pt_game_name = st.text_input(
43
- "Game name (optional)",
44
- value=st.session_state.pt_game_name,
45
- placeholder="e.g. Catan, Ticket to Ride…",
46
- )
47
 
48
  st.session_state.pt_winning_score = st.number_input(
49
  "Winning score (points to win)",
@@ -58,7 +81,6 @@ def _setup_phase():
58
  name = st.session_state.pt_new_player.strip()
59
  if name and name not in st.session_state.pt_players:
60
  st.session_state.pt_players.append(name)
61
- wb = _get_workbook()
62
  if wb is not None:
63
  sheets.add_player(wb, name)
64
  st.session_state.pt_new_player = ""
@@ -71,7 +93,6 @@ def _setup_phase():
71
  st.write("")
72
  st.button("Add Player", key="pt_add_btn", on_click=_add_player)
73
 
74
- wb = _get_workbook()
75
  if wb is not None:
76
  saved_names = [p["name"] for p in sheets.list_players(wb) if p.get("name")]
77
  remaining = [n for n in saved_names if n not in st.session_state.pt_players]
@@ -102,6 +123,44 @@ def _setup_phase():
102
  st.info("Add at least 2 players to start.")
103
 
104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  def _compute_cumulative(players, scores):
106
  cumsum = {p: 0 for p in players}
107
  history = []
 
26
  """Returns the logged-in user's Sheets workbook, or None for anonymous sessions."""
27
  if "auth" not in st.secrets or not st.user.is_logged_in:
28
  return None
29
+ access_token = st.session_state.get("google_access_token")
30
  if not access_token:
31
  return None
32
  if "pt_workbook" not in st.session_state:
 
36
  return st.session_state.pt_workbook
37
 
38
 
39
+ _NEW_GAME_NAME_SENTINEL = "+ New game name"
40
+
41
+
42
+ def _game_name_picker(wb):
43
+ past_names = sheets.list_game_names(wb) if wb is not None else []
44
+ if not past_names:
45
+ st.session_state.pt_game_name = st.text_input(
46
+ "Game name (optional)",
47
+ value=st.session_state.pt_game_name,
48
+ placeholder="e.g. Catan, Ticket to Ride…",
49
+ )
50
+ return
51
+
52
+ options = past_names + [_NEW_GAME_NAME_SENTINEL]
53
+ current = st.session_state.pt_game_name
54
+ default_index = options.index(current) if current in options else len(options) - 1
55
+ choice = st.selectbox("Game name (optional)", options, index=default_index)
56
+ if choice == _NEW_GAME_NAME_SENTINEL:
57
+ st.session_state.pt_game_name = st.text_input(
58
+ "New game name",
59
+ value="" if current in past_names else current,
60
+ placeholder="e.g. Catan, Ticket to Ride…",
61
+ )
62
+ else:
63
+ st.session_state.pt_game_name = choice
64
+
65
+
66
+ def _render_new_game_tab(wb):
67
  st.subheader("Game Setup")
68
 
69
+ _game_name_picker(wb)
 
 
 
 
70
 
71
  st.session_state.pt_winning_score = st.number_input(
72
  "Winning score (points to win)",
 
81
  name = st.session_state.pt_new_player.strip()
82
  if name and name not in st.session_state.pt_players:
83
  st.session_state.pt_players.append(name)
 
84
  if wb is not None:
85
  sheets.add_player(wb, name)
86
  st.session_state.pt_new_player = ""
 
93
  st.write("")
94
  st.button("Add Player", key="pt_add_btn", on_click=_add_player)
95
 
 
96
  if wb is not None:
97
  saved_names = [p["name"] for p in sheets.list_players(wb) if p.get("name")]
98
  remaining = [n for n in saved_names if n not in st.session_state.pt_players]
 
123
  st.info("Add at least 2 players to start.")
124
 
125
 
126
+ def _render_saved_players_tab(wb):
127
+ st.subheader("Saved Players")
128
+ if wb is None:
129
+ st.info("Log in with Google to see your saved players.")
130
+ return
131
+ players = sheets.list_players(wb)
132
+ if not players:
133
+ st.caption("No saved players yet -- add one in the New Game tab.")
134
+ return
135
+ df = pd.DataFrame(players)[["name", "created_at"]]
136
+ st.dataframe(df, hide_index=True, width="stretch")
137
+
138
+
139
+ def _render_game_history_tab(wb):
140
+ st.subheader("Game History")
141
+ if wb is None:
142
+ st.info("Log in with Google to see your game history.")
143
+ return
144
+ names = sheets.list_game_names(wb)
145
+ if not names:
146
+ st.caption("No games recorded yet.")
147
+ return
148
+ for name in names:
149
+ st.write(f"- {name}")
150
+
151
+
152
+ def _setup_phase():
153
+ wb = _get_workbook()
154
+ tab_new, tab_players, tab_history = st.tabs(["New Game", "Saved Players", "Game History"])
155
+
156
+ with tab_new:
157
+ _render_new_game_tab(wb)
158
+ with tab_players:
159
+ _render_saved_players_tab(wb)
160
+ with tab_history:
161
+ _render_game_history_tab(wb)
162
+
163
+
164
  def _compute_cumulative(players, scores):
165
  cumsum = {p: 0 for p in players}
166
  history = []
requirements.txt CHANGED
@@ -6,3 +6,6 @@ google-auth
6
  Authlib
7
  httpx
8
  tomli_w
 
 
 
 
6
  Authlib
7
  httpx
8
  tomli_w
9
+ psycopg2-binary
10
+ cryptography
11
+ requests
src/bootstrap_secrets.py CHANGED
@@ -5,8 +5,6 @@ import tomli_w
5
 
6
  _SECRETS_PATH = Path(".streamlit/secrets.toml")
7
 
8
- _DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file"
9
-
10
 
11
  def ensure_secrets_file() -> None:
12
  if _SECRETS_PATH.exists() or "AUTH_CLIENT_ID" not in os.environ:
@@ -20,11 +18,10 @@ def ensure_secrets_file() -> None:
20
  "client_id": os.environ["AUTH_CLIENT_ID"],
21
  "client_secret": os.environ["AUTH_CLIENT_SECRET"],
22
  "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
23
- "expose_tokens": ["access"],
24
- "client_kwargs": {
25
- "scope": f"openid email profile {_DRIVE_FILE_SCOPE}",
26
- "prompt": "consent select_account",
27
- },
28
  },
29
  }
30
  _SECRETS_PATH.write_text(tomli_w.dumps(data))
 
5
 
6
  _SECRETS_PATH = Path(".streamlit/secrets.toml")
7
 
 
 
8
 
9
  def ensure_secrets_file() -> None:
10
  if _SECRETS_PATH.exists() or "AUTH_CLIENT_ID" not in os.environ:
 
18
  "client_id": os.environ["AUTH_CLIENT_ID"],
19
  "client_secret": os.environ["AUTH_CLIENT_SECRET"],
20
  "server_metadata_url": "https://accounts.google.com/.well-known/openid-configuration",
21
+ },
22
+ "supabase": {
23
+ "db_url": os.environ["SUPABASE_DB_URL"],
24
+ "encryption_key": os.environ["SUPABASE_ENCRYPTION_KEY"],
 
25
  },
26
  }
27
  _SECRETS_PATH.write_text(tomli_w.dumps(data))
src/google_oauth.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from urllib.parse import urlencode
2
+
3
+ import requests
4
+
5
+ _AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"
6
+ _TOKEN_URL = "https://oauth2.googleapis.com/token"
7
+ _DRIVE_FILE_SCOPE = "https://www.googleapis.com/auth/drive.file"
8
+
9
+
10
+ def build_authorize_url(client_id: str, redirect_uri: str, state: str) -> str:
11
+ """Builds the Google consent-screen URL for the one-time Drive connection.
12
+
13
+ access_type=offline + prompt=consent force Google to include a
14
+ refresh_token in the token response, even for a client that has been
15
+ authorized before.
16
+ """
17
+ params = {
18
+ "client_id": client_id,
19
+ "redirect_uri": redirect_uri,
20
+ "response_type": "code",
21
+ "scope": _DRIVE_FILE_SCOPE,
22
+ "access_type": "offline",
23
+ "prompt": "consent",
24
+ "state": state,
25
+ }
26
+ return f"{_AUTHORIZE_URL}?{urlencode(params)}"
27
+
28
+
29
+ def exchange_code(client_id: str, client_secret: str, redirect_uri: str, code: str) -> dict:
30
+ """Exchanges an authorization code for tokens, including a refresh_token."""
31
+ response = requests.post(
32
+ _TOKEN_URL,
33
+ data={
34
+ "client_id": client_id,
35
+ "client_secret": client_secret,
36
+ "redirect_uri": redirect_uri,
37
+ "code": code,
38
+ "grant_type": "authorization_code",
39
+ },
40
+ timeout=10,
41
+ )
42
+ response.raise_for_status()
43
+ return response.json()
44
+
45
+
46
+ def refresh_access_token(client_id: str, client_secret: str, refresh_token: str) -> dict:
47
+ """Mints a fresh access_token from a stored refresh_token, no browser needed.
48
+
49
+ Raises requests.HTTPError (e.g. invalid_grant) if the refresh_token has
50
+ been revoked -- callers should delete the stored token and prompt the
51
+ user to reconnect Drive.
52
+ """
53
+ response = requests.post(
54
+ _TOKEN_URL,
55
+ data={
56
+ "client_id": client_id,
57
+ "client_secret": client_secret,
58
+ "refresh_token": refresh_token,
59
+ "grant_type": "refresh_token",
60
+ },
61
+ timeout=10,
62
+ )
63
+ response.raise_for_status()
64
+ return response.json()
src/sheets_backend.py CHANGED
@@ -41,6 +41,13 @@ def list_players(wb: gspread.Spreadsheet) -> list[dict]:
41
  return ws.get_all_records()
42
 
43
 
 
 
 
 
 
 
 
44
  def add_player(wb: gspread.Spreadsheet, name: str) -> str:
45
  ws = wb.worksheet("players")
46
  existing = ws.get_all_records()
 
41
  return ws.get_all_records()
42
 
43
 
44
+ def list_game_names(wb: gspread.Spreadsheet) -> list[str]:
45
+ """Distinct game names played, most-recently-used first."""
46
+ ws = wb.worksheet("games_played")
47
+ names = [row["game_name"] for row in ws.get_all_records() if row.get("game_name")]
48
+ return list(dict.fromkeys(reversed(names)))
49
+
50
+
51
  def add_player(wb: gspread.Spreadsheet, name: str) -> str:
52
  ws = wb.worksheet("players")
53
  existing = ws.get_all_records()
src/token_store.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import psycopg2
2
+ import streamlit as st
3
+ from cryptography.fernet import Fernet
4
+
5
+ _TABLE_DDL = """
6
+ CREATE TABLE IF NOT EXISTS user_refresh_tokens (
7
+ email TEXT PRIMARY KEY,
8
+ encrypted_refresh_token TEXT NOT NULL,
9
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
10
+ )
11
+ """
12
+
13
+
14
+ @st.cache_resource
15
+ def _get_conn():
16
+ conn = psycopg2.connect(st.secrets["supabase"]["db_url"])
17
+ conn.autocommit = True
18
+ with conn.cursor() as cur:
19
+ cur.execute(_TABLE_DDL)
20
+ return conn
21
+
22
+
23
+ def _fernet() -> Fernet:
24
+ return Fernet(st.secrets["supabase"]["encryption_key"])
25
+
26
+
27
+ def save_refresh_token(email: str, refresh_token: str) -> None:
28
+ encrypted = _fernet().encrypt(refresh_token.encode()).decode()
29
+ conn = _get_conn()
30
+ with conn.cursor() as cur:
31
+ cur.execute(
32
+ """
33
+ INSERT INTO user_refresh_tokens (email, encrypted_refresh_token, updated_at)
34
+ VALUES (%s, %s, now())
35
+ ON CONFLICT (email) DO UPDATE
36
+ SET encrypted_refresh_token = EXCLUDED.encrypted_refresh_token,
37
+ updated_at = now()
38
+ """,
39
+ (email, encrypted),
40
+ )
41
+
42
+
43
+ def get_refresh_token(email: str) -> str | None:
44
+ conn = _get_conn()
45
+ with conn.cursor() as cur:
46
+ cur.execute(
47
+ "SELECT encrypted_refresh_token FROM user_refresh_tokens WHERE email = %s",
48
+ (email,),
49
+ )
50
+ row = cur.fetchone()
51
+ if row is None:
52
+ return None
53
+ return _fernet().decrypt(row[0].encode()).decode()
54
+
55
+
56
+ def delete_refresh_token(email: str) -> None:
57
+ conn = _get_conn()
58
+ with conn.cursor() as cur:
59
+ cur.execute("DELETE FROM user_refresh_tokens WHERE email = %s", (email,))