saitejatirunagari Claude Sonnet 4.6 commited on
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>

Files changed (5) hide show
  1. HISTORY.md +31 -0
  2. api_server.py +15 -0
  3. requirements.txt +3 -0
  4. src/supabase_client.py +57 -0
  5. 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: