Spaces:
Runtime error
Runtime error
Commit ·
1e1198f
1
Parent(s): bdd1640
login issue fixed
Browse files- backend/app/routers/auth.py +24 -10
- backend/app/services/email.py +9 -1
- backend/app/services/email_verification.py +15 -4
- backend/tests/test_auth.py +36 -0
- frontend/src/api/auth.ts +3 -1
- frontend/src/api/client.ts +9 -1
- frontend/src/components/AppShell.tsx +3 -6
- frontend/src/hooks/useAuth.tsx +48 -7
- frontend/src/pages/AuthPage.tsx +80 -27
- frontend/src/pages/Settings.tsx +2 -10
- frontend/src/pages/Welcome.tsx +5 -2
- frontend/src/styles/app.css +24 -0
- frontend/src/styles/pages.css +8 -0
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 |
-
#
|
| 342 |
-
if user
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 37 |
} finally {
|
| 38 |
-
|
| 39 |
-
|
| 40 |
setUser(null);
|
|
|
|
| 41 |
}
|
| 42 |
-
}, []);
|
| 43 |
|
| 44 |
-
const value = useMemo(
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(`
|
| 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 |
-
<
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
</label>
|
| 229 |
)}
|
| 230 |
{error && (
|
|
@@ -232,9 +283,15 @@ export default function AuthPage() {
|
|
| 232 |
{error}
|
| 233 |
</p>
|
| 234 |
)}
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 264 |
-
type="button"
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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;
|