Spaces:
Sleeping
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 +91 -0
- pages/points_tracker.py +68 -9
- requirements.txt +3 -0
- src/bootstrap_secrets.py +4 -7
- src/google_oauth.py +64 -0
- src/sheets_backend.py +7 -0
- src/token_store.py +59 -0
|
@@ -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")
|
|
@@ -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.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
st.subheader("Game Setup")
|
| 41 |
|
| 42 |
-
|
| 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 = []
|
|
@@ -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
|
|
@@ -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 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 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))
|
|
@@ -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()
|
|
@@ -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()
|
|
@@ -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,))
|