Spaces:
Sleeping
Sleeping
Commit Β·
1ef8c5a
1
Parent(s): b62f15b
feat: Supabase login gate + persistent resume + generated LaTeX storage
Browse files- Login page (email/password via Supabase Auth) gates the entire app
- Resume PDF backed up to Supabase Storage on upload; restored on restart
- Generated LaTeX saved to generated_resumes table after each run
- Logout button in header clears session
- src/supabase_client.py: lazy singleton clients + get_owner_user_id()
- supabase>=2.3.0 added to requirements.txt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- HISTORY.md +31 -0
- api_server.py +15 -0
- requirements.txt +3 -0
- src/supabase_client.py +57 -0
- ui.py +88 -0
HISTORY.md
CHANGED
|
@@ -4,6 +4,37 @@ A running log of everything built, fixed, and changed. Most recent first.
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
## 2026-06-23 (PM7) β LaTeX resume upload support
|
| 8 |
|
| 9 |
Resume upload page now accepts `.tex` files in addition to PDF. When a `.tex` file is
|
|
|
|
| 4 |
|
| 5 |
---
|
| 6 |
|
| 7 |
+
## 2026-06-23 (PM8) β Supabase integration: login gate + persistent storage
|
| 8 |
+
|
| 9 |
+
Added Supabase (PostgreSQL + Auth + Storage) as the persistent backend.
|
| 10 |
+
HF Spaces has an ephemeral filesystem β every restart wiped job history,
|
| 11 |
+
candidate vault, uploaded resumes, and generated LaTeX files. Supabase fixes all of this.
|
| 12 |
+
|
| 13 |
+
**New file: `src/supabase_client.py`**
|
| 14 |
+
Lazy singleton clients (`get_anon_client`, `get_service_client`) and a
|
| 15 |
+
`get_owner_user_id()` helper that uses the service_role admin API to look up the
|
| 16 |
+
single owner user's UUID (cached after first call).
|
| 17 |
+
|
| 18 |
+
**Login gate (`ui.py`)**
|
| 19 |
+
The entire app is now behind email/password auth via Supabase Auth. A centered
|
| 20 |
+
sign-in form renders if `st.session_state["logged_in"]` is `False` and calls
|
| 21 |
+
`supabase.auth.sign_in_with_password()`. On success, `user_id` and `user_email`
|
| 22 |
+
are stored in session state. A sign-out button appears in the header.
|
| 23 |
+
|
| 24 |
+
**Resume PDF persistence (`ui.py`)**
|
| 25 |
+
- On upload: saved to Supabase Storage bucket `resumes/{user_id}/resume.pdf`
|
| 26 |
+
(upsert so re-uploads overwrite).
|
| 27 |
+
- On startup: if `data/resume/resume.pdf` is missing (post-restart), it is
|
| 28 |
+
automatically restored from Supabase Storage before the app renders.
|
| 29 |
+
|
| 30 |
+
**Generated resume persistence (`api_server.py`)**
|
| 31 |
+
After each successful LaTeX generation, the `.tex` source, job title, company,
|
| 32 |
+
and ATS score are inserted into the `generated_resumes` Supabase table. Non-fatal.
|
| 33 |
+
|
| 34 |
+
**`requirements.txt`**: added `supabase>=2.3.0`.
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
## 2026-06-23 (PM7) β LaTeX resume upload support
|
| 39 |
|
| 40 |
Resume upload page now accepts `.tex` files in addition to PDF. When a `.tex` file is
|
api_server.py
CHANGED
|
@@ -310,6 +310,21 @@ async def _generate_from_latex(
|
|
| 310 |
print(f"[api/generate:latex] max_ats={max_ats} status={status} "
|
| 311 |
f"external_cov={pct}% compiled={payload['latex_compiled']} "
|
| 312 |
f"engine={payload['latex_engine']} injected={len(payload['injected_terms'])}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
return JSONResponse(payload)
|
| 314 |
except Exception as exc:
|
| 315 |
return JSONResponse(
|
|
|
|
| 310 |
print(f"[api/generate:latex] max_ats={max_ats} status={status} "
|
| 311 |
f"external_cov={pct}% compiled={payload['latex_compiled']} "
|
| 312 |
f"engine={payload['latex_engine']} injected={len(payload['injected_terms'])}")
|
| 313 |
+
# Persist generated resume to Supabase so it survives HF Space restarts
|
| 314 |
+
try:
|
| 315 |
+
from src.supabase_client import get_service_client, is_configured, get_owner_user_id
|
| 316 |
+
if is_configured() and tex_src:
|
| 317 |
+
uid = get_owner_user_id()
|
| 318 |
+
if uid:
|
| 319 |
+
get_service_client().table("generated_resumes").insert({
|
| 320 |
+
"job_title": job_title,
|
| 321 |
+
"company": company,
|
| 322 |
+
"tex_source": tex_src,
|
| 323 |
+
"ats_score": pct,
|
| 324 |
+
"user_id": uid,
|
| 325 |
+
}).execute()
|
| 326 |
+
except Exception as _sb_exc:
|
| 327 |
+
print(f"[api/generate:latex] supabase save failed (non-fatal): {_sb_exc}")
|
| 328 |
return JSONResponse(payload)
|
| 329 |
except Exception as exc:
|
| 330 |
return JSONResponse(
|
requirements.txt
CHANGED
|
@@ -52,3 +52,6 @@ fastapi>=0.111.0
|
|
| 52 |
uvicorn[standard]>=0.29.0
|
| 53 |
websockets>=12.0
|
| 54 |
python-multipart>=0.0.9
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
uvicorn[standard]>=0.29.0
|
| 53 |
websockets>=12.0
|
| 54 |
python-multipart>=0.0.9
|
| 55 |
+
|
| 56 |
+
# Database + Auth (persistent storage across HF Space restarts)
|
| 57 |
+
supabase>=2.3.0
|
src/supabase_client.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Supabase singleton clients.
|
| 3 |
+
|
| 4 |
+
Two clients:
|
| 5 |
+
get_anon_client() β anon key, used for user-facing auth (sign in/out)
|
| 6 |
+
get_service_client() β service_role key, used server-side for DB + Storage
|
| 7 |
+
(bypasses RLS, never expose to the browser)
|
| 8 |
+
|
| 9 |
+
get_owner_user_id() β returns the single owner user's UUID (single-user app).
|
| 10 |
+
Cached after first call.
|
| 11 |
+
"""
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
|
| 15 |
+
SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "")
|
| 16 |
+
SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "")
|
| 17 |
+
|
| 18 |
+
_anon_client = None
|
| 19 |
+
_service_client = None
|
| 20 |
+
_owner_user_id = None # cached after first admin lookup
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def is_configured() -> bool:
|
| 24 |
+
return bool(SUPABASE_URL and SUPABASE_ANON_KEY)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_anon_client():
|
| 28 |
+
global _anon_client
|
| 29 |
+
if _anon_client is None:
|
| 30 |
+
from supabase import create_client
|
| 31 |
+
_anon_client = create_client(SUPABASE_URL, SUPABASE_ANON_KEY)
|
| 32 |
+
return _anon_client
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def get_service_client():
|
| 36 |
+
global _service_client
|
| 37 |
+
if _service_client is None:
|
| 38 |
+
from supabase import create_client
|
| 39 |
+
_service_client = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY)
|
| 40 |
+
return _service_client
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def get_owner_user_id() -> str | None:
|
| 44 |
+
"""Return the single owner user's UUID. Cached after first successful lookup."""
|
| 45 |
+
global _owner_user_id
|
| 46 |
+
if _owner_user_id:
|
| 47 |
+
return _owner_user_id
|
| 48 |
+
try:
|
| 49 |
+
sb = get_service_client()
|
| 50 |
+
result = sb.auth.admin.list_users()
|
| 51 |
+
users = result if isinstance(result, list) else getattr(result, "users", [])
|
| 52 |
+
if users:
|
| 53 |
+
_owner_user_id = users[0].id
|
| 54 |
+
return _owner_user_id
|
| 55 |
+
except Exception:
|
| 56 |
+
pass
|
| 57 |
+
return None
|
ui.py
CHANGED
|
@@ -639,6 +639,7 @@ _DEFAULTS = {
|
|
| 639 |
"setup_step": 1,
|
| 640 |
"completed_jobs": [], # per-job results streamed in during a run
|
| 641 |
"custom_roles": [], # user-added custom role titles
|
|
|
|
| 642 |
}
|
| 643 |
for _k, _v in _DEFAULTS.items():
|
| 644 |
if _k not in st.session_state:
|
|
@@ -656,6 +657,25 @@ if not st.session_state.get("_hf_synced"):
|
|
| 656 |
pass
|
| 657 |
st.session_state["_hf_synced"] = True
|
| 658 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 659 |
# ββ Shared progress queue ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 660 |
if "progress_q" not in st.session_state:
|
| 661 |
st.session_state["progress_q"] = queue.Queue()
|
|
@@ -923,6 +943,51 @@ def _readiness_level(pct):
|
|
| 923 |
return ("Getting Started", False)
|
| 924 |
|
| 925 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 926 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 927 |
# HEADER
|
| 928 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -987,6 +1052,16 @@ with hdr_r:
|
|
| 987 |
if st.button(hist_label, use_container_width=True):
|
| 988 |
st.session_state.show_history = not st.session_state.show_history
|
| 989 |
st.rerun()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 990 |
|
| 991 |
|
| 992 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -1208,6 +1283,19 @@ if show_config:
|
|
| 1208 |
with open("data/resume/resume.pdf", "wb") as f:
|
| 1209 |
f.write(resume_file.getvalue())
|
| 1210 |
st.session_state["_uploaded_sig"] = _sig
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1211 |
# Invalidate the parsed-profile cache so it re-parses the
|
| 1212 |
# NEW file (cache is keyed by mtime+size, but delete to be safe)
|
| 1213 |
try:
|
|
|
|
| 639 |
"setup_step": 1,
|
| 640 |
"completed_jobs": [], # per-job results streamed in during a run
|
| 641 |
"custom_roles": [], # user-added custom role titles
|
| 642 |
+
"logged_in": False, "user_id": None, "user_email": "",
|
| 643 |
}
|
| 644 |
for _k, _v in _DEFAULTS.items():
|
| 645 |
if _k not in st.session_state:
|
|
|
|
| 657 |
pass
|
| 658 |
st.session_state["_hf_synced"] = True
|
| 659 |
|
| 660 |
+
# ββ Restore uploaded resume from Supabase Storage (survives HF restarts) ββββββ
|
| 661 |
+
if not st.session_state.get("_sb_resume_synced"):
|
| 662 |
+
st.session_state["_sb_resume_synced"] = True
|
| 663 |
+
if not os.path.exists("data/resume/resume.pdf"):
|
| 664 |
+
try:
|
| 665 |
+
from src.supabase_client import get_service_client, is_configured, get_owner_user_id
|
| 666 |
+
if is_configured():
|
| 667 |
+
uid = get_owner_user_id()
|
| 668 |
+
if uid:
|
| 669 |
+
pdf_data = get_service_client().storage.from_("resumes").download(
|
| 670 |
+
f"{uid}/resume.pdf"
|
| 671 |
+
)
|
| 672 |
+
if pdf_data:
|
| 673 |
+
os.makedirs("data/resume", exist_ok=True)
|
| 674 |
+
with open("data/resume/resume.pdf", "wb") as _f:
|
| 675 |
+
_f.write(pdf_data)
|
| 676 |
+
except Exception:
|
| 677 |
+
pass
|
| 678 |
+
|
| 679 |
# ββ Shared progress queue ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 680 |
if "progress_q" not in st.session_state:
|
| 681 |
st.session_state["progress_q"] = queue.Queue()
|
|
|
|
| 943 |
return ("Getting Started", False)
|
| 944 |
|
| 945 |
|
| 946 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 947 |
+
# AUTH β Login gate
|
| 948 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 949 |
+
def _render_login_page():
|
| 950 |
+
from src.supabase_client import is_configured, get_anon_client
|
| 951 |
+
_, col, _ = st.columns([1, 1.4, 1])
|
| 952 |
+
with col:
|
| 953 |
+
st.markdown("""
|
| 954 |
+
<div style="text-align:center;padding:48px 0 28px">
|
| 955 |
+
<div style="font-size:2.8rem;line-height:1">π€</div>
|
| 956 |
+
<h2 style="font-size:1.35rem;font-weight:700;color:#0F172A;margin:10px 0 4px">JAA Β· ATS Tool</h2>
|
| 957 |
+
<p style="font-size:0.84rem;color:#64748B;margin:0">Sign in to continue</p>
|
| 958 |
+
</div>""", unsafe_allow_html=True)
|
| 959 |
+
if not is_configured():
|
| 960 |
+
st.error("Supabase not configured. Add SUPABASE_URL and SUPABASE_ANON_KEY in HF Space β Settings β Secrets.")
|
| 961 |
+
return
|
| 962 |
+
with st.form("login_form", clear_on_submit=False):
|
| 963 |
+
email = st.text_input("Email", placeholder="you@example.com")
|
| 964 |
+
password = st.text_input("Password", type="password", placeholder="β’β’β’β’β’β’β’β’")
|
| 965 |
+
submitted = st.form_submit_button("Sign in", use_container_width=True)
|
| 966 |
+
if submitted:
|
| 967 |
+
if not email or not password:
|
| 968 |
+
st.error("Enter your email and password.")
|
| 969 |
+
return
|
| 970 |
+
try:
|
| 971 |
+
resp = get_anon_client().auth.sign_in_with_password(
|
| 972 |
+
{"email": email, "password": password}
|
| 973 |
+
)
|
| 974 |
+
st.session_state["logged_in"] = True
|
| 975 |
+
st.session_state["user_id"] = resp.user.id
|
| 976 |
+
st.session_state["user_email"] = resp.user.email
|
| 977 |
+
st.rerun()
|
| 978 |
+
except Exception as exc:
|
| 979 |
+
msg = str(exc).lower()
|
| 980 |
+
if "invalid" in msg or "credentials" in msg or "login" in msg:
|
| 981 |
+
st.error("Incorrect email or password.")
|
| 982 |
+
else:
|
| 983 |
+
st.error(f"Login failed: {exc}")
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
if not st.session_state.get("logged_in"):
|
| 987 |
+
_render_login_page()
|
| 988 |
+
st.stop()
|
| 989 |
+
|
| 990 |
+
|
| 991 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 992 |
# HEADER
|
| 993 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 1052 |
if st.button(hist_label, use_container_width=True):
|
| 1053 |
st.session_state.show_history = not st.session_state.show_history
|
| 1054 |
st.rerun()
|
| 1055 |
+
_email_short = (st.session_state.get("user_email") or "").split("@")[0]
|
| 1056 |
+
if st.button(f"β» {_email_short or 'Sign out'}", use_container_width=True, help="Sign out"):
|
| 1057 |
+
try:
|
| 1058 |
+
from src.supabase_client import get_anon_client
|
| 1059 |
+
get_anon_client().auth.sign_out()
|
| 1060 |
+
except Exception:
|
| 1061 |
+
pass
|
| 1062 |
+
for _k in ("logged_in", "user_id", "user_email"):
|
| 1063 |
+
st.session_state[_k] = False if _k == "logged_in" else None
|
| 1064 |
+
st.rerun()
|
| 1065 |
|
| 1066 |
|
| 1067 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 1283 |
with open("data/resume/resume.pdf", "wb") as f:
|
| 1284 |
f.write(resume_file.getvalue())
|
| 1285 |
st.session_state["_uploaded_sig"] = _sig
|
| 1286 |
+
# Persist to Supabase Storage so it survives HF Space restarts
|
| 1287 |
+
try:
|
| 1288 |
+
from src.supabase_client import get_service_client, is_configured, get_owner_user_id
|
| 1289 |
+
if is_configured():
|
| 1290 |
+
_uid = st.session_state.get("user_id") or get_owner_user_id()
|
| 1291 |
+
if _uid:
|
| 1292 |
+
with open("data/resume/resume.pdf", "rb") as _rf:
|
| 1293 |
+
get_service_client().storage.from_("resumes").upload(
|
| 1294 |
+
f"{_uid}/resume.pdf", _rf.read(),
|
| 1295 |
+
{"upsert": "true", "content-type": "application/pdf"},
|
| 1296 |
+
)
|
| 1297 |
+
except Exception:
|
| 1298 |
+
pass
|
| 1299 |
# Invalidate the parsed-profile cache so it re-parses the
|
| 1300 |
# NEW file (cache is keyed by mtime+size, but delete to be safe)
|
| 1301 |
try:
|