From 3f8d178d38686c1a5d71d94ebccdb61701469e95 Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 31 Aug 2026 16:52:15 -0600 Subject: working on magic token and session token auth --- jb-ui/src/pages/MagicLink.tsx | 44 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 jb-ui/src/pages/MagicLink.tsx (limited to 'jb-ui/src/pages') diff --git a/jb-ui/src/pages/MagicLink.tsx b/jb-ui/src/pages/MagicLink.tsx new file mode 100644 index 0000000..cf7f87b --- /dev/null +++ b/jb-ui/src/pages/MagicLink.tsx @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState } from "react"; + +type ExchangeState = "working" | "failed" | "missing"; + +const MagicLink = function () { + const started = useRef(false); + const [state, setState] = useState("working"); + + useEffect(() => { + // React Strict Mode runs effects twice in development. Never redeem twice. + if (started.current) return; + started.current = true; + + const params = new URLSearchParams(window.location.search); + const token = params.get("token"); + if (!token) { + setState("missing"); + return; + } + + // Keep the credential out of browser history and subsequent Referer headers. + window.history.replaceState({}, "", window.location.pathname); + + void fetch("/auth/magic-link/exchange", { + method: "POST", + credentials: "include", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({token}), + }).then((response) => { + if (!response.ok) throw new Error("Magic-link exchange failed"); + window.location.replace("/"); + }).catch(() => setState("failed")); + }, []); + + if (state === "missing") { + return

This login link is missing its token.

; + } + if (state === "failed") { + return

This login link is invalid or has expired.

; + } + return

Signing you in…

; +}; + +export default MagicLink; -- cgit v1.2.3 From f5a1882de073ea6859c226395daefeb566e9e802 Mon Sep 17 00:00:00 2001 From: stuppie Date: Tue, 1 Sep 2026 12:17:32 -0600 Subject: add a test_magic_link with a super cool fake_gr_api_manager using fastapi dependency_overrides --- jb-ui/src/pages/MagicLink.tsx | 2 +- jb/api/auth.py | 7 +++-- jb/dependencies.py | 8 ++++++ jb/views/auth.py | 16 +++++++---- tests/http/test_auth.py | 66 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 10 deletions(-) create mode 100644 jb/dependencies.py create mode 100644 tests/http/test_auth.py (limited to 'jb-ui/src/pages') diff --git a/jb-ui/src/pages/MagicLink.tsx b/jb-ui/src/pages/MagicLink.tsx index cf7f87b..4042624 100644 --- a/jb-ui/src/pages/MagicLink.tsx +++ b/jb-ui/src/pages/MagicLink.tsx @@ -25,7 +25,7 @@ const MagicLink = function () { method: "POST", credentials: "include", headers: {"Content-Type": "application/json"}, - body: JSON.stringify({token}), + body: JSON.stringify({"token": token}), }).then((response) => { if (!response.ok) throw new Error("Magic-link exchange failed"); window.location.replace("/"); diff --git a/jb/api/auth.py b/jb/api/auth.py index 3481b30..a92515d 100644 --- a/jb/api/auth.py +++ b/jb/api/auth.py @@ -8,7 +8,8 @@ from fastapi import Depends, HTTPException, Request, Response, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from jb.config import settings -from jb.decorators import gr_api_manager +from jb.dependencies import get_gr_api_manager +from jb.managers.gr_api import GRApiManager from jb.models.auth import User bearer = HTTPBearer(auto_error=False) @@ -22,6 +23,7 @@ JWT_AUDIENCE = "jamesbillings67" def get_authenticated_user( request: Request, credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)], + gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], ) -> User: """FastAPI dependency for endpoints requiring a valid session.""" if settings.session_jwt_secret is None: @@ -65,7 +67,7 @@ def get_authenticated_user( ) product_user_id = claims.get("sub") - user = gr_api_manager.get_user(product_user_id=product_user_id) + user = gr_api.get_user(product_user_id=product_user_id) return user @@ -86,4 +88,3 @@ def create_session(product_user_id: str) -> str: algorithm="HS256", ) - diff --git a/jb/dependencies.py b/jb/dependencies.py new file mode 100644 index 0000000..b9577bc --- /dev/null +++ b/jb/dependencies.py @@ -0,0 +1,8 @@ +"""FastAPI dependency providers shared by application routes.""" + +from jb.decorators import gr_api_manager +from jb.managers.gr_api import GRApiManager + + +def get_gr_api_manager() -> GRApiManager: + return gr_api_manager diff --git a/jb/views/auth.py b/jb/views/auth.py index 06dbdde..33c9452 100644 --- a/jb/views/auth.py +++ b/jb/views/auth.py @@ -18,12 +18,12 @@ from jb.api.auth import ( ) from jb.api.magic_token import consume_magic_token, create_magic_token from jb.config import settings -from jb.decorators import gr_api_manager +from jb.dependencies import get_gr_api_manager +from jb.managers.gr_api import GRApiManager from jb.models.auth import ( - MagicLinkExchangeRequest, AccountLogin, + MagicLinkExchangeRequest, User, - email_to_product_user_id, ) from jb.settings import BASE_HTML @@ -58,13 +58,17 @@ def magic_link_landing_page() -> HTMLResponse: @auth_router.post("/magic-link/exchange", status_code=status.HTTP_204_NO_CONTENT) -def exchange_magic_link(body: MagicLinkExchangeRequest, response: Response) -> None: +def exchange_magic_link( + body: MagicLinkExchangeRequest, + response: Response, + gr_api: Annotated[GRApiManager, Depends(get_gr_api_manager)], +) -> None: """Exchange a magic link only after its landing page makes an explicit POST.""" user_email = consume_magic_token(body.token) - user = User.model_validate({'email': user_email}) + user = User.model_validate({"email": user_email}) # hit thl to make sure this user exists - user = gr_api_manager.ensure_user_exists(user) + user = gr_api.ensure_user_exists(user) session_token = create_session(user.product_user_id) response.set_cookie( diff --git a/tests/http/test_auth.py b/tests/http/test_auth.py new file mode 100644 index 0000000..8a625bf --- /dev/null +++ b/tests/http/test_auth.py @@ -0,0 +1,66 @@ +import secrets +from urllib.parse import parse_qs, urlparse + +import pytest +from httpx import AsyncClient + +from jb.api.auth import SESSION_COOKIE_NAME +from jb.dependencies import get_gr_api_manager +from jb.main import app +from jb.models.auth import User + + +class FakeGRApiManager: + def __init__(self): + self.users: dict[str, User] = {} + + def ensure_user_exists(self, user: User) -> User: + self.users.setdefault(user.product_user_id, user) + return self.users[user.product_user_id] + + def get_user(self, product_user_id: str) -> User: + return self.users[product_user_id] + + +@pytest.fixture +def email() -> str: + email = secrets.token_urlsafe(16) + "@gmail.com" + return email.lower() + + +@pytest.fixture +def fake_gr_api_manager(): + manager = FakeGRApiManager() + app.dependency_overrides[get_gr_api_manager] = lambda: manager + yield manager + app.dependency_overrides.pop(get_gr_api_manager, None) + + +class TestAuth: + @pytest.mark.anyio + async def test_magic_link( + self, + httpxclient: AsyncClient, + fake_gr_api_manager: FakeGRApiManager, + email: str, + ): + client = httpxclient + + res = await client.post( + "/auth/magic-link/request", json={"email": email} + ) + d = res.json() + assert res.status_code == 200 + assert d["magic_link"] + + token = parse_qs(urlparse(d["magic_link"]).query)["token"][0] + + url = "/auth/magic-link/exchange" + body = {"token": token} + res = await client.post(url, json=body) + assert res.status_code == 204 + assert client.cookies.get(SESSION_COOKIE_NAME) + + res = await client.get("/auth/session") + assert res.status_code == 200 + assert res.json()["email"] == email -- cgit v1.2.3 From 27c2e8fee5c604d64cd06a37afc62db0252f8370 Mon Sep 17 00:00:00 2001 From: stuppie Date: Mon, 7 Sep 2026 08:53:34 -0600 Subject: add LinkAmtAccount.tsx --- jb-ui/src/pages/LinkAmtAccount.tsx | 73 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 jb-ui/src/pages/LinkAmtAccount.tsx (limited to 'jb-ui/src/pages') diff --git a/jb-ui/src/pages/LinkAmtAccount.tsx b/jb-ui/src/pages/LinkAmtAccount.tsx new file mode 100644 index 0000000..0c9dab3 --- /dev/null +++ b/jb-ui/src/pages/LinkAmtAccount.tsx @@ -0,0 +1,73 @@ +import { type FormEvent, useState } from "react"; + +import { Button } from "@/components/ui/button"; + +type RequestState = "idle" | "working" | "sent" | "failed" | "missing-worker"; + +const LinkAmtAccount = function () { + const [email, setEmail] = useState(""); + const [state, setState] = useState("idle"); + const [mockLink, setMockLink] = useState(null); + const amtWorkerId = new URLSearchParams(window.location.search).get("workerId"); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!amtWorkerId) { + setState("missing-worker"); + return; + } + + setState("working"); + setMockLink(null); + try { + const response = await fetch("/auth/link-amt/request", { + method: "POST", + credentials: "include", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({ + "email": email, + "amt_worker_id": amtWorkerId, + }), + }); + if (!response.ok) throw new Error("Account-link request failed"); + const body = await response.json() as {magic_link?: string}; + setMockLink(body.magic_link ?? null); + setState("sent"); + } catch { + setState("failed"); + } + }; + + return ( +
+
+

Link your MTurk account

+

Enter the email address you want to use for James Billings.

+
+ setEmail(event.target.value)} + placeholder="you@example.com" + /> + +
+ {state === "sent" &&

Check your email to finish linking your account.

} + {state === "failed" &&

Unable to send the link. Please try again.

} + {state === "missing-worker" &&

This page is missing your MTurk worker ID.

} + {mockLink && ( +

+ Development link: continue +

+ )} +
+
+ ); +}; + +export default LinkAmtAccount; -- cgit v1.2.3 From ed201936813d9a720b51e6cb40a05267b94e7314 Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Thu, 10 Sep 2026 17:46:41 -0700 Subject: Typescript prettier Committing seperate so it's easier to see actual changes. --- jb-ui/.vscode/settings.json | 14 +- jb-ui/src/JBApp.tsx | 343 ++++--- jb-ui/src/components/EventMarquee.tsx | 428 ++++---- jb-ui/src/components/Footer.tsx | 76 +- jb-ui/src/components/Profiling.tsx | 1297 ++++++++++++------------ jb-ui/src/components/Wallet.tsx | 859 ++++++++-------- jb-ui/src/components/ui/button.tsx | 20 +- jb-ui/src/components/ui/checkbox.tsx | 14 +- jb-ui/src/components/ui/confetti.tsx | 98 +- jb-ui/src/components/ui/dot-pattern.tsx | 60 +- jb-ui/src/components/ui/field.tsx | 72 +- jb-ui/src/components/ui/label.tsx | 12 +- jb-ui/src/components/ui/magic-card.tsx | 70 +- jb-ui/src/components/ui/pagination.tsx | 30 +- jb-ui/src/components/ui/separator.tsx | 14 +- jb-ui/src/components/ui/skeleton.tsx | 6 +- jb-ui/src/hooks.ts | 10 +- jb-ui/src/index.css | 1 - jb-ui/src/lib/utils.ts | 164 +-- jb-ui/src/main.tsx | 24 +- jb-ui/src/models/app.ts | 58 +- jb-ui/src/models/appSlice.ts | 323 +++--- jb-ui/src/models/grlEventsSlice.ts | 74 +- jb-ui/src/models/grlStatsSlice.ts | 67 +- jb-ui/src/models/profilingQuestionsSlice.ts | 672 +++++++------ jb-ui/src/models/profilingUtils.ts | 309 +++--- jb-ui/src/pages/Home.tsx | 12 +- jb-ui/src/pages/LinkAmtAccount.tsx | 131 +-- jb-ui/src/pages/MagicLink.tsx | 72 +- jb-ui/src/pages/Preview.tsx | 523 +++++----- jb-ui/src/pages/Result.tsx | 206 ++-- jb-ui/src/pages/Transfer.tsx | 50 + jb-ui/src/pages/Work.tsx | 1452 ++++++++++++++------------- jb-ui/src/store.ts | 43 +- jb-ui/src/vite-env.d.ts | 16 +- jb-ui/tests/base.test.ts | 6 +- jb-ui/tests/setupTests.ts | 2 +- 37 files changed, 3980 insertions(+), 3648 deletions(-) create mode 100644 jb-ui/src/pages/Transfer.tsx (limited to 'jb-ui/src/pages') diff --git a/jb-ui/.vscode/settings.json b/jb-ui/.vscode/settings.json index aef94ef..4257030 100644 --- a/jb-ui/.vscode/settings.json +++ b/jb-ui/.vscode/settings.json @@ -1,3 +1,15 @@ { - "editor.rulers": [80, 120] + "editor.rulers": [ + 80, + 120 + ], + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true, + "[typescriptreact]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + } } \ No newline at end of file diff --git a/jb-ui/src/JBApp.tsx b/jb-ui/src/JBApp.tsx index ef23b3b..472b976 100644 --- a/jb-ui/src/JBApp.tsx +++ b/jb-ui/src/JBApp.tsx @@ -1,21 +1,26 @@ import { - EventMessage, - EventType, - PingMessage, - PongMessage, - PongMessageKindEnum, - StatsMessage, - StatusApi, - SubscribeMessage, SubscribeMessageKindEnum, - TaskStatusResponse, - UserWalletBalance, - UserWalletBalanceResponse, - WalletApi + EventMessage, + EventType, + PingMessage, + PongMessage, + PongMessageKindEnum, + StatsMessage, + StatusApi, + SubscribeMessage, + SubscribeMessageKindEnum, + TaskStatusResponse, + UserWalletBalance, + UserWalletBalanceResponse, + WalletApi, } from "@/api_fsb"; import Footer from "@/components/Footer"; import { useAppDispatch } from "@/hooks"; import { bpid, routeBasename, tagManager } from "@/lib/utils"; -import { setAssignmentID, setProductUserID, setTurkSubmitTo } from "@/models/appSlice"; +import { + setAssignmentID, + setProductUserID, + setTurkSubmitTo, +} from "@/models/appSlice"; import { addEvent } from "@/models/grlEventsSlice"; import { addStatsData } from "@/models/grlStatsSlice"; import Home from "@/pages/Home"; @@ -23,8 +28,15 @@ import MagicLink from "@/pages/MagicLink"; import Preview from "@/pages/Preview"; import Result from "@/pages/Result"; import Work from "@/pages/Work"; -import { BrowserRouter, Outlet, Route, Routes, useSearchParams } from "react-router-dom"; -import useWebSocket from 'react-use-websocket'; +import Transfer from "@/pages/Transfer"; +import { + BrowserRouter, + Outlet, + Route, + Routes, + useSearchParams, +} from "react-router-dom"; +import useWebSocket from "react-use-websocket"; import NewsTicker from "./components/EventMarquee"; import { useEffect } from "react"; @@ -34,178 +46,195 @@ import "@/index.css"; import { setTaskStatus, setUserWalletBalance } from "@/models/appSlice"; import { Profiling } from "./components/Profiling"; -type Message = PingMessage | PongMessage | SubscribeMessage | EventMessage | StatsMessage; +type Message = + | PingMessage + | PongMessage + | SubscribeMessage + | EventMessage + | StatsMessage; function isStatsMessage(msg: Message): msg is StatsMessage { - return msg.kind === 'stats'; + return msg.kind === "stats"; } function isEventMessage(msg: Message): msg is EventMessage { - return msg.kind === 'event'; + return msg.kind === "event"; } function isPingMessage(msg: Message): msg is PingMessage { - return msg.kind === 'ping'; + return msg.kind === "ping"; } function QueryParamProcessor() { - const dispatch = useAppDispatch() - const [searchParams] = useSearchParams(); + const dispatch = useAppDispatch(); + const [searchParams] = useSearchParams(); - useEffect(() => { - const worker_id = searchParams.get('workerId'); - const assignment_id = searchParams.get('assignmentId'); - const tsid = searchParams.get('tsid'); - const turkSubmitTo = searchParams.get('turkSubmitTo'); - - if (turkSubmitTo) { - dispatch(setTurkSubmitTo(turkSubmitTo)) - } + useEffect(() => { + const worker_id = searchParams.get("workerId"); + const assignment_id = searchParams.get("assignmentId"); + const tsid = searchParams.get("tsid"); + const turkSubmitTo = searchParams.get("turkSubmitTo"); - if (worker_id) { - dispatch(setProductUserID(worker_id)) - - new WalletApi().getUserWalletBalanceProductIdWalletGet( - bpid, // productId - worker_id, // bpuid - ).then(res => { - const response = res.data as UserWalletBalanceResponse; - const balance = response.wallet as UserWalletBalance; - dispatch(setUserWalletBalance(balance)); - }); - } + if (turkSubmitTo) { + dispatch(setTurkSubmitTo(turkSubmitTo)); + } - if (assignment_id) { - dispatch(setAssignmentID(assignment_id)) - } + if (worker_id) { + dispatch(setProductUserID(worker_id)); + + new WalletApi() + .getUserWalletBalanceProductIdWalletGet( + bpid, // productId + worker_id, // bpuid + ) + .then((res) => { + const response = res.data as UserWalletBalanceResponse; + const balance = response.wallet as UserWalletBalance; + dispatch(setUserWalletBalance(balance)); + }); + } - if (tsid) { - new StatusApi().getTaskStatusProductIdStatusTsidGet( - bpid, // productId - tsid).then(res => { - const response: TaskStatusResponse = res.data; - dispatch(setTaskStatus(response)) - }).catch((error) => { - console.error("Error fetching task status:", error); - }); - } + if (assignment_id) { + dispatch(setAssignmentID(assignment_id)); + } - }, [searchParams]); + if (tsid) { + new StatusApi() + .getTaskStatusProductIdStatusTsidGet( + bpid, // productId + tsid, + ) + .then((res) => { + const response: TaskStatusResponse = res.data; + dispatch(setTaskStatus(response)); + }) + .catch((error) => { + console.error("Error fetching task status:", error); + }); + } + }, [searchParams]); - return ; + return ; } const Layout = () => { - return ( - + + +