Muhammad-Mehdi commited on
Commit
1e1198f
·
1 Parent(s): bdd1640

login issue fixed

Browse files
backend/app/routers/auth.py CHANGED
@@ -36,6 +36,15 @@ def _err(exc: ident.IdentityError | ev.VerificationError, status: int = 400) ->
36
  return HTTPException(getattr(exc, "status", status), {"code": exc.code, "message": exc.message})
37
 
38
 
 
 
 
 
 
 
 
 
 
39
  def _serialize_user(user: User) -> dict:
40
  return {
41
  "id": str(user.id),
@@ -279,7 +288,7 @@ def register_start(body: RegisterStartIn, db: Session = Depends(get_db)):
279
  except (ident.IdentityError, ev.VerificationError) as exc:
280
  raise _err(exc, 429 if getattr(exc, "code", "") == "cooldown" else 400) from exc
281
  db.commit()
282
- email_service.send_verification_code(email, issued.code)
283
  return {"ok": True}
284
 
285
 
@@ -314,7 +323,7 @@ def register_resend(body: EmailIn, db: Session = Depends(get_db)):
314
  except ev.VerificationError as exc:
315
  raise _err(exc, 429) from exc
316
  db.commit()
317
- email_service.send_verification_code(email, issued.code)
318
  return {"ok": True}
319
 
320
 
@@ -338,14 +347,19 @@ def email_login(body: LoginIn, db: Session = Depends(get_db)):
338
  @router.post("/password/forgot/start")
339
  def forgot_start(body: EmailIn, db: Session = Depends(get_db)):
340
  user = ident.find_user(db, body.email)
341
- # Always 200 so the endpoint does not reveal which emails exist.
342
- if user and user.auth_provider == AuthProvider.EMAIL and user.is_active:
343
- try:
344
- issued = ev.issue_code(db, user.email, VerificationPurpose.PASSWORD_RESET)
345
- db.commit()
346
- email_service.send_password_reset_code(user.email, issued.code)
347
- except ev.VerificationError:
348
- pass
 
 
 
 
 
349
  return {"ok": True}
350
 
351
 
 
36
  return HTTPException(getattr(exc, "status", status), {"code": exc.code, "message": exc.message})
37
 
38
 
39
+ def _sent_or_fail(db: Session, sent: bool, email: str, purpose: VerificationPurpose) -> None:
40
+ """A code the user never receives is useless: drop it and say so instead of reporting success."""
41
+ if sent:
42
+ return
43
+ ev.clear_codes(db, email, purpose)
44
+ db.commit()
45
+ raise HTTPException(502, {"code": "email_failed", "message": "We couldn't send the code. Try again in a moment."})
46
+
47
+
48
  def _serialize_user(user: User) -> dict:
49
  return {
50
  "id": str(user.id),
 
288
  except (ident.IdentityError, ev.VerificationError) as exc:
289
  raise _err(exc, 429 if getattr(exc, "code", "") == "cooldown" else 400) from exc
290
  db.commit()
291
+ _sent_or_fail(db, email_service.send_verification_code(email, issued.code), email, VerificationPurpose.SIGNUP)
292
  return {"ok": True}
293
 
294
 
 
323
  except ev.VerificationError as exc:
324
  raise _err(exc, 429) from exc
325
  db.commit()
326
+ _sent_or_fail(db, email_service.send_verification_code(email, issued.code), email, VerificationPurpose.SIGNUP)
327
  return {"ok": True}
328
 
329
 
 
347
  @router.post("/password/forgot/start")
348
  def forgot_start(body: EmailIn, db: Session = Depends(get_db)):
349
  user = ident.find_user(db, body.email)
350
+ # Sign up already tells whether an email is taken, so naming the problem here leaks nothing new.
351
+ if not user or user.deleted_at or not user.is_active:
352
+ raise HTTPException(404, {"code": "not_found", "message": "No account found with this email. Create one instead."})
353
+ if user.auth_provider != AuthProvider.EMAIL:
354
+ raise _err(ident.wrong_provider_error(user))
355
+ try:
356
+ issued = ev.issue_code(db, user.email, VerificationPurpose.PASSWORD_RESET)
357
+ except ev.VerificationError as exc:
358
+ raise _err(exc, 429) from exc
359
+ db.commit()
360
+ _sent_or_fail(
361
+ db, email_service.send_password_reset_code(user.email, issued.code), user.email, VerificationPurpose.PASSWORD_RESET
362
+ )
363
  return {"ok": True}
364
 
365
 
backend/app/services/email.py CHANGED
@@ -55,7 +55,8 @@ class ConsoleEmailProvider(BaseEmailProvider):
55
 
56
  def send(self, email: OutgoingEmail) -> str | None:
57
  ConsoleEmailProvider.sent.append(email)
58
- log.info("[email] to=%s subject=%s\n%s", email.to, email.subject, email.text)
 
59
  return None
60
 
61
 
@@ -264,6 +265,13 @@ class EmailService:
264
  def _build() -> EmailService:
265
  if settings.email_provider == "resend" and settings.resend_api_key:
266
  return EmailService(ResendEmailProvider(settings.resend_api_key))
 
 
 
 
 
 
 
267
  return EmailService(ConsoleEmailProvider())
268
 
269
 
 
55
 
56
  def send(self, email: OutgoingEmail) -> str | None:
57
  ConsoleEmailProvider.sent.append(email)
58
+ # Warning, not info: locally this is the only place a sign up or reset code shows up.
59
+ log.warning("[email] to=%s subject=%s\n%s", email.to, email.subject, email.text)
60
  return None
61
 
62
 
 
265
  def _build() -> EmailService:
266
  if settings.email_provider == "resend" and settings.resend_api_key:
267
  return EmailService(ResendEmailProvider(settings.resend_api_key))
268
+ if settings.email_provider == "resend":
269
+ log.warning("EMAIL_PROVIDER=resend but RESEND_API_KEY is empty: emails are only logged, not sent")
270
+ elif settings.email_provider != "console":
271
+ log.warning("Unknown EMAIL_PROVIDER=%r (use resend or console): emails are only logged, not sent",
272
+ settings.email_provider)
273
+ else:
274
+ log.warning("EMAIL_PROVIDER=console: emails are only logged, not sent")
275
  return EmailService(ConsoleEmailProvider())
276
 
277
 
backend/app/services/email_verification.py CHANGED
@@ -52,6 +52,11 @@ def _latest(db: Session, email: str, purpose: VerificationPurpose) -> EmailVerif
52
  )
53
 
54
 
 
 
 
 
 
55
  def issue_code(
56
  db: Session,
57
  email: str,
@@ -61,7 +66,7 @@ def issue_code(
61
  ) -> IssuedCode:
62
  now = datetime.now(UTC)
63
  latest = _latest(db, email, purpose)
64
- if latest and now - _aware(latest.created_at) < RESEND_COOLDOWN:
65
  raise VerificationError("cooldown", "Please wait a minute before requesting another code.")
66
  clear_codes(db, email, purpose)
67
  code = f"{secrets.randbelow(1_000_000):06d}"
@@ -83,14 +88,20 @@ def check_code(db: Session, email: str, purpose: VerificationPurpose, code: str)
83
  record = _latest(db, email, purpose)
84
  if not record:
85
  raise VerificationError("not_found", "No active code. Request a new one.")
 
 
86
  if datetime.now(UTC) > _aware(record.expires_at):
87
  raise VerificationError("expired", "That code expired. Request a new one.")
88
- if record.attempts >= MAX_ATTEMPTS:
89
- raise VerificationError("too_many_attempts", "Too many attempts. Request a new code.")
90
  if not hmac.compare_digest(record.code_hash, _hash(email, code.strip())):
91
  record.attempts += 1
 
 
 
 
 
 
92
  db.commit()
93
- raise VerificationError("invalid", "That code is not right.")
94
  return record
95
 
96
 
 
52
  )
53
 
54
 
55
+ def _alive(record: EmailVerificationCode, now: datetime) -> bool:
56
+ """A code that can still be entered. Dead codes (expired, out of tries) never hold up a resend."""
57
+ return now <= _aware(record.expires_at) and record.attempts < MAX_ATTEMPTS
58
+
59
+
60
  def issue_code(
61
  db: Session,
62
  email: str,
 
66
  ) -> IssuedCode:
67
  now = datetime.now(UTC)
68
  latest = _latest(db, email, purpose)
69
+ if latest and _alive(latest, now) and now - _aware(latest.created_at) < RESEND_COOLDOWN:
70
  raise VerificationError("cooldown", "Please wait a minute before requesting another code.")
71
  clear_codes(db, email, purpose)
72
  code = f"{secrets.randbelow(1_000_000):06d}"
 
88
  record = _latest(db, email, purpose)
89
  if not record:
90
  raise VerificationError("not_found", "No active code. Request a new one.")
91
+ if record.attempts >= MAX_ATTEMPTS:
92
+ raise VerificationError("too_many_attempts", "Too many wrong tries. This code has expired. Request a new one.")
93
  if datetime.now(UTC) > _aware(record.expires_at):
94
  raise VerificationError("expired", "That code expired. Request a new one.")
 
 
95
  if not hmac.compare_digest(record.code_hash, _hash(email, code.strip())):
96
  record.attempts += 1
97
+ left = MAX_ATTEMPTS - record.attempts
98
+ if left <= 0:
99
+ # Expire rather than consume: sign up resend still needs the pending name and password.
100
+ record.expires_at = datetime.now(UTC)
101
+ db.commit()
102
+ raise VerificationError("too_many_attempts", "Too many wrong tries. This code has expired. Request a new one.")
103
  db.commit()
104
+ raise VerificationError("invalid", f"That code is not right. {left} {'try' if left == 1 else 'tries'} left.")
105
  return record
106
 
107
 
backend/tests/test_auth.py CHANGED
@@ -35,6 +35,42 @@ def test_wrong_code_and_attempt_limit(client):
35
  assert r.json()["detail"]["code"] == "too_many_attempts"
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  def test_resend_cooldown(client):
39
  client.post("/api/auth/email/register/start", json={"email": "c@d.co", "password": "longenough"})
40
  r = client.post("/api/auth/email/register/resend", json={"email": "c@d.co"})
 
35
  assert r.json()["detail"]["code"] == "too_many_attempts"
36
 
37
 
38
+ def test_wrong_code_counts_down_and_lockout_allows_resend(client):
39
+ client.post("/api/auth/email/register/start", json={"email": "e@f.co", "password": "longenough"})
40
+ if last_code() == "000000":
41
+ return
42
+ r = client.post("/api/auth/email/register/verify", json={"email": "e@f.co", "code": "000000"})
43
+ assert r.json()["detail"]["message"].endswith("4 tries left.")
44
+ for _ in range(4):
45
+ r = client.post("/api/auth/email/register/verify", json={"email": "e@f.co", "code": "000000"})
46
+ assert r.json()["detail"]["code"] == "too_many_attempts"
47
+ # A dead code does not hold up a new one, and the pending sign up survives the lockout.
48
+ assert client.post("/api/auth/email/register/resend", json={"email": "e@f.co"}).status_code == 200
49
+ r = client.post("/api/auth/email/register/verify", json={"email": "e@f.co", "code": last_code()})
50
+ assert r.status_code == 200
51
+
52
+
53
+ def test_email_send_failure_is_reported(client, monkeypatch):
54
+ from app.services.email import email_service
55
+
56
+ monkeypatch.setattr(email_service, "send_verification_code", lambda to, code: False)
57
+ r = client.post("/api/auth/email/register/start", json={"email": "f@g.co", "password": "longenough"})
58
+ assert r.status_code == 502
59
+ assert r.json()["detail"]["code"] == "email_failed"
60
+
61
+
62
+ def test_forgot_password_unknown_and_google(client, db_session):
63
+ from app.services.auth_identity import resolve_or_create_google_user
64
+
65
+ r = client.post("/api/auth/password/forgot/start", json={"email": "nobody@example.com"})
66
+ assert r.status_code == 404
67
+ resolve_or_create_google_user(db_session, email="g2@example.com", google_id="456", name="G", avatar_url=None)
68
+ db_session.commit()
69
+ r = client.post("/api/auth/password/forgot/start", json={"email": "g2@example.com"})
70
+ assert r.status_code == 409
71
+ assert r.json()["detail"]["code"] == "wrong_provider"
72
+
73
+
74
  def test_resend_cooldown(client):
75
  client.post("/api/auth/email/register/start", json={"email": "c@d.co", "password": "longenough"})
76
  r = client.post("/api/auth/email/register/resend", json={"email": "c@d.co"})
frontend/src/api/auth.ts CHANGED
@@ -34,6 +34,8 @@ export const authApi = {
34
  forgotStart: (email: string) => post<{ ok: true }>("/api/auth/password/forgot/start", { email }),
35
  forgotComplete: (email: string, code: string, password: string) =>
36
  post<LoginResult>("/api/auth/password/forgot/complete", { email, code, password }),
37
- logout: () => post<{ ok: true }>("/api/auth/logout"),
 
 
38
  deleteAccount: () => post<{ ok: true }>("/api/auth/delete-account"),
39
  };
 
34
  forgotStart: (email: string) => post<{ ok: true }>("/api/auth/password/forgot/start", { email }),
35
  forgotComplete: (email: string, code: string, password: string) =>
36
  post<LoginResult>("/api/auth/password/forgot/complete", { email, code, password }),
37
+ /** Takes the token explicitly: sign out clears local tokens before revoking on the server. */
38
+ logout: (access: string) =>
39
+ api<{ ok: true }>("/api/auth/logout", { method: "POST", headers: { Authorization: `Bearer ${access}` } }, false),
40
  deleteAccount: () => post<{ ok: true }>("/api/auth/delete-account"),
41
  };
frontend/src/api/client.ts CHANGED
@@ -17,6 +17,9 @@ export class ApiError extends Error {
17
  /** Fired on every 402 plan_limit so the upgrade popup opens wherever the request came from. */
18
  export const PLAN_LIMIT_EVENT = "ns:plan-limit";
19
 
 
 
 
20
  export type PlanLimitDetail = { message: string; kind?: string; plan?: string; upgrade_to?: string | null };
21
 
22
  function read(key: string): string | null {
@@ -93,9 +96,14 @@ async function request(path: string, init: RequestInit, retry: boolean): Promise
93
  if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
94
  headers.set("Content-Type", "application/json");
95
  }
96
- if (tokens.access) headers.set("Authorization", `Bearer ${tokens.access}`);
 
97
  const res = await fetch(`${BASE}${path}`, { ...init, headers });
98
  if (res.status === 401 && retry && (await tryRefresh())) return request(path, init, false);
 
 
 
 
99
  if (!res.ok) throw await parseError(res);
100
  return res;
101
  }
 
17
  /** Fired on every 402 plan_limit so the upgrade popup opens wherever the request came from. */
18
  export const PLAN_LIMIT_EVENT = "ns:plan-limit";
19
 
20
+ /** Fired when a signed in request gets a 401 that a refresh cannot fix, so the app signs the user out. */
21
+ export const SESSION_EXPIRED_EVENT = "ns:session-expired";
22
+
23
  export type PlanLimitDetail = { message: string; kind?: string; plan?: string; upgrade_to?: string | null };
24
 
25
  function read(key: string): string | null {
 
96
  if (init.body && !(init.body instanceof FormData) && !headers.has("Content-Type")) {
97
  headers.set("Content-Type", "application/json");
98
  }
99
+ const signedIn = Boolean(tokens.access);
100
+ if (signedIn) headers.set("Authorization", `Bearer ${tokens.access}`);
101
  const res = await fetch(`${BASE}${path}`, { ...init, headers });
102
  if (res.status === 401 && retry && (await tryRefresh())) return request(path, init, false);
103
+ // Auth endpoints answer 401 for a wrong password and similar; only a dead session elsewhere signs out.
104
+ if (res.status === 401 && signedIn && retry && !path.startsWith("/api/auth/")) {
105
+ window.dispatchEvent(new Event(SESSION_EXPIRED_EVENT));
106
+ }
107
  if (!res.ok) throw await parseError(res);
108
  return res;
109
  }
frontend/src/components/AppShell.tsx CHANGED
@@ -33,7 +33,7 @@ function readCollapsed(): boolean {
33
  }
34
 
35
  export default function AppShell() {
36
- const { user, loading, logout } = useAuth();
37
  const location = useLocation();
38
  const notebookFocus = /^\/app\/notebooks\/[^/]+$/.test(location.pathname);
39
  const [collapsed, setCollapsed] = useState(readCollapsed);
@@ -47,10 +47,7 @@ export default function AppShell() {
47
  return !c;
48
  });
49
  if (loading) return <div className="boot"><div className="orbit-loader"><span /></div></div>;
50
- if (!user) {
51
- const next = new URLSearchParams({ next: location.pathname + location.search });
52
- return <Navigate to={`/auth?${next}`} replace />;
53
- }
54
 
55
  return (
56
  <UpgradeProvider>
@@ -103,7 +100,7 @@ export default function AppShell() {
103
  </NavLink>
104
  <div className="sidebar-user">
105
  <span className="mono muted side-label">{user.email}</span>
106
- <button className="link-btn" onClick={logout} title="Sign out">
107
  Sign out
108
  </button>
109
  </div>
 
33
  }
34
 
35
  export default function AppShell() {
36
+ const { user, loading, signingOut, logout } = useAuth();
37
  const location = useLocation();
38
  const notebookFocus = /^\/app\/notebooks\/[^/]+$/.test(location.pathname);
39
  const [collapsed, setCollapsed] = useState(readCollapsed);
 
47
  return !c;
48
  });
49
  if (loading) return <div className="boot"><div className="orbit-loader"><span /></div></div>;
50
+ if (!user) return <Navigate to="/" replace />;
 
 
 
51
 
52
  return (
53
  <UpgradeProvider>
 
100
  </NavLink>
101
  <div className="sidebar-user">
102
  <span className="mono muted side-label">{user.email}</span>
103
+ <button className="link-btn" onClick={logout} disabled={signingOut} title="Sign out">
104
  Sign out
105
  </button>
106
  </div>
frontend/src/hooks/useAuth.tsx CHANGED
@@ -1,20 +1,27 @@
1
  import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
 
2
  import { authApi, type LoginResult, type User } from "../api/auth";
3
- import { tokens } from "../api/client";
4
  import { clearSourceCache } from "../components/video/sourceCache";
5
 
6
  type AuthState = {
7
  user: User | null;
8
  loading: boolean;
 
9
  complete: (result: LoginResult) => void;
10
  logout: () => Promise<void>;
11
  };
12
 
13
  const AuthContext = createContext<AuthState | null>(null);
14
 
 
 
 
15
  export function AuthProvider({ children }: { children: ReactNode }) {
16
  const [user, setUser] = useState<User | null>(null);
17
  const [loading, setLoading] = useState(Boolean(tokens.access));
 
 
18
 
19
  useEffect(() => {
20
  if (!tokens.access) return;
@@ -25,6 +32,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
25
  .finally(() => setLoading(false));
26
  }, []);
27
 
 
 
 
 
 
 
 
 
 
 
 
28
  const complete = useCallback((result: LoginResult) => {
29
  tokens.set(result.access_token, result.refresh_token);
30
  clearSourceCache();
@@ -32,17 +50,40 @@ export function AuthProvider({ children }: { children: ReactNode }) {
32
  }, []);
33
 
34
  const logout = useCallback(async () => {
 
 
 
 
 
35
  try {
36
- await authApi.logout();
 
 
 
37
  } finally {
38
- tokens.clear();
39
- clearSourceCache();
40
  setUser(null);
 
41
  }
42
- }, []);
43
 
44
- const value = useMemo(() => ({ user, loading, complete, logout }), [user, loading, complete, logout]);
45
- return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  }
47
 
48
  export function useAuth(): AuthState {
 
1
  import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
2
+ import { useNavigate } from "react-router-dom";
3
  import { authApi, type LoginResult, type User } from "../api/auth";
4
+ import { SESSION_EXPIRED_EVENT, tokens } from "../api/client";
5
  import { clearSourceCache } from "../components/video/sourceCache";
6
 
7
  type AuthState = {
8
  user: User | null;
9
  loading: boolean;
10
+ signingOut: boolean;
11
  complete: (result: LoginResult) => void;
12
  logout: () => Promise<void>;
13
  };
14
 
15
  const AuthContext = createContext<AuthState | null>(null);
16
 
17
+ /** How long sign out waits for the server to revoke the session before moving on. */
18
+ const REVOKE_TIMEOUT_MS = 5000;
19
+
20
  export function AuthProvider({ children }: { children: ReactNode }) {
21
  const [user, setUser] = useState<User | null>(null);
22
  const [loading, setLoading] = useState(Boolean(tokens.access));
23
+ const [signingOut, setSigningOut] = useState(false);
24
+ const navigate = useNavigate();
25
 
26
  useEffect(() => {
27
  if (!tokens.access) return;
 
32
  .finally(() => setLoading(false));
33
  }, []);
34
 
35
+ useEffect(() => {
36
+ const expire = () => {
37
+ tokens.clear();
38
+ clearSourceCache();
39
+ setUser(null);
40
+ navigate("/", { replace: true });
41
+ };
42
+ window.addEventListener(SESSION_EXPIRED_EVENT, expire);
43
+ return () => window.removeEventListener(SESSION_EXPIRED_EVENT, expire);
44
+ }, [navigate]);
45
+
46
  const complete = useCallback((result: LoginResult) => {
47
  tokens.set(result.access_token, result.refresh_token);
48
  clearSourceCache();
 
50
  }, []);
51
 
52
  const logout = useCallback(async () => {
53
+ setSigningOut(true);
54
+ // Drop the session locally first so nothing (in this tab or another) can use it again.
55
+ const access = tokens.access;
56
+ tokens.clear();
57
+ clearSourceCache();
58
  try {
59
+ if (access) {
60
+ const timeout = new Promise<void>((resolve) => setTimeout(resolve, REVOKE_TIMEOUT_MS));
61
+ await Promise.race([authApi.logout(access).catch(() => undefined), timeout]);
62
+ }
63
  } finally {
64
+ // Navigate in the same batch as clearing the user so AppShell never sees a signed out render.
65
+ navigate("/", { replace: true });
66
  setUser(null);
67
+ setSigningOut(false);
68
  }
69
+ }, [navigate]);
70
 
71
+ const value = useMemo(
72
+ () => ({ user, loading, signingOut, complete, logout }),
73
+ [user, loading, signingOut, complete, logout],
74
+ );
75
+ return (
76
+ <AuthContext.Provider value={value}>
77
+ {children}
78
+ {signingOut && (
79
+ <div className="boot signout-overlay" role="status" aria-label="Signing out">
80
+ <div className="orbit-loader">
81
+ <span />
82
+ </div>
83
+ </div>
84
+ )}
85
+ </AuthContext.Provider>
86
+ );
87
  }
88
 
89
  export function useAuth(): AuthState {
frontend/src/pages/AuthPage.tsx CHANGED
@@ -6,6 +6,9 @@ import Logo from "../components/Logo";
6
  import SkyCanvas from "../components/SkyCanvas";
7
  import { useAuth } from "../hooks/useAuth";
8
 
 
 
 
9
  type Mode = "signin" | "signup" | "verify" | "forgot" | "reset";
10
 
11
  declare global {
@@ -75,6 +78,9 @@ export default function AuthPage() {
75
  const [password, setPassword] = useState("");
76
  const [name, setName] = useState("");
77
  const [code, setCode] = useState("");
 
 
 
78
  const [error, setError] = useState<string | null>(null);
79
  const [notice, setNotice] = useState<string | null>(null);
80
  const [busy, setBusy] = useState(false);
@@ -115,11 +121,36 @@ export default function AuthPage() {
115
  }
116
  } catch (e) {
117
  setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
 
 
 
 
 
 
 
 
118
  } finally {
119
  setBusy(false);
120
  }
121
  };
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  const onGoogle = (credential: string) => run(() => authApi.google(credential));
124
 
125
  const submit = (e: FormEvent) => {
@@ -136,11 +167,14 @@ export default function AuthPage() {
136
  return run(async () => {
137
  await authApi.forgotStart(email);
138
  setMode("reset");
139
- setNotice(`If ${email} has an account, a reset code is on its way.`);
140
  });
141
  return run(() => authApi.forgotComplete(email, code, password));
142
  };
143
 
 
 
 
144
  const titles: Record<Mode, string> = {
145
  signin: "Welcome back",
146
  signup: "Create your account",
@@ -216,15 +250,32 @@ export default function AuthPage() {
216
  {(mode === "signin" || mode === "signup" || mode === "reset") && (
217
  <label>
218
  <span>{mode === "reset" ? "New password" : "Password"}</span>
219
- <input
220
- className="input"
221
- type="password"
222
- required
223
- minLength={8}
224
- value={password}
225
- onChange={(e) => setPassword(e.target.value)}
226
- autoComplete={mode === "signin" ? "current-password" : "new-password"}
227
- />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  </label>
229
  )}
230
  {error && (
@@ -232,9 +283,15 @@ export default function AuthPage() {
232
  {error}
233
  </p>
234
  )}
235
- <button type="submit" className="btn btn-primary" disabled={busy}>
236
- {busy ? "Working..." : cta[mode]}
237
- </button>
 
 
 
 
 
 
238
  </form>
239
 
240
  <div className="auth-switch muted">
@@ -259,20 +316,16 @@ export default function AuthPage() {
259
  </button>
260
  </span>
261
  )}
262
- {mode === "verify" && (
263
- <button
264
- type="button"
265
- className="link-btn"
266
- onClick={() =>
267
- run(async () => {
268
- await authApi.registerResend(email);
269
- setNotice("New code sent.");
270
- })
271
- }
272
- >
273
- Resend code
274
- </button>
275
- )}
276
  {(mode === "forgot" || mode === "reset") && (
277
  <button type="button" className="link-btn" onClick={() => setMode("signin")}>
278
  Back to sign in
 
6
  import SkyCanvas from "../components/SkyCanvas";
7
  import { useAuth } from "../hooks/useAuth";
8
 
9
+ /** Mirrors validate_password on the backend. */
10
+ const MIN_PASSWORD = 8;
11
+
12
  type Mode = "signin" | "signup" | "verify" | "forgot" | "reset";
13
 
14
  declare global {
 
78
  const [password, setPassword] = useState("");
79
  const [name, setName] = useState("");
80
  const [code, setCode] = useState("");
81
+ const [showPassword, setShowPassword] = useState(false);
82
+ // The current code is out of tries or expired: the only ways forward are a new code or a new email.
83
+ const [codeDead, setCodeDead] = useState(false);
84
  const [error, setError] = useState<string | null>(null);
85
  const [notice, setNotice] = useState<string | null>(null);
86
  const [busy, setBusy] = useState(false);
 
121
  }
122
  } catch (e) {
123
  setError(e instanceof ApiError ? e.message : "Something went wrong. Try again.");
124
+ if (!(e instanceof ApiError)) return;
125
+ // A dead code cannot be retried; clear it so the next step is clearly "Resend code".
126
+ if (e.code === "too_many_attempts" || e.code === "expired") {
127
+ setCode("");
128
+ setCodeDead(true);
129
+ }
130
+ // Registered with the other method: back to sign in, where the Google button sits.
131
+ if (e.code === "wrong_provider") setMode("signin");
132
  } finally {
133
  setBusy(false);
134
  }
135
  };
136
 
137
+ useEffect(() => setCodeDead(false), [mode]);
138
+
139
+ const resend = () =>
140
+ run(async () => {
141
+ if (mode === "verify") await authApi.registerResend(email);
142
+ else await authApi.forgotStart(email);
143
+ setCodeDead(false);
144
+ setNotice("New code sent.");
145
+ });
146
+
147
+ const tryAnotherEmail = () => {
148
+ setCode("");
149
+ setError(null);
150
+ setNotice(null);
151
+ setMode(mode === "verify" ? "signup" : "forgot");
152
+ };
153
+
154
  const onGoogle = (credential: string) => run(() => authApi.google(credential));
155
 
156
  const submit = (e: FormEvent) => {
 
167
  return run(async () => {
168
  await authApi.forgotStart(email);
169
  setMode("reset");
170
+ setNotice(`We sent a reset code to ${email}.`);
171
  });
172
  return run(() => authApi.forgotComplete(email, code, password));
173
  };
174
 
175
+ // New passwords (sign up, reset) must meet the length rule before the form can be sent.
176
+ const passwordTooShort = (mode === "signup" || mode === "reset") && password.length < MIN_PASSWORD;
177
+
178
  const titles: Record<Mode, string> = {
179
  signin: "Welcome back",
180
  signup: "Create your account",
 
250
  {(mode === "signin" || mode === "signup" || mode === "reset") && (
251
  <label>
252
  <span>{mode === "reset" ? "New password" : "Password"}</span>
253
+ <div className="password-field">
254
+ <input
255
+ className="input"
256
+ type={showPassword ? "text" : "password"}
257
+ required
258
+ minLength={MIN_PASSWORD}
259
+ value={password}
260
+ onChange={(e) => setPassword(e.target.value)}
261
+ autoComplete={mode === "signin" ? "current-password" : "new-password"}
262
+ />
263
+ <button
264
+ type="button"
265
+ className="icon-btn password-toggle"
266
+ onClick={() => setShowPassword((v) => !v)}
267
+ aria-label={showPassword ? "Hide password" : "Show password"}
268
+ aria-pressed={showPassword}
269
+ title={showPassword ? "Hide password" : "Show password"}
270
+ >
271
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
272
+ <path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12z" />
273
+ <circle cx="12" cy="12" r="3" />
274
+ {showPassword && <path d="M4 20L20 4" />}
275
+ </svg>
276
+ </button>
277
+ </div>
278
+ {mode !== "signin" && <span className="muted small">At least {MIN_PASSWORD} characters.</span>}
279
  </label>
280
  )}
281
  {error && (
 
283
  {error}
284
  </p>
285
  )}
286
+ {codeDead ? (
287
+ <button type="button" className="btn btn-primary" disabled={busy} onClick={resend}>
288
+ {busy ? "Working..." : "Resend code"}
289
+ </button>
290
+ ) : (
291
+ <button type="submit" className="btn btn-primary" disabled={busy || passwordTooShort}>
292
+ {busy ? "Working..." : cta[mode]}
293
+ </button>
294
+ )}
295
  </form>
296
 
297
  <div className="auth-switch muted">
 
316
  </button>
317
  </span>
318
  )}
319
+ {(mode === "verify" || mode === "reset") &&
320
+ (codeDead ? (
321
+ <button type="button" className="link-btn" onClick={tryAnotherEmail}>
322
+ Try with another email
323
+ </button>
324
+ ) : (
325
+ <button type="button" className="link-btn" onClick={resend}>
326
+ Resend code
327
+ </button>
328
+ ))}
 
 
 
 
329
  {(mode === "forgot" || mode === "reset") && (
330
  <button type="button" className="link-btn" onClick={() => setMode("signin")}>
331
  Back to sign in
frontend/src/pages/Settings.tsx CHANGED
@@ -1,5 +1,4 @@
1
  import { useEffect, useState, type ReactNode } from "react";
2
- import { useNavigate } from "react-router-dom";
3
  import { authApi } from "../api/auth";
4
  import { tokens, uploadFile } from "../api/client";
5
  import { settingsApi, type SettingsPatch } from "../api/endpoints";
@@ -49,9 +48,8 @@ export default function Settings() {
49
  const [saved, setSaved] = useState<string | null>(null);
50
  const [error, setError] = useState<string | null>(null);
51
  const [sounds, setSounds] = useState(soundsEnabled);
52
- const { logout } = useAuth();
53
  const upgrade = useUpgrade();
54
- const navigate = useNavigate();
55
 
56
  const hydrate = (d: SettingsData) => {
57
  setS(d);
@@ -210,13 +208,7 @@ export default function Settings() {
210
 
211
  <Section title="Account">
212
  <div className="row">
213
- <button
214
- className="btn"
215
- onClick={async () => {
216
- await logout();
217
- navigate("/auth");
218
- }}
219
- >
220
  Sign out everywhere
221
  </button>
222
  <ConfirmButton
 
1
  import { useEffect, useState, type ReactNode } from "react";
 
2
  import { authApi } from "../api/auth";
3
  import { tokens, uploadFile } from "../api/client";
4
  import { settingsApi, type SettingsPatch } from "../api/endpoints";
 
48
  const [saved, setSaved] = useState<string | null>(null);
49
  const [error, setError] = useState<string | null>(null);
50
  const [sounds, setSounds] = useState(soundsEnabled);
51
+ const { logout, signingOut } = useAuth();
52
  const upgrade = useUpgrade();
 
53
 
54
  const hydrate = (d: SettingsData) => {
55
  setS(d);
 
208
 
209
  <Section title="Account">
210
  <div className="row">
211
+ <button className="btn" onClick={logout} disabled={signingOut}>
 
 
 
 
 
 
212
  Sign out everywhere
213
  </button>
214
  <ConfirmButton
frontend/src/pages/Welcome.tsx CHANGED
@@ -14,7 +14,7 @@ type Goal = "ask" | "audio" | "launch";
14
  type Mode = "feed" | "url" | "file";
15
 
16
  const MODES: { id: Mode; label: string }[] = [
17
- { id: "feed", label: "Blog or newsletter" },
18
  { id: "url", label: "One article" },
19
  { id: "file", label: "Upload files" },
20
  ];
@@ -210,7 +210,10 @@ export default function Welcome() {
210
  {["Connect", "Index", "Choose", "Launch"].map((label, i) => (
211
  <li key={label} className={i < stepIndex ? "done" : i === stepIndex ? "current" : ""}>
212
  <span className="welcome-dot" />
213
- {label}
 
 
 
214
  </li>
215
  ))}
216
  </ol>
 
14
  type Mode = "feed" | "url" | "file";
15
 
16
  const MODES: { id: Mode; label: string }[] = [
17
+ { id: "feed", label: "Blog or website" },
18
  { id: "url", label: "One article" },
19
  { id: "file", label: "Upload files" },
20
  ];
 
210
  {["Connect", "Index", "Choose", "Launch"].map((label, i) => (
211
  <li key={label} className={i < stepIndex ? "done" : i === stepIndex ? "current" : ""}>
212
  <span className="welcome-dot" />
213
+ <span>
214
+ <span className="welcome-step-num">{i + 1}</span>
215
+ {label}
216
+ </span>
217
  </li>
218
  ))}
219
  </ol>
frontend/src/styles/app.css CHANGED
@@ -382,6 +382,14 @@
382
  place-items: center;
383
  }
384
 
 
 
 
 
 
 
 
 
385
  /* ---------- Pricing ---------- */
386
  .pricing-note {
387
  display: flex;
@@ -696,6 +704,22 @@
696
  border-radius: var(--radius-sm);
697
  }
698
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
699
  .code-input {
700
  font-family: var(--font-mono);
701
  letter-spacing: 0.4em;
 
382
  place-items: center;
383
  }
384
 
385
+ /* Covers the app while sign out revokes the session. */
386
+ .signout-overlay {
387
+ position: fixed;
388
+ inset: 0;
389
+ z-index: 10000;
390
+ background: var(--black);
391
+ }
392
+
393
  /* ---------- Pricing ---------- */
394
  .pricing-note {
395
  display: flex;
 
704
  border-radius: var(--radius-sm);
705
  }
706
 
707
+ .password-field {
708
+ position: relative;
709
+ }
710
+
711
+ .password-field .input {
712
+ width: 100%;
713
+ padding-right: 48px;
714
+ }
715
+
716
+ .password-toggle {
717
+ position: absolute;
718
+ top: 50%;
719
+ right: 8px;
720
+ transform: translateY(-50%);
721
+ }
722
+
723
  .code-input {
724
  font-family: var(--font-mono);
725
  letter-spacing: 0.4em;
frontend/src/styles/pages.css CHANGED
@@ -1684,6 +1684,9 @@ select.input option {
1684
  padding: 32px var(--gutter) 64px;
1685
  display: grid;
1686
  gap: 24px;
 
 
 
1687
  }
1688
 
1689
  .welcome-top {
@@ -1708,6 +1711,11 @@ select.input option {
1708
  gap: 8px;
1709
  }
1710
 
 
 
 
 
 
1711
  .welcome-dot {
1712
  height: 3px;
1713
  border-radius: 999px;
 
1684
  padding: 32px var(--gutter) 64px;
1685
  display: grid;
1686
  gap: 24px;
1687
+ /* Sit the onboarding block in the vertical middle of the screen rather than pinned to the top. */
1688
+ min-height: 100vh;
1689
+ align-content: center;
1690
  }
1691
 
1692
  .welcome-top {
 
1711
  gap: 8px;
1712
  }
1713
 
1714
+ .welcome-step-num {
1715
+ margin-right: 8px;
1716
+ color: var(--blue);
1717
+ }
1718
+
1719
  .welcome-dot {
1720
  height: 3px;
1721
  border-radius: 999px;