diff --git a/Cargo.lock b/Cargo.lock index 4b5c924..e9aa1ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -251,9 +251,18 @@ name = "auth-api" version = "0.2.0" dependencies = [ "axum", + "axum-extra", "edb", + "getrandom 0.4.3", + "http 1.5.0", "runtime", + "serde", + "sqlx", + "thiserror 2.0.21", + "time", "tokio", + "tower", + "tower-http 0.7.1", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index df5a075..92f6162 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ time = "0.3.55" url = "2.5.8" bytes = "1.12.1" sha2 = "0.11.1" +getrandom = "0.4.3" # aws-lc-rs: the rustls provider reqwest already uses. sqlx = { version = "0.9.0", default-features = false, features = [ "macros", diff --git a/apps/auth-api/.env.example b/apps/auth-api/.env.example index de050fa..7ba0f98 100644 --- a/apps/auth-api/.env.example +++ b/apps/auth-api/.env.example @@ -11,3 +11,6 @@ PLATFORM_NETWORK=platform # For Hosted Postgres: append `?sslmode=verify-full`. EDB_DATABASE_URL=postgres://aapi:@127.0.0.1:15432/examiner + +COOKIE_KEY= +WEB_APP_ORIGINS=http://127.0.0.1:13003,http://localhost:13003 diff --git a/apps/auth-api/Cargo.toml b/apps/auth-api/Cargo.toml index 9f343ca..a6f33c1 100644 --- a/apps/auth-api/Cargo.toml +++ b/apps/auth-api/Cargo.toml @@ -7,10 +7,21 @@ publish.workspace = true [dependencies] axum.workspace = true +axum-extra.workspace = true edb.workspace = true +getrandom.workspace = true +http.workspace = true +runtime.workspace = true +serde.workspace = true +sqlx.workspace = true +thiserror.workspace = true +time.workspace = true tokio.workspace = true +tower-http = { workspace = true, features = ["cors"] } tracing.workspace = true -runtime.workspace = true + +[dev-dependencies] +tower = { version = "0.5.3", features = ["util"] } [lints] workspace = true diff --git a/apps/auth-api/Dockerfile b/apps/auth-api/Dockerfile index 8ba07c8..b542dbe 100644 --- a/apps/auth-api/Dockerfile +++ b/apps/auth-api/Dockerfile @@ -11,10 +11,12 @@ COPY libs libs COPY databases/curriculum databases/curriculum COPY databases/examiner databases/examiner ENV SQLX_OFFLINE=true +ARG CARGO_PROFILE=release RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/src/target \ - cargo build --locked --release -p auth-api -p edb \ - && cp target/release/auth-api target/release/edb-migrate /usr/local/bin/ + cargo build --locked --profile "$CARGO_PROFILE" -p auth-api -p edb \ + && out="target/$([ "$CARGO_PROFILE" = dev ] && echo debug || echo "$CARGO_PROFILE")" \ + && cp "$out/auth-api" "$out/edb-migrate" /usr/local/bin/ FROM debian:trixie-slim RUN apt-get update \ diff --git a/apps/auth-api/compose.yaml b/apps/auth-api/compose.yaml index 58a55ec..56100ce 100644 --- a/apps/auth-api/compose.yaml +++ b/apps/auth-api/compose.yaml @@ -17,6 +17,8 @@ services: SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-0.2} RUST_LOG: ${RUST_LOG:-info} EDB_DATABASE_URL: ${EDB_DATABASE_URL:?set EDB_DATABASE_URL} + COOKIE_KEY: ${COOKIE_KEY:?set COOKIE_KEY} + WEB_APP_ORIGINS: ${WEB_APP_ORIGINS:?set WEB_APP_ORIGINS} networks: platform: aliases: [auth-api] diff --git a/apps/auth-api/src/app.rs b/apps/auth-api/src/app.rs index a813672..e694319 100644 --- a/apps/auth-api/src/app.rs +++ b/apps/auth-api/src/app.rs @@ -1,7 +1,10 @@ use axum::{Router, routing::get}; +use axum_extra::extract::cookie::Key; use edb::Database; +use http::{Method, header::CONTENT_TYPE}; +use tower_http::cors::CorsLayer; -use crate::config::AppConfig; +use crate::config::{AppConfig, ServerState}; use crate::routes; pub async fn app(config: &AppConfig) -> Router { @@ -9,9 +12,114 @@ pub async fn app(config: &AppConfig) -> Router { .await .expect("failed to connect to eDb"); - let api = Router::new().with_state(database); + router(ServerState { + config: config.clone(), + database, + cookie_key: Key::from(config.cookie_key.as_bytes()), + }) +} + +pub fn router(server_state: ServerState) -> Router { + let cors = CorsLayer::new() + .allow_origin(server_state.config.web_app_origins.clone()) + .allow_methods([Method::GET, Method::POST, Method::DELETE]) + .allow_headers([CONTENT_TYPE]) + .allow_credentials(true); + + let api = Router::new() + .nest("/auth", routes::auth::router(server_state.config.mock_auth)) + .layer(cors) + .with_state(server_state); runtime::instrument(api) // After instrumentation: probes stay out of request traces and Sentry. .route("/healthz", get(routes::get_healthz)) } + +#[cfg(test)] +mod tests { + use axum::body::Body; + use http::{ + HeaderValue, Request, StatusCode, + header::{ + ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_ORIGIN, + ACCESS_CONTROL_REQUEST_METHOD, ORIGIN, + }, + }; + use tower::ServiceExt; + + use super::*; + use crate::testing::{WEB_APP_ORIGINS, server_state}; + + fn preflight(origin: &str) -> Request { + Request::options("/auth/session") + .header(ORIGIN, origin) + .header(ACCESS_CONTROL_REQUEST_METHOD, "GET") + .body(Body::empty()) + .expect("valid request") + } + + fn dev_login(email: &str) -> Request { + Request::post("/auth/dev/login") + .header(CONTENT_TYPE, "application/json") + .body(Body::from(format!( + r#"{{"name":"Camper","email":"{email}"}}"# + ))) + .expect("valid request") + } + + #[tokio::test] + async fn allows_credentialed_requests_from_every_web_app_origin() { + for origin in WEB_APP_ORIGINS { + let response = router(server_state(false)) + .oneshot(preflight(origin)) + .await + .expect("infallible"); + + let headers = response.headers(); + assert_eq!( + headers.get(ACCESS_CONTROL_ALLOW_ORIGIN), + Some(&HeaderValue::from_static(origin)) + ); + assert_eq!( + headers.get(ACCESS_CONTROL_ALLOW_CREDENTIALS), + Some(&HeaderValue::from_static("true")) + ); + } + } + + #[tokio::test] + async fn rejects_other_origins() { + let response = router(server_state(false)) + .oneshot(preflight("https://evil.example")) + .await + .expect("infallible"); + + assert!( + response + .headers() + .get(ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none() + ); + } + + #[tokio::test] + async fn dev_login_needs_mock_auth() { + let response = router(server_state(false)) + .oneshot(dev_login("camper@example.com")) + .await + .expect("infallible"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn dev_login_rejects_invalid_email() { + let response = router(server_state(true)) + .oneshot(dev_login("camper")) + .await + .expect("infallible"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/apps/auth-api/src/config.rs b/apps/auth-api/src/config.rs index 14ef2b2..8b0e14f 100644 --- a/apps/auth-api/src/config.rs +++ b/apps/auth-api/src/config.rs @@ -1,21 +1,133 @@ use std::env::var; +use axum::extract::FromRef; +use axum_extra::extract::cookie::Key; +use edb::Database; +use http::HeaderValue; + +#[derive(Clone)] +pub struct ServerState { + pub config: AppConfig, + pub database: Database, + pub cookie_key: Key, +} + +impl FromRef for Key { + fn from_ref(state: &ServerState) -> Self { + state.cookie_key.clone() + } +} + #[derive(Debug, Clone)] pub struct AppConfig { - /// eDb connection string. For hosted Postgres ensure `?sslmode=verify-full`. + pub cookie_key: String, pub edb_database_url: String, + pub mock_auth: bool, + pub web_app_origins: Vec, +} + +fn parse_origins(value: &str) -> Result, String> { + let origins = value + .split(',') + .map(str::trim) + .filter(|origin| !origin.is_empty()) + .map(|origin| { + if origin.ends_with('/') { + return Err(format!("{origin} must not end with /")); + } + HeaderValue::from_str(origin).map_err(|e| format!("{origin}: {e}")) + }) + .collect::, _>>()?; + + if origins.is_empty() { + return Err("no origins".to_string()); + } + Ok(origins) +} + +fn required_env(name: &str) -> String { + runtime::var(name).unwrap_or_else(|| { + tracing::error!("{name} not set"); + panic!("{name} required"); + }) +} + +fn env_or_mock_default(name: &str, default: &str, mock_auth: bool) -> String { + match runtime::var(name) { + Some(v) => v, + None if mock_auth => default.to_string(), + None => required_env(name), + } } impl AppConfig { pub fn new() -> Self { - let edb_database_url = match var("EDB_DATABASE_URL") { - Ok(v) if !v.is_empty() => v, - _ => { - tracing::error!("EDB_DATABASE_URL not set"); - panic!("EDB_DATABASE_URL required"); + let mock_auth = match var("MOCK_AUTH") { + Ok(v) => { + let mock_auth = v.parse().unwrap_or(false); + if cfg!(not(debug_assertions)) && mock_auth { + panic!("MOCK_AUTH is not allowed to be set to 'true' outside of a debug build"); + } + + tracing::warn!("MOCK_AUTH explicitly set to {mock_auth}"); + mock_auth } + Err(_e) => false, }; - Self { edb_database_url } + let cookie_key = env_or_mock_default("COOKIE_KEY", &"test-dev-string".repeat(5), mock_auth); + + assert!( + cookie_key.len() >= 64, + "COOKIE_KEY must be at least 64 bytes" + ); + + let edb_database_url = required_env("EDB_DATABASE_URL"); + + let web_app_origins = env_or_mock_default( + "WEB_APP_ORIGINS", + "http://127.0.0.1:13003,http://localhost:13003", + mock_auth, + ); + let web_app_origins = parse_origins(&web_app_origins).unwrap_or_else(|e| { + tracing::error!("WEB_APP_ORIGINS invalid: {e}"); + panic!("WEB_APP_ORIGINS invalid: {e}"); + }); + + Self { + cookie_key, + edb_database_url, + mock_auth, + web_app_origins, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_comma_separated_origins() { + let origins = + parse_origins("http://127.0.0.1:13003, http://localhost:13003,").expect("valid"); + + assert_eq!( + origins, + [ + HeaderValue::from_static("http://127.0.0.1:13003"), + HeaderValue::from_static("http://localhost:13003"), + ] + ); + } + + #[test] + fn rejects_empty_origins() { + assert!(parse_origins(" , ").is_err()); + } + + #[test] + fn rejects_origins_with_a_path() { + assert!(parse_origins("https://web-app.freecodecamp.dev/").is_err()); } } diff --git a/apps/auth-api/src/error.rs b/apps/auth-api/src/error.rs new file mode 100644 index 0000000..a76ad44 --- /dev/null +++ b/apps/auth-api/src/error.rs @@ -0,0 +1,40 @@ +use axum::response::{IntoResponse, Response}; +use http::StatusCode; + +#[derive(thiserror::Error, Debug)] +pub enum Error { + #[error("{1}")] + Server(StatusCode, String), + #[error("{0}")] + Sqlx(#[from] sqlx::Error), + #[error("{0}")] + TimeConversion(#[from] time::error::ConversionRange), +} + +impl Error { + pub fn bad_request(message: impl Into) -> Self { + Self::Server(StatusCode::BAD_REQUEST, message.into()) + } + + pub fn unauthorized(message: impl Into) -> Self { + Self::Server(StatusCode::UNAUTHORIZED, message.into()) + } +} + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let msg = self.to_string(); + let status: StatusCode = self.into(); + + (status, msg).into_response() + } +} + +impl From for StatusCode { + fn from(error: Error) -> Self { + match error { + Error::Server(c, _) => c, + _ => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} diff --git a/apps/auth-api/src/extractor.rs b/apps/auth-api/src/extractor.rs new file mode 100644 index 0000000..2a2b8cc --- /dev/null +++ b/apps/auth-api/src/extractor.rs @@ -0,0 +1,76 @@ +use axum::{extract::FromRequestParts, http::request::Parts}; +use axum_extra::extract::{PrivateCookieJar, cookie::Key}; +use edb::Candidate; + +use crate::{config::ServerState, error::Error, routes::auth::SESSION_COOKIE}; + +#[derive(Debug, Clone)] +pub struct AuthCandidate(pub Candidate); + +impl FromRequestParts for AuthCandidate { + type Rejection = Error; + + async fn from_request_parts( + parts: &mut Parts, + state: &ServerState, + ) -> Result { + let jar = PrivateCookieJar::::from_request_parts(parts, state) + .await + .unwrap_or_else(|never| match never {}); + + let session_id = jar + .get(SESSION_COOKIE) + .ok_or_else(|| Error::unauthorized("no session"))?; + let session = state + .database + .find_candidate_session(session_id.value()) + .await? + .ok_or_else(|| Error::unauthorized("unknown or expired session"))?; + let candidate = state + .database + .find_candidate_by_id(session.candidate_id) + .await? + .ok_or_else(|| Error::unauthorized("no candidate for session"))?; + + Ok(AuthCandidate(candidate)) + } +} + +#[cfg(test)] +mod tests { + use axum::{Router, body::Body, routing::get}; + use axum_extra::extract::cookie::Cookie; + use http::{Request, StatusCode, header::COOKIE}; + use tower::ServiceExt; + + use super::*; + use crate::testing::server_state; + + async fn status(request: Request) -> StatusCode { + let app = Router::new() + .route("/", get(|_: AuthCandidate| async { "ok" })) + .with_state(server_state(false)); + + app.oneshot(request).await.expect("infallible").status() + } + + #[tokio::test] + async fn rejects_missing_session_cookie() { + let request = Request::get("/") + .body(Body::empty()) + .expect("valid request"); + + assert_eq!(status(request).await, StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn rejects_unencrypted_session_cookie() { + let cookie = Cookie::new(SESSION_COOKIE, "forged"); + let request = Request::get("/") + .header(COOKIE, cookie.to_string()) + .body(Body::empty()) + .expect("valid request"); + + assert_eq!(status(request).await, StatusCode::UNAUTHORIZED); + } +} diff --git a/apps/auth-api/src/main.rs b/apps/auth-api/src/main.rs index 6ce46cf..70d329a 100644 --- a/apps/auth-api/src/main.rs +++ b/apps/auth-api/src/main.rs @@ -1,7 +1,11 @@ //! aAPI: candidate identity and the attempt path. The only application candidate devices reach. mod app; mod config; +mod error; +mod extractor; mod routes; +#[cfg(test)] +mod testing; use runtime::{Exit, ServerConfig}; diff --git a/apps/auth-api/src/routes/auth.rs b/apps/auth-api/src/routes/auth.rs new file mode 100644 index 0000000..ce77f4d --- /dev/null +++ b/apps/auth-api/src/routes/auth.rs @@ -0,0 +1,177 @@ +use std::time::Duration; + +use axum::{ + Json, Router, + extract::State, + http::header::{CACHE_CONTROL, HeaderValue}, + response::IntoResponse, + routing::{delete, get, post}, +}; +use axum_extra::extract::{ + PrivateCookieJar, + cookie::{Cookie, SameSite}, +}; +use edb::{Candidate, CandidateSession, NewCandidate}; +use http::StatusCode; +use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; +use tracing::instrument; + +use crate::{config::ServerState, error::Error, extractor::AuthCandidate}; + +pub const SESSION_COOKIE: &str = "candidate_sid"; +const SESSION_TTL: Duration = Duration::from_secs(60 * 60); + +pub fn router(mock_auth: bool) -> Router { + let router = Router::new() + .route("/session", get(get_session)) + .route("/logout", delete(delete_logout)); + + if mock_auth { + router.route("/dev/login", post(post_dev_login)) + } else { + router + } +} + +fn new_session_id() -> String { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).expect("OS random source unavailable"); + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn is_email(email: &str) -> bool { + email + .split_once('@') + .is_some_and(|(local, domain)| !local.is_empty() && !domain.is_empty()) +} + +async fn create_session( + server_state: &ServerState, + jar: PrivateCookieJar, + candidate: &Candidate, + expires_in: Duration, +) -> Result { + let session = CandidateSession { + session_id: new_session_id(), + candidate_id: candidate.id, + expires_at: OffsetDateTime::now_utc() + expires_in, + }; + + server_state + .database + .insert_candidate_session(&session) + .await?; + + let cookie = Cookie::build((SESSION_COOKIE, session.session_id)) + .path("/") + .secure(true) + .http_only(true) + .same_site(SameSite::Lax) + .max_age(expires_in.try_into()?); + + Ok(jar.add(cookie)) +} + +#[derive(Debug, Serialize)] +pub struct SessionCandidate { + pub id: String, + pub name: String, + pub email: String, +} + +impl From for SessionCandidate { + fn from(candidate: Candidate) -> Self { + Self { + id: candidate.id.to_string(), + name: candidate.name, + email: candidate.email, + } + } +} + +#[instrument(skip_all, level = "debug")] +pub async fn get_session(AuthCandidate(candidate): AuthCandidate) -> impl IntoResponse { + ( + [(CACHE_CONTROL, HeaderValue::from_static("no-store"))], + Json(SessionCandidate::from(candidate)), + ) +} + +#[instrument(skip_all, err(Debug), level = "debug")] +pub async fn delete_logout( + AuthCandidate(candidate): AuthCandidate, + jar: PrivateCookieJar, + State(server_state): State, +) -> Result<(PrivateCookieJar, StatusCode), Error> { + server_state + .database + .delete_candidate_sessions(candidate.id) + .await?; + + Ok(( + jar.remove(Cookie::build(SESSION_COOKIE).path("/")), + StatusCode::NO_CONTENT, + )) +} + +#[derive(Deserialize)] +pub struct DevLoginBody { + pub name: String, + pub email: String, +} + +#[instrument(skip_all, err(Debug), level = "debug")] +pub async fn post_dev_login( + jar: PrivateCookieJar, + State(server_state): State, + Json(body): Json, +) -> Result { + if !is_email(&body.email) { + return Err(Error::bad_request("invalid email")); + } + + let candidate = match server_state + .database + .find_candidate_by_email(&body.email) + .await? + { + Some(candidate) => candidate, + None => { + server_state + .database + .insert_candidate(NewCandidate { + name: body.name, + email: body.email, + }) + .await? + } + }; + + let jar = create_session(&server_state, jar, &candidate, SESSION_TTL).await?; + + Ok((jar, Json(SessionCandidate::from(candidate)))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_ids_are_random_hex() { + let a = new_session_id(); + let b = new_session_id(); + + assert_eq!(a.len(), 64); + assert!(a.bytes().all(|c| c.is_ascii_hexdigit())); + assert_ne!(a, b); + } + + #[test] + fn validates_email_shape() { + assert!(is_email("camper@example.com")); + assert!(!is_email("camper")); + assert!(!is_email("@example.com")); + assert!(!is_email("camper@")); + } +} diff --git a/apps/auth-api/src/routes/mod.rs b/apps/auth-api/src/routes/mod.rs index c7728b5..e17c910 100644 --- a/apps/auth-api/src/routes/mod.rs +++ b/apps/auth-api/src/routes/mod.rs @@ -1,6 +1,8 @@ use axum::http::header::{CACHE_CONTROL, HeaderValue}; use axum::response::IntoResponse; +pub mod auth; + pub async fn get_healthz() -> impl IntoResponse { ( [(CACHE_CONTROL, HeaderValue::from_static("no-store"))], diff --git a/apps/auth-api/src/testing.rs b/apps/auth-api/src/testing.rs new file mode 100644 index 0000000..5941815 --- /dev/null +++ b/apps/auth-api/src/testing.rs @@ -0,0 +1,22 @@ +use axum_extra::extract::cookie::Key; +use http::HeaderValue; + +use crate::config::{AppConfig, ServerState}; + +pub const WEB_APP_ORIGINS: [&str; 2] = ["http://127.0.0.1:13003", "http://localhost:13003"]; + +pub fn server_state(mock_auth: bool) -> ServerState { + let config = AppConfig { + cookie_key: "k".repeat(64), + edb_database_url: "postgres://unused@127.0.0.1:1/unused".to_string(), + mock_auth, + web_app_origins: WEB_APP_ORIGINS.map(HeaderValue::from_static).to_vec(), + }; + let pool = sqlx::PgPool::connect_lazy(&config.edb_database_url).expect("valid URL"); + + ServerState { + database: edb::Database::from_pool(pool), + cookie_key: Key::from(config.cookie_key.as_bytes()), + config, + } +} diff --git a/apps/examiner-dashboard/server/routes/auth/mod.rs b/apps/examiner-dashboard/server/routes/auth/mod.rs index f09820a..9890bc1 100644 --- a/apps/examiner-dashboard/server/routes/auth/mod.rs +++ b/apps/examiner-dashboard/server/routes/auth/mod.rs @@ -24,7 +24,7 @@ pub mod google; pub const ALLOWED_EMAIL_DOMAIN: &str = "freecodecamp.org"; const SESSION_TTL: Duration = Duration::from_secs(60 * 60); -pub const SESSION_COOKIE: &str = "sid"; +pub const SESSION_COOKIE: &str = "examiner_sid"; pub fn router(mock_auth: bool) -> Router { let router = Router::new() diff --git a/apps/web-app/.lintstagedrc.yaml b/apps/web-app/.lintstagedrc.yaml index 0a2ae42..3b14089 100644 --- a/apps/web-app/.lintstagedrc.yaml +++ b/apps/web-app/.lintstagedrc.yaml @@ -1 +1,4 @@ -"*": bun run fmt -- --no-error-on-unmatched-pattern +"*.{ts,tsx}": + - bun run lint -- + - bun run fmt -- --no-error-on-unmatched-pattern +"!(*.ts|*.tsx)": bun run fmt -- --no-error-on-unmatched-pattern diff --git a/apps/web-app/.oxfmtrc.json b/apps/web-app/.oxfmtrc.json new file mode 100644 index 0000000..684e35d --- /dev/null +++ b/apps/web-app/.oxfmtrc.json @@ -0,0 +1,5 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": ["dist/**", ".astro/**", "CHANGELOG.md"], + "astro": true +} diff --git a/apps/web-app/.oxlintrc.json b/apps/web-app/.oxlintrc.json new file mode 100644 index 0000000..d4671d6 --- /dev/null +++ b/apps/web-app/.oxlintrc.json @@ -0,0 +1,26 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "extends": [ + "./node_modules/oxlint-config-presets/@eslint/recommended.json", + "./node_modules/oxlint-config-presets/@typescript-eslint/recommended-type-checked.json", + "./node_modules/oxlint-config-presets/react/recommended.json", + "./node_modules/oxlint-config-presets/react/jsx-runtime.json" + ], + "plugins": ["typescript", "react"], + "ignorePatterns": ["**/*.astro", "dist/**", ".astro/**"], + "settings": { + "react": { + "version": "19" + } + }, + "rules": { + "typescript/no-misused-promises": [ + "error", + { + "checksVoidReturn": { + "attributes": false + } + } + ] + } +} diff --git a/apps/web-app/Caddyfile b/apps/web-app/Caddyfile index e3d5f35..5f13b65 100644 --- a/apps/web-app/Caddyfile +++ b/apps/web-app/Caddyfile @@ -22,6 +22,18 @@ handle { root * /srv encode zstd gzip + + @exam path_regexp ^/exams/[^/]+/?$ + rewrite @exam /exams/_/index.html + @profile path_regexp ^/users/[^/]+/profile/?$ + rewrite @profile /users/_/profile/index.html + @certifications path_regexp ^/users/[^/]+/certifications/?$ + rewrite @certifications /users/_/certifications/index.html + @certification path_regexp ^/users/[^/]+/certifications/[^/]+/?$ + rewrite @certification /users/_/certifications/_/index.html + @attempt path_regexp ^/attempts/[^/]+/?$ + rewrite @attempt /attempts/_/index.html + # Content-hashed by Astro: safe to cache forever. header /_astro/* Cache-Control "public, max-age=31536000, immutable" file_server diff --git a/apps/web-app/astro.config.ts b/apps/web-app/astro.config.ts index 4bbfce1..c4218fd 100644 --- a/apps/web-app/astro.config.ts +++ b/apps/web-app/astro.config.ts @@ -3,6 +3,7 @@ import { fileURLToPath } from "node:url"; import type { AstroIntegration } from "astro"; import { defineConfig } from "astro/config"; import react from "@astrojs/react"; +import { DYNAMIC_ROUTES, matchPath, placeholderPath } from "./src/utils/routes"; // Dev stand-in for the image's Caddyfile, which serves /config.js from the container's env. function runtimeConfig(): AstroIntegration { @@ -23,9 +24,25 @@ function runtimeConfig(): AstroIntegration { }; } +function dynamicRoutes(): AstroIntegration { + return { + name: "dynamic-routes", + hooks: { + "astro:server:setup": ({ server }) => { + server.middlewares.use((req, _res, next) => { + const url = new URL(req.url ?? "/", "http://localhost"); + const pattern = DYNAMIC_ROUTES.find((p) => matchPath(p, url.pathname)); + if (pattern) req.url = placeholderPath(pattern) + url.search; + next(); + }); + }, + }, + }; +} + // Static MPA: every page prerenders to HTML; React islands hydrate where interactive. // Origins are runtime config (/config.js), not build env: one image serves every environment. export default defineConfig({ output: "static", - integrations: [react(), runtimeConfig()], + integrations: [react(), runtimeConfig(), dynamicRoutes()], }); diff --git a/apps/web-app/package.json b/apps/web-app/package.json index 022efe9..0996a17 100644 --- a/apps/web-app/package.json +++ b/apps/web-app/package.json @@ -6,24 +6,38 @@ "scripts": { "dev": "astro dev --host 127.0.0.1 --port 13003", "build": "astro check && astro build", + "check": "astro check", "fmt": "oxfmt", + "lint": "oxlint", "preview": "astro preview --host 127.0.0.1 --port 13003", - "check": "astro check" + "test": "vitest" }, "dependencies": { "@astrojs/react": "catalog:astro", + "@chakra-ui/react": "catalog:astro", + "@emotion/react": "catalog:astro", "@tanstack/react-query": "catalog:", "astro": "catalog:astro", + "next-themes": "catalog:astro", "react": "catalog:", - "react-dom": "catalog:" + "react-dom": "catalog:", + "react-icons": "catalog:astro" }, "devDependencies": { "@astrojs/check": "catalog:astro", "@tanstack/react-query-devtools": "catalog:", + "@testing-library/dom": "catalog:astro", + "@testing-library/jest-dom": "catalog:astro", + "@testing-library/react": "catalog:astro", "@types/bun": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "jsdom": "catalog:astro", "oxfmt": "catalog:", - "typescript": "catalog:astro" + "oxlint": "catalog:", + "oxlint-config-presets": "catalog:", + "oxlint-tsgolint": "catalog:", + "typescript": "catalog:astro", + "vitest": "catalog:" } } diff --git a/apps/web-app/public/favicon.ico b/apps/web-app/public/favicon.ico new file mode 100644 index 0000000..94d9c3a Binary files /dev/null and b/apps/web-app/public/favicon.ico differ diff --git a/apps/web-app/src/components/app-root.tsx b/apps/web-app/src/components/app-root.tsx new file mode 100644 index 0000000..7658263 --- /dev/null +++ b/apps/web-app/src/components/app-root.tsx @@ -0,0 +1,18 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { useState, type ReactNode } from "react"; +import { ChakraUiProvider } from "@/components/ui/chakra-ui-provider"; +import { AuthProvider } from "@/contexts/auth"; + +// Each page is its own React island, so every view wraps itself in this. +export function AppRoot({ children }: { children: ReactNode }) { + const [queryClient] = useState(() => new QueryClient()); + return ( + + + {children} + + + + ); +} diff --git a/apps/web-app/src/components/back-link.tsx b/apps/web-app/src/components/back-link.tsx new file mode 100644 index 0000000..77dad23 --- /dev/null +++ b/apps/web-app/src/components/back-link.tsx @@ -0,0 +1,12 @@ +import { IconButton } from "@chakra-ui/react"; +import { LuArrowLeft } from "react-icons/lu"; + +export function BackLink({ href, label }: { href: string; label: string }) { + return ( + + + + + + ); +} diff --git a/apps/web-app/src/components/certification-card.tsx b/apps/web-app/src/components/certification-card.tsx new file mode 100644 index 0000000..5668ba3 --- /dev/null +++ b/apps/web-app/src/components/certification-card.tsx @@ -0,0 +1,65 @@ +import { Center, Flex, Heading, HStack, IconButton } from "@chakra-ui/react"; +import { LuAward, LuCalendar, LuLink, LuTarget } from "react-icons/lu"; + +import { MetaItem } from "@/components/meta-item"; +import { buildPath, ROUTES } from "@/utils/routes"; +import type { Certification } from "@/utils/types"; + +export function certificationUrl(userId: string, certificationId: string): string { + return new URL( + buildPath(ROUTES.certification, { userId, certificationId }), + window.location.origin, + ).href; +} + +export function CertificationCard({ + userId, + certification, +}: { + userId: string; + certification: Certification; +}) { + return ( + + +
+ +
+ + + {certification.name} + + +
+ + + + {certification.issuedAt} + + + {certification.scorePercent}% + + + + void navigator.clipboard.writeText(certificationUrl(userId, certification.id)) + } + > + + + +
+ ); +} diff --git a/apps/web-app/src/components/empty-state.tsx b/apps/web-app/src/components/empty-state.tsx new file mode 100644 index 0000000..a244bb9 --- /dev/null +++ b/apps/web-app/src/components/empty-state.tsx @@ -0,0 +1,27 @@ +import { EmptyState as ChakraEmptyState, VStack } from "@chakra-ui/react"; +import type { ReactNode } from "react"; +import type { IconType } from "react-icons"; + +export function EmptyState({ + icon: Icon, + title, + children, +}: { + icon: IconType; + title: string; + children?: ReactNode; +}) { + return ( + + + + + + + {title} + + {children} + + + ); +} diff --git a/apps/web-app/src/components/exam-card.tsx b/apps/web-app/src/components/exam-card.tsx new file mode 100644 index 0000000..1e558fd --- /dev/null +++ b/apps/web-app/src/components/exam-card.tsx @@ -0,0 +1,99 @@ +import { Center, Flex, Heading, HStack } from "@chakra-ui/react"; +import type { IconType } from "react-icons"; +import { + LuArrowRight, + LuAward, + LuCalendarCheck, + LuCircleHelp, + LuClock, + LuLock, + LuTarget, +} from "react-icons/lu"; + +import { MetaItem } from "@/components/meta-item"; +import { buildPath, ROUTES } from "@/utils/routes"; +import type { ExamStatus, ExamSummary } from "@/utils/types"; + +const STATUS_BADGES: Partial< + Record +> = { + passed: { label: "Passed", icon: LuAward, bg: "positive.subtle", color: "positive.fg" }, + registered: { + label: "Registered", + icon: LuCalendarCheck, + bg: "notice.subtle", + color: "notice.fg", + }, + locked: { label: "Prerequisites needed", icon: LuLock, bg: "bg.muted", color: "fg.muted" }, +}; + +export function StatusBadge({ status }: { status: ExamStatus }) { + const badge = STATUS_BADGES[status]; + if (!badge) return null; + const { label, icon: Icon, bg, color } = badge; + + return ( +
+ +
+ ); +} + +export function ExamCard({ exam }: { exam: ExamSummary }) { + return ( + + + + + {exam.name} + + + + + + + {exam.durationMinutes} min + + + {exam.questionCount} + + + {exam.passPercent}% + + + + + + + + + ); +} diff --git a/apps/web-app/src/components/fcc-wordmark.tsx b/apps/web-app/src/components/fcc-wordmark.tsx new file mode 100644 index 0000000..e460be7 --- /dev/null +++ b/apps/web-app/src/components/fcc-wordmark.tsx @@ -0,0 +1,23 @@ +import type { SVGProps } from "react"; + +export function FccWordmark(props: SVGProps) { + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web-app/src/components/icon-field.tsx b/apps/web-app/src/components/icon-field.tsx new file mode 100644 index 0000000..052c870 --- /dev/null +++ b/apps/web-app/src/components/icon-field.tsx @@ -0,0 +1,35 @@ +import { Input, InputGroup, VisuallyHidden, type InputProps } from "@chakra-ui/react"; +import type { ReactNode } from "react"; +import type { IconType } from "react-icons"; + +export function IconField({ + id, + label, + icon: Icon, + endElement, + ...inputProps +}: InputProps & { id: string; label: string; icon: IconType; endElement?: ReactNode }) { + return ( + + + {label} + + } + startElementProps={{ color: "fg.muted", pointerEvents: "auto", w: "12" }} + endElement={endElement} + > + + + ); +} diff --git a/apps/web-app/src/components/meta-item.tsx b/apps/web-app/src/components/meta-item.tsx new file mode 100644 index 0000000..634d391 --- /dev/null +++ b/apps/web-app/src/components/meta-item.tsx @@ -0,0 +1,21 @@ +import { HStack, VisuallyHidden } from "@chakra-ui/react"; +import type { ReactNode } from "react"; +import type { IconType } from "react-icons"; + +export function MetaItem({ + icon: Icon, + label, + children, +}: { + icon: IconType; + label: string; + children: ReactNode; +}) { + return ( + + + {label}: + {children} + + ); +} diff --git a/apps/web-app/src/components/page-shell.test.tsx b/apps/web-app/src/components/page-shell.test.tsx new file mode 100644 index 0000000..723d983 --- /dev/null +++ b/apps/web-app/src/components/page-shell.test.tsx @@ -0,0 +1,54 @@ +/* @vitest-environment jsdom */ +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; + +import { AuthContext } from "@/contexts/auth"; +import type { SessionUser } from "@/utils/fetch"; +import { render } from "../../test-utils/render"; +import { camperbot } from "../../test-utils/session"; +import { PageShell } from "./page-shell"; + +function renderShell(user: SessionUser | null, path: string) { + return render( + + {null} + , + ); +} + +afterEach(cleanup); + +describe("PageShell", () => { + test("links logged out users to sign in only", () => { + const screen = renderShell(null, "/"); + + expect(screen.getByRole("link", { name: "Sign in" })).toHaveAttribute("href", "/login"); + expect(screen.getByRole("link", { name: "Exam Platform home" })).toHaveAttribute("href", "/"); + expect(screen.queryByRole("link", { name: "Exams" })).not.toBeInTheDocument(); + }); + + test("links logged in users to their own pages", () => { + const screen = renderShell(camperbot, "/"); + + expect(screen.getByRole("link", { name: "Exams" })).toHaveAttribute("href", "/exams"); + expect(screen.getByRole("link", { name: "Profile" })).toHaveAttribute( + "href", + "/users/camperbot-id/profile", + ); + expect(screen.getByRole("link", { name: "Certifications" })).toHaveAttribute( + "href", + "/users/camperbot-id/certifications", + ); + expect(screen.queryByRole("link", { name: "Sign in" })).not.toBeInTheDocument(); + }); + + test("marks the section of a nested page current", () => { + const screen = renderShell(camperbot, "/users/camperbot-id/certifications/c1"); + + expect(screen.getByRole("link", { name: "Certifications" })).toHaveAttribute( + "aria-current", + "page", + ); + expect(screen.getByRole("link", { name: "Exams" })).not.toHaveAttribute("aria-current"); + }); +}); diff --git a/apps/web-app/src/components/page-shell.tsx b/apps/web-app/src/components/page-shell.tsx new file mode 100644 index 0000000..15c11c1 --- /dev/null +++ b/apps/web-app/src/components/page-shell.tsx @@ -0,0 +1,48 @@ +import { Box } from "@chakra-ui/react"; +import type { ReactNode } from "react"; +import { LuAward, LuClipboardList, LuLogIn, LuUserRound } from "react-icons/lu"; + +import { SiteHeader, type NavItem } from "@/components/site-header"; +import { useAuth } from "@/contexts/auth"; +import type { SessionUser } from "@/utils/fetch"; +import { buildPath, ROUTES } from "@/utils/routes"; + +function navItems(user: SessionUser | null): NavItem[] { + if (!user) { + return [{ href: ROUTES.login, label: "Sign in", icon: LuLogIn }]; + } + + const userId = user.id; + return [ + { href: ROUTES.exams, label: "Exams", icon: LuClipboardList }, + { + href: buildPath(ROUTES.certifications, { userId }), + label: "Certifications", + icon: LuAward, + }, + { href: buildPath(ROUTES.profile, { userId }), label: "Profile", icon: LuUserRound }, + ]; +} + +export function PageShell({ + path, + maxW = "1200px", + pt = "12", + children, +}: { + path: string; + maxW?: string; + pt?: string; + children: ReactNode; +}) { + const { user } = useAuth(); + + return ( + + + + {children} + + + ); +} diff --git a/apps/web-app/src/components/protected-route.test.tsx b/apps/web-app/src/components/protected-route.test.tsx new file mode 100644 index 0000000..c8ed80c --- /dev/null +++ b/apps/web-app/src/components/protected-route.test.tsx @@ -0,0 +1,54 @@ +/* @vitest-environment jsdom */ +import { cleanup } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { AuthContext } from "@/contexts/auth"; +import { redirect } from "@/utils/navigate"; +import { render } from "../../test-utils/render"; +import { camperbot } from "../../test-utils/session"; +import { ProtectedRoute } from "./protected-route"; + +vi.mock("@/utils/navigate"); + +function renderWithAuth(auth: React.ContextType) { + return render( + + Protected content + , + ); +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +describe("ProtectedRoute", () => { + test("waits for the session check", () => { + const screen = renderWithAuth({ user: null, isLoading: true, error: null }); + + expect(screen.getByText("Checking session...")).toBeInTheDocument(); + expect(screen.queryByText("Protected content")).not.toBeInTheDocument(); + expect(redirect).not.toHaveBeenCalled(); + }); + + test("redirects logged out users to /login", () => { + const screen = renderWithAuth({ user: null, isLoading: false, error: null }); + + expect(screen.queryByText("Protected content")).not.toBeInTheDocument(); + expect(redirect).toHaveBeenCalledWith("/login"); + }); + + test("redirects to /login when the session check fails", () => { + renderWithAuth({ user: null, isLoading: false, error: new Error("500 - boom") }); + + expect(redirect).toHaveBeenCalledWith("/login"); + }); + + test("renders children for logged in users", () => { + const screen = renderWithAuth({ user: camperbot, isLoading: false, error: null }); + + expect(screen.getByText("Protected content")).toBeInTheDocument(); + expect(redirect).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web-app/src/components/protected-route.tsx b/apps/web-app/src/components/protected-route.tsx new file mode 100644 index 0000000..3ed833b --- /dev/null +++ b/apps/web-app/src/components/protected-route.tsx @@ -0,0 +1,30 @@ +import { Center, HStack, Spinner, Text } from "@chakra-ui/react"; +import { useEffect, type ReactNode } from "react"; + +import { useAuth } from "@/contexts/auth"; +import { redirect } from "@/utils/navigate"; +import { ROUTES } from "@/utils/routes"; + +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { user, isLoading } = useAuth(); + const isLoggedOut = !isLoading && !user; + + useEffect(() => { + if (isLoggedOut) { + redirect(ROUTES.login); + } + }, [isLoggedOut]); + + if (!user) { + return ( +
+ + + {isLoggedOut ? "Redirecting to login..." : "Checking session..."} + +
+ ); + } + + return children; +} diff --git a/apps/web-app/src/components/site-header.tsx b/apps/web-app/src/components/site-header.tsx new file mode 100644 index 0000000..384c2c7 --- /dev/null +++ b/apps/web-app/src/components/site-header.tsx @@ -0,0 +1,64 @@ +import { Flex, HStack, IconButton } from "@chakra-ui/react"; +import type { IconType } from "react-icons"; + +import { FccWordmark } from "@/components/fcc-wordmark"; +import { ROUTES } from "@/utils/routes"; + +export interface NavItem { + href: string; + label: string; + icon: IconType; +} + +export function SiteHeader({ nav = [], path }: { nav?: NavItem[]; path?: string }) { + return ( + + + + + + + + {nav.length > 0 && ( + + {nav.map(({ href, label, icon: Icon }) => { + const active = path !== undefined && isActive(href, path); + return ( + + + + + + ); + })} + + )} + + + ); +} + +export function isActive(href: string, path: string): boolean { + return href === path || (href !== ROUTES.home && path.startsWith(`${href}/`)); +} diff --git a/apps/web-app/src/components/toggle-group.tsx b/apps/web-app/src/components/toggle-group.tsx new file mode 100644 index 0000000..f099548 --- /dev/null +++ b/apps/web-app/src/components/toggle-group.tsx @@ -0,0 +1,50 @@ +import { Flex, HStack, IconButton, Text, VisuallyHidden } from "@chakra-ui/react"; +import type { IconType } from "react-icons"; + +export interface ToggleOption { + value: T; + label: string; + icon: IconType; +} + +export function ToggleGroup({ + legend, + value, + options, + onChange, +}: { + legend: string; + value: T; + options: ToggleOption[]; + onChange: (value: T) => void; +}) { + return ( + + {legend} + + {legend} + + + {options.map(({ value: option, label, icon: Icon }) => { + const pressed = option === value; + return ( + onChange(option)} + > + + + ); + })} + + + ); +} diff --git a/apps/web-app/src/components/ui/chakra-ui-provider.tsx b/apps/web-app/src/components/ui/chakra-ui-provider.tsx new file mode 100644 index 0000000..3360efb --- /dev/null +++ b/apps/web-app/src/components/ui/chakra-ui-provider.tsx @@ -0,0 +1,75 @@ +import { ChakraProvider, createSystem, defaultConfig, defineConfig } from "@chakra-ui/react"; +import { ColorModeProvider, type ColorModeProviderProps } from "./color-mode"; + +const config = defineConfig({ + globalCss: { + body: { bg: "bg", color: "fg", fontFamily: "body" }, + a: { color: "link" }, + "a:focus-visible, button:focus-visible, input:focus-visible, label:focus-within": { + outline: "3px solid {colors.focus}", + outlineOffset: "2px", + }, + }, + theme: { + tokens: { + fonts: { + heading: { value: "'Space Grotesk', Inter, system-ui, sans-serif" }, + body: { value: "Inter, system-ui, sans-serif" }, + }, + }, + semanticTokens: { + radii: { + l1: { value: "6px" }, + l2: { value: "8px" }, + l3: { value: "12px" }, + }, + colors: { + bg: { + DEFAULT: { value: { _light: "#f5f6f7", _dark: "#0a0a23" } }, + panel: { value: { _light: "#ffffff", _dark: "#1b1b32" } }, + muted: { value: { _light: "#ebebee", _dark: "#2a2a40" } }, + emphasized: { value: { _light: "#dfdfe2", _dark: "#3b3b4f" } }, + }, + fg: { + DEFAULT: { value: { _light: "#0a0a23", _dark: "#f5f6f7" } }, + muted: { value: { _light: "#3b3b4f", _dark: "#d0d0d5" } }, + }, + border: { + DEFAULT: { value: { _light: "#d0d0d5", _dark: "#3b3b4f" } }, + }, + link: { value: { _light: "#002ead", _dark: "#99c9ff" } }, + focus: { value: "#198eee" }, + brand: { + solid: { value: "#feac32" }, + contrast: { value: "#0a0a23" }, + fg: { value: { _light: "#4d3800", _dark: "#feac32" } }, + muted: { value: { _light: "#ffd28a", _dark: "#4d3800" } }, + subtle: { value: { _light: "#ffe7c2", _dark: "#2a2a40" } }, + emphasized: { value: "#ffbf5e" }, + focusRing: { value: "#198eee" }, + }, + positive: { + fg: { value: { _light: "#00471b", _dark: "#acd157" } }, + subtle: { value: { _light: "#dcefb4", _dark: "#00471b" } }, + }, + notice: { + fg: { value: { _light: "#002ead", _dark: "#99c9ff" } }, + subtle: { value: { _light: "#dbeafe", _dark: "#002ead" } }, + }, + lilac: { + fg: { value: { _light: "#5a01a7", _dark: "#dbb8ff" } }, + }, + }, + }, + }, +}); + +const system = createSystem(defaultConfig, config); + +export function ChakraUiProvider(props: ColorModeProviderProps) { + return ( + + + + ); +} diff --git a/apps/web-app/src/components/ui/color-mode.tsx b/apps/web-app/src/components/ui/color-mode.tsx new file mode 100644 index 0000000..700df0b --- /dev/null +++ b/apps/web-app/src/components/ui/color-mode.tsx @@ -0,0 +1,103 @@ +"use client"; + +import type { IconButtonProps, SpanProps } from "@chakra-ui/react"; +import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"; +import { ThemeProvider, useTheme } from "next-themes"; +import type { ThemeProviderProps } from "next-themes"; +import * as React from "react"; +import { LuMoon, LuSun } from "react-icons/lu"; + +export type ColorModeProviderProps = ThemeProviderProps; + +export function ColorModeProvider(props: ColorModeProviderProps) { + return ; +} + +export type ColorMode = "light" | "dark"; + +export interface UseColorModeReturn { + colorMode: ColorMode; + setColorMode: (colorMode: ColorMode) => void; + toggleColorMode: () => void; +} + +export function useColorMode(): UseColorModeReturn { + const { resolvedTheme, setTheme, forcedTheme } = useTheme(); + const colorMode = forcedTheme || resolvedTheme; + const toggleColorMode = () => { + setTheme(resolvedTheme === "dark" ? "light" : "dark"); + }; + return { + colorMode: colorMode as ColorMode, + setColorMode: setTheme, + toggleColorMode, + }; +} + +export function useColorModeValue(light: T, dark: T) { + const { colorMode } = useColorMode(); + return colorMode === "dark" ? dark : light; +} + +export function ColorModeIcon() { + const { colorMode } = useColorMode(); + return colorMode === "dark" ? : ; +} + +type ColorModeButtonProps = Omit; + +export const ColorModeButton = React.forwardRef( + function ColorModeButton(props, ref) { + const { toggleColorMode } = useColorMode(); + return ( + }> + + + + + ); + }, +); + +export const LightMode = React.forwardRef( + function LightMode(props, ref) { + return ( + + ); + }, +); + +export const DarkMode = React.forwardRef(function DarkMode(props, ref) { + return ( + + ); +}); diff --git a/apps/web-app/src/components/user-heading.tsx b/apps/web-app/src/components/user-heading.tsx new file mode 100644 index 0000000..b260649 --- /dev/null +++ b/apps/web-app/src/components/user-heading.tsx @@ -0,0 +1,33 @@ +import { Center, Heading, HStack, Stack, Text } from "@chakra-ui/react"; +import type { ReactNode } from "react"; + +import { initials } from "@/utils/initials"; + +export function UserHeading({ name, children }: { name: string; children?: ReactNode }) { + return ( + +
+ {initials(name)} +
+ + + {name} + + {children && ( + + {children} + + )} + +
+ ); +} diff --git a/apps/web-app/src/config.ts b/apps/web-app/src/config.ts index 8a8fe52..f656ba2 100644 --- a/apps/web-app/src/config.ts +++ b/apps/web-app/src/config.ts @@ -3,8 +3,19 @@ declare global { var AUTH_API_URL: string | undefined; } +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1"]); + +export function matchLoopbackHostname(apiUrl: URL, pageHostname: string): URL { + if (LOOPBACK_HOSTNAMES.has(apiUrl.hostname) && LOOPBACK_HOSTNAMES.has(pageHostname)) { + const url = new URL(apiUrl); + url.hostname = pageHostname; + return url; + } + return apiUrl; +} + /** aAPI origin the browser calls. Throws if /config.js did not load or set a URL. */ export function authApiUrl(): URL { if (!globalThis.AUTH_API_URL) throw new Error("AUTH_API_URL unset: /config.js did not load"); - return new URL(globalThis.AUTH_API_URL); + return matchLoopbackHostname(new URL(globalThis.AUTH_API_URL), globalThis.location.hostname); } diff --git a/apps/web-app/src/contexts/auth.tsx b/apps/web-app/src/contexts/auth.tsx new file mode 100644 index 0000000..d8defad --- /dev/null +++ b/apps/web-app/src/contexts/auth.tsx @@ -0,0 +1,36 @@ +import { useQuery } from "@tanstack/react-query"; +import { createContext, useContext, useMemo, type ReactNode } from "react"; + +import { getSessionUser, type SessionUser } from "@/utils/fetch"; + +interface Auth { + user: SessionUser | null; + isLoading: boolean; + error: Error | null; +} + +export const AuthContext = createContext(null); + +export function AuthProvider({ children }: { children: ReactNode }) { + const { data, isPending, error } = useQuery({ + queryKey: ["session"], + queryFn: getSessionUser, + retry: false, + staleTime: Infinity, + }); + + const value = useMemo( + () => ({ user: data ?? null, isLoading: isPending, error }), + [data, isPending, error], + ); + + return {children}; +} + +export function useAuth(): Auth { + const auth = useContext(AuthContext); + if (!auth) { + throw new Error("useAuth must be used within AuthProvider"); + } + return auth; +} diff --git a/apps/web-app/src/layouts/base.astro b/apps/web-app/src/layouts/base.astro new file mode 100644 index 0000000..a121744 --- /dev/null +++ b/apps/web-app/src/layouts/base.astro @@ -0,0 +1,38 @@ +--- +interface Props { + title?: string; +} + +const { title } = Astro.props; +--- + + + + + + + {title ? `${title} | Exam Platform` : "Exam Platform"} + + + + + + + + + + + diff --git a/apps/web-app/src/pages/404.astro b/apps/web-app/src/pages/404.astro index 040574d..f27af2a 100644 --- a/apps/web-app/src/pages/404.astro +++ b/apps/web-app/src/pages/404.astro @@ -1,15 +1,9 @@ --- +import Base from "@/layouts/base.astro"; --- - - - - - Page Not Found | Exam Platform - - -

404: Page Not Found

-

The page you are looking for does not exist.

- Go home - - + +

404: Page Not Found

+

The page you are looking for does not exist.

+ Go home + diff --git a/apps/web-app/src/pages/attempts/[attempt_id].astro b/apps/web-app/src/pages/attempts/[attempt_id].astro new file mode 100644 index 0000000..204307d --- /dev/null +++ b/apps/web-app/src/pages/attempts/[attempt_id].astro @@ -0,0 +1,13 @@ +--- +import Base from "@/layouts/base.astro"; +import { Attempt } from "@/views/attempt"; +import { PLACEHOLDER } from "@/utils/routes"; + +export function getStaticPaths() { + return [{ params: { attempt_id: PLACEHOLDER } }]; +} +--- + + + + diff --git a/apps/web-app/src/pages/exams/[exam_id].astro b/apps/web-app/src/pages/exams/[exam_id].astro new file mode 100644 index 0000000..1751d58 --- /dev/null +++ b/apps/web-app/src/pages/exams/[exam_id].astro @@ -0,0 +1,13 @@ +--- +import Base from "@/layouts/base.astro"; +import { Exam } from "@/views/exam"; +import { PLACEHOLDER } from "@/utils/routes"; + +export function getStaticPaths() { + return [{ params: { exam_id: PLACEHOLDER } }]; +} +--- + + + + diff --git a/apps/web-app/src/pages/exams/index.astro b/apps/web-app/src/pages/exams/index.astro new file mode 100644 index 0000000..bad8c8f --- /dev/null +++ b/apps/web-app/src/pages/exams/index.astro @@ -0,0 +1,8 @@ +--- +import Base from "@/layouts/base.astro"; +import { Exams } from "@/views/exams"; +--- + + + + diff --git a/apps/web-app/src/pages/index.astro b/apps/web-app/src/pages/index.astro index 1ac9aa7..3cbdb5e 100644 --- a/apps/web-app/src/pages/index.astro +++ b/apps/web-app/src/pages/index.astro @@ -1,14 +1,8 @@ --- +import Base from "@/layouts/base.astro"; +import { Home } from "@/views/home"; --- - - - - - Exam Platform - - - -

Web App

- - + + + diff --git a/apps/web-app/src/pages/login.astro b/apps/web-app/src/pages/login.astro new file mode 100644 index 0000000..80d1a16 --- /dev/null +++ b/apps/web-app/src/pages/login.astro @@ -0,0 +1,8 @@ +--- +import Base from "@/layouts/base.astro"; +import { Login } from "@/views/login"; +--- + + + + diff --git a/apps/web-app/src/pages/users/[user_id]/certifications/[certification_id].astro b/apps/web-app/src/pages/users/[user_id]/certifications/[certification_id].astro new file mode 100644 index 0000000..72555e3 --- /dev/null +++ b/apps/web-app/src/pages/users/[user_id]/certifications/[certification_id].astro @@ -0,0 +1,13 @@ +--- +import Base from "@/layouts/base.astro"; +import { Certification } from "@/views/certification"; +import { PLACEHOLDER } from "@/utils/routes"; + +export function getStaticPaths() { + return [{ params: { user_id: PLACEHOLDER, certification_id: PLACEHOLDER } }]; +} +--- + + + + diff --git a/apps/web-app/src/pages/users/[user_id]/certifications/index.astro b/apps/web-app/src/pages/users/[user_id]/certifications/index.astro new file mode 100644 index 0000000..b1e56e7 --- /dev/null +++ b/apps/web-app/src/pages/users/[user_id]/certifications/index.astro @@ -0,0 +1,13 @@ +--- +import Base from "@/layouts/base.astro"; +import { Certifications } from "@/views/certifications"; +import { PLACEHOLDER } from "@/utils/routes"; + +export function getStaticPaths() { + return [{ params: { user_id: PLACEHOLDER } }]; +} +--- + + + + diff --git a/apps/web-app/src/pages/users/[user_id]/profile.astro b/apps/web-app/src/pages/users/[user_id]/profile.astro new file mode 100644 index 0000000..20ec9df --- /dev/null +++ b/apps/web-app/src/pages/users/[user_id]/profile.astro @@ -0,0 +1,13 @@ +--- +import Base from "@/layouts/base.astro"; +import { Profile } from "@/views/profile"; +import { PLACEHOLDER } from "@/utils/routes"; + +export function getStaticPaths() { + return [{ params: { user_id: PLACEHOLDER } }]; +} +--- + + + + diff --git a/apps/web-app/src/utils/countries.ts b/apps/web-app/src/utils/countries.ts new file mode 100644 index 0000000..4a40bdc --- /dev/null +++ b/apps/web-app/src/utils/countries.ts @@ -0,0 +1,267 @@ +const ISO_3166_ALPHA_2 = [ + "AD", + "AE", + "AF", + "AG", + "AI", + "AL", + "AM", + "AO", + "AQ", + "AR", + "AS", + "AT", + "AU", + "AW", + "AX", + "AZ", + "BA", + "BB", + "BD", + "BE", + "BF", + "BG", + "BH", + "BI", + "BJ", + "BL", + "BM", + "BN", + "BO", + "BQ", + "BR", + "BS", + "BT", + "BV", + "BW", + "BY", + "BZ", + "CA", + "CC", + "CD", + "CF", + "CG", + "CH", + "CI", + "CK", + "CL", + "CM", + "CN", + "CO", + "CR", + "CU", + "CV", + "CW", + "CX", + "CY", + "CZ", + "DE", + "DJ", + "DK", + "DM", + "DO", + "DZ", + "EC", + "EE", + "EG", + "EH", + "ER", + "ES", + "ET", + "FI", + "FJ", + "FK", + "FM", + "FO", + "FR", + "GA", + "GB", + "GD", + "GE", + "GF", + "GG", + "GH", + "GI", + "GL", + "GM", + "GN", + "GP", + "GQ", + "GR", + "GS", + "GT", + "GU", + "GW", + "GY", + "HK", + "HM", + "HN", + "HR", + "HT", + "HU", + "ID", + "IE", + "IL", + "IM", + "IN", + "IO", + "IQ", + "IR", + "IS", + "IT", + "JE", + "JM", + "JO", + "JP", + "KE", + "KG", + "KH", + "KI", + "KM", + "KN", + "KP", + "KR", + "KW", + "KY", + "KZ", + "LA", + "LB", + "LC", + "LI", + "LK", + "LR", + "LS", + "LT", + "LU", + "LV", + "LY", + "MA", + "MC", + "MD", + "ME", + "MF", + "MG", + "MH", + "MK", + "ML", + "MM", + "MN", + "MO", + "MP", + "MQ", + "MR", + "MS", + "MT", + "MU", + "MV", + "MW", + "MX", + "MY", + "MZ", + "NA", + "NC", + "NE", + "NF", + "NG", + "NI", + "NL", + "NO", + "NP", + "NR", + "NU", + "NZ", + "OM", + "PA", + "PE", + "PF", + "PG", + "PH", + "PK", + "PL", + "PM", + "PN", + "PR", + "PS", + "PT", + "PW", + "PY", + "QA", + "RE", + "RO", + "RS", + "RU", + "RW", + "SA", + "SB", + "SC", + "SD", + "SE", + "SG", + "SH", + "SI", + "SJ", + "SK", + "SL", + "SM", + "SN", + "SO", + "SR", + "SS", + "ST", + "SV", + "SX", + "SY", + "SZ", + "TC", + "TD", + "TF", + "TG", + "TH", + "TJ", + "TK", + "TL", + "TM", + "TN", + "TO", + "TR", + "TT", + "TV", + "TW", + "TZ", + "UA", + "UG", + "UM", + "US", + "UY", + "UZ", + "VA", + "VC", + "VE", + "VG", + "VI", + "VN", + "VU", + "WF", + "WS", + "YE", + "YT", + "ZA", + "ZM", + "ZW", +] as const; + +export type CountryCode = (typeof ISO_3166_ALPHA_2)[number]; + +export interface Country { + code: CountryCode; + name: string; +} + +export function countries(locale?: string): Country[] { + const names = new Intl.DisplayNames(locale, { type: "region" }); + const collator = new Intl.Collator(locale); + return ISO_3166_ALPHA_2.map((code) => ({ + code, + name: names.of(code) ?? code, + })).sort((a, b) => collator.compare(a.name, b.name)); +} diff --git a/apps/web-app/src/utils/fetch.test.ts b/apps/web-app/src/utils/fetch.test.ts new file mode 100644 index 0000000..fb99e81 --- /dev/null +++ b/apps/web-app/src/utils/fetch.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { mockFetchResponse } from "../../test-utils/session"; +import { authorizedFetch, getSessionUser, HttpError } from "./fetch"; +import { redirect } from "./navigate"; + +vi.mock("./navigate"); + +afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("authorizedFetch", () => { + test("calls aAPI with credentials", async () => { + mockFetchResponse(() => Response.json([])); + + await authorizedFetch("/exams", { method: "POST" }); + + expect(fetch).toHaveBeenCalledWith(new URL("http://127.0.0.1:13001/exams"), { + method: "POST", + credentials: "include", + }); + }); + + test("sends users without a session to /login", async () => { + mockFetchResponse(() => new Response("no session", { status: 401 })); + + await expect(authorizedFetch("/exams")).rejects.toMatchObject({ status: 401 }); + expect(redirect).toHaveBeenCalledWith("/login"); + }); + + test("rejects forbidden users with the server's message", async () => { + mockFetchResponse(() => new Response("not your attempt", { status: 403 })); + + const result = authorizedFetch("/attempts/a1"); + + await expect(result).rejects.toBeInstanceOf(HttpError); + await expect(result).rejects.toThrow("403 - not your attempt"); + expect(redirect).not.toHaveBeenCalled(); + }); +}); + +describe("getSessionUser", () => { + test("returns null when logged out", async () => { + mockFetchResponse(() => new Response("no session", { status: 401 })); + + await expect(getSessionUser()).resolves.toBeNull(); + expect(fetch).toHaveBeenCalledWith(new URL("http://127.0.0.1:13001/auth/session"), { + credentials: "include", + }); + expect(redirect).not.toHaveBeenCalled(); + }); + + test("rejects other failures", async () => { + mockFetchResponse(() => new Response("boom", { status: 500 })); + + await expect(getSessionUser()).rejects.toThrow("500 - boom"); + }); +}); diff --git a/apps/web-app/src/utils/fetch.ts b/apps/web-app/src/utils/fetch.ts new file mode 100644 index 0000000..eaca229 --- /dev/null +++ b/apps/web-app/src/utils/fetch.ts @@ -0,0 +1,72 @@ +import { authApiUrl } from "@/config"; +import { redirect } from "@/utils/navigate"; +import { ROUTES } from "@/utils/routes"; + +export interface SessionUser { + id: string; + name: string; + email: string; +} + +export class HttpError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "HttpError"; + } +} + +async function responseError(res: Response): Promise { + const body = await res.text(); + return new HttpError(res.status, `${res.status} - ${body || res.statusText}`); +} + +function authApiFetch(path: string, options?: RequestInit): Promise { + return fetch(new URL(path, authApiUrl()), { ...options, credentials: "include" }); +} + +export async function authorizedFetch(path: string, options?: RequestInit): Promise { + const res = await authApiFetch(path, options); + + if (res.status === 401) { + redirect(ROUTES.login); + } + if (!res.ok) { + throw await responseError(res); + } + + return res; +} + +export async function getSessionUser(): Promise { + const res = await authApiFetch("/auth/session"); + + if (res.status === 401) { + return null; + } + if (!res.ok) { + throw await responseError(res); + } + + return (await res.json()) as SessionUser; +} + +export async function postDevLogin(user: { name: string; email: string }): Promise { + const res = await authApiFetch("/auth/dev/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(user), + }); + + if (!res.ok) { + throw await responseError(res); + } + + return (await res.json()) as SessionUser; +} + +export async function deleteSession(): Promise { + await authorizedFetch("/auth/logout", { method: "DELETE" }); +} diff --git a/apps/web-app/src/utils/initials.ts b/apps/web-app/src/utils/initials.ts new file mode 100644 index 0000000..6ea2893 --- /dev/null +++ b/apps/web-app/src/utils/initials.ts @@ -0,0 +1,8 @@ +export function initials(name: string): string { + return name + .split(/\s+/) + .filter(Boolean) + .slice(0, 2) + .map((word) => word[0]!.toUpperCase()) + .join(""); +} diff --git a/apps/web-app/src/utils/navigate.ts b/apps/web-app/src/utils/navigate.ts new file mode 100644 index 0000000..1cb40f9 --- /dev/null +++ b/apps/web-app/src/utils/navigate.ts @@ -0,0 +1,4 @@ +// Full-page navigation, replacing the current history entry. Own module so tests can mock it: +export function redirect(path: string): void { + window.location.replace(path); +} diff --git a/apps/web-app/src/utils/routes.test.ts b/apps/web-app/src/utils/routes.test.ts new file mode 100644 index 0000000..682a216 --- /dev/null +++ b/apps/web-app/src/utils/routes.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, test } from "vitest"; + +import { + buildPath, + currentRouteParams, + DYNAMIC_ROUTES, + matchPath, + placeholderPath, + ROUTES, +} from "./routes"; + +describe("matchPath", () => { + test("extracts every param", () => { + expect(matchPath(ROUTES.certification, "/users/u1/certifications/c2")).toEqual({ + userId: "u1", + certificationId: "c2", + }); + }); + + test("allows a trailing slash", () => { + expect(matchPath(ROUTES.exam, "/exams/e1/")).toEqual({ examId: "e1" }); + }); + + test("decodes params", () => { + expect(matchPath(ROUTES.attempt, "/attempts/a%20b")).toEqual({ attemptId: "a b" }); + }); + + test("rejects other paths", () => { + expect(matchPath(ROUTES.exam, "/exams")).toBeNull(); + expect(matchPath(ROUTES.exam, "/exams/e1/extra")).toBeNull(); + expect(matchPath(ROUTES.certifications, "/users/u1/certifications/c2")).toBeNull(); + }); + + test("rejects malformed encodings", () => { + expect(matchPath(ROUTES.exam, "/exams/%E0")).toBeNull(); + }); +}); + +describe("buildPath", () => { + test("encodes params", () => { + expect(buildPath(ROUTES.profile, { userId: "a/b" })).toBe("/users/a%2Fb/profile"); + }); + + test("round trips through matchPath", () => { + const params = { userId: "u 1", certificationId: "c?2" }; + expect(matchPath(ROUTES.certification, buildPath(ROUTES.certification, params))).toEqual( + params, + ); + }); +}); + +test("placeholderPath replaces every param", () => { + expect(placeholderPath(ROUTES.certification)).toBe("/users/_/certifications/_"); +}); + +test("DYNAMIC_ROUTES lists only patterns with params", () => { + expect(DYNAMIC_ROUTES).toEqual([ + ROUTES.exam, + ROUTES.profile, + ROUTES.certifications, + ROUTES.certification, + ROUTES.attempt, + ]); +}); + +describe("currentRouteParams", () => { + afterEach(() => { + window.history.replaceState(null, "", "/"); + }); + + test("reads params from the current URL", () => { + window.history.replaceState(null, "", "/exams/e1?tab=1"); + + expect(currentRouteParams(ROUTES.exam)).toEqual({ examId: "e1" }); + }); + + test("throws when the URL does not match", () => { + window.history.replaceState(null, "", "/exams"); + + expect(() => currentRouteParams(ROUTES.exam)).toThrow("/exams does not match /exams/:examId"); + }); +}); diff --git a/apps/web-app/src/utils/routes.ts b/apps/web-app/src/utils/routes.ts new file mode 100644 index 0000000..e854b5e --- /dev/null +++ b/apps/web-app/src/utils/routes.ts @@ -0,0 +1,59 @@ +export const ROUTES = { + home: "/", + login: "/login", + exams: "/exams", + exam: "/exams/:examId", + profile: "/users/:userId/profile", + certifications: "/users/:userId/certifications", + certification: "/users/:userId/certifications/:certificationId", + attempt: "/attempts/:attemptId", +} as const; + +export const PLACEHOLDER = "_"; + +type ParamNames

= P extends `${string}:${infer Name}/${infer Rest}` + ? Name | ParamNames + : P extends `${string}:${infer Name}` + ? Name + : never; + +export type RouteParams

= Record, string>; + +const PARAM = /:(\w+)/g; + +export const DYNAMIC_ROUTES: string[] = Object.values(ROUTES).filter((pattern) => + pattern.includes(":"), +); + +export function buildPath

(pattern: P, params: RouteParams

): string { + return pattern.replace(PARAM, (_, name: ParamNames

) => encodeURIComponent(params[name])); +} + +export function placeholderPath(pattern: string): string { + return pattern.replace(PARAM, PLACEHOLDER); +} + +export function matchPath

(pattern: P, pathname: string): RouteParams

| null { + const names: string[] = []; + const source = pattern.replace(PARAM, (_, name: string) => { + names.push(name); + return "([^/]+)"; + }); + const match = new RegExp(`^${source}/?$`).exec(pathname); + if (!match) return null; + + try { + return Object.fromEntries( + names.map((name, i) => [name, decodeURIComponent(match[i + 1]!)]), + ) as RouteParams

; + } catch { + return null; + } +} + +export function currentRouteParams

(pattern: P): RouteParams

{ + const { pathname } = window.location; + const params = matchPath(pattern, pathname); + if (!params) throw new Error(`${pathname} does not match ${pattern}`); + return params; +} diff --git a/apps/web-app/src/utils/types.ts b/apps/web-app/src/utils/types.ts new file mode 100644 index 0000000..7cbb770 --- /dev/null +++ b/apps/web-app/src/utils/types.ts @@ -0,0 +1,38 @@ +export type ExamStatus = "open" | "registered" | "passed" | "locked"; + +export interface ExamSummary { + id: string; + name: string; + durationMinutes: number; + questionCount: number; + passPercent: number; + status: ExamStatus; +} + +export interface Prerequisite { + name: string; + kind: "certification" | "identity"; + met: boolean; +} + +export interface ExamDetail extends ExamSummary { + description: string; + retakeHours: number; + prerequisites: Prerequisite[]; + attemptId: string | null; +} + +export interface Attempt { + id: string; + examId: string; + examName: string; + identityVerified: boolean; + desktopConnected: boolean; +} + +export interface Certification { + id: string; + name: string; + issuedAt: string; + scorePercent: number; +} diff --git a/apps/web-app/src/views/attempt.tsx b/apps/web-app/src/views/attempt.tsx new file mode 100644 index 0000000..0ee8f69 --- /dev/null +++ b/apps/web-app/src/views/attempt.tsx @@ -0,0 +1,172 @@ +import { Box, Button, Center, Heading, HStack, SimpleGrid, Stack, Text } from "@chakra-ui/react"; +import type { IconType } from "react-icons"; +import { + LuAward, + LuCalendarCheck, + LuDownload, + LuIdCard, + LuMonitor, + LuPlug, + LuSearchX, +} from "react-icons/lu"; + +import { AppRoot } from "@/components/app-root"; +import { BackLink } from "@/components/back-link"; +import { EmptyState } from "@/components/empty-state"; +import { PageShell } from "@/components/page-shell"; +import { ProtectedRoute } from "@/components/protected-route"; +import { buildPath, currentRouteParams, ROUTES } from "@/utils/routes"; +import type { Attempt as AttemptData } from "@/utils/types"; + +type StepState = "done" | "current" | "pending"; + +const STEP_COLORS: Record = { + done: { color: "positive.fg", borderColor: "border" }, + current: { color: "brand.fg", borderColor: "brand.solid" }, + pending: { color: "fg.muted", borderColor: "border" }, +}; + +function Step({ + icon: Icon, + label, + title, + state, +}: { + icon: IconType; + label: string; + title: string; + state: StepState; +}) { + return ( + + + + {label} + + + ); +} + +function AttemptDetails({ attempt }: { attempt: AttemptData }) { + const connected = attempt.desktopConnected; + + return ( + + + + + {attempt.examName} + + + + + + + + + + + + +

+ +
+ + + + {connected ? "Connected" : "Not connected"} + + {!connected && ( + + )} + + + + Get the app + + + + + ); +} + +function AttemptPage({ attempt }: { attempt: AttemptData | null }) { + const params = currentRouteParams(ROUTES.attempt); + + return ( + + {attempt ? ( + + ) : ( + + + + + )} + + ); +} + +export function Attempt({ attempt = null }: { attempt?: AttemptData | null }) { + return ( + + + + + + ); +} diff --git a/apps/web-app/src/views/certification.tsx b/apps/web-app/src/views/certification.tsx new file mode 100644 index 0000000..05f175b --- /dev/null +++ b/apps/web-app/src/views/certification.tsx @@ -0,0 +1,45 @@ +import { Stack } from "@chakra-ui/react"; +import { LuSearchX } from "react-icons/lu"; + +import { AppRoot } from "@/components/app-root"; +import { BackLink } from "@/components/back-link"; +import { CertificationCard } from "@/components/certification-card"; +import { EmptyState } from "@/components/empty-state"; +import { PageShell } from "@/components/page-shell"; +import { ProtectedRoute } from "@/components/protected-route"; +import { buildPath, currentRouteParams, ROUTES } from "@/utils/routes"; +import type { Certification as CertificationData } from "@/utils/types"; + +function CertificationPage({ certification }: { certification: CertificationData | null }) { + const params = currentRouteParams(ROUTES.certification); + + return ( + + + + {certification ? ( + + ) : ( + + )} + + + ); +} + +export function Certification({ + certification = null, +}: { + certification?: CertificationData | null; +}) { + return ( + + + + + + ); +} diff --git a/apps/web-app/src/views/certifications.tsx b/apps/web-app/src/views/certifications.tsx new file mode 100644 index 0000000..d036f1c --- /dev/null +++ b/apps/web-app/src/views/certifications.tsx @@ -0,0 +1,95 @@ +import { Button, Flex, Heading, IconButton, SimpleGrid, Stack } from "@chakra-ui/react"; +import { LuAward, LuClipboardList, LuLink, LuPlus } from "react-icons/lu"; + +import { AppRoot } from "@/components/app-root"; +import { CertificationCard } from "@/components/certification-card"; +import { EmptyState } from "@/components/empty-state"; +import { PageShell } from "@/components/page-shell"; +import { ProtectedRoute } from "@/components/protected-route"; +import { UserHeading } from "@/components/user-heading"; +import { useAuth } from "@/contexts/auth"; +import { buildPath, currentRouteParams, ROUTES } from "@/utils/routes"; +import type { Certification } from "@/utils/types"; + +function CertificationsPage({ certifications }: { certifications: Certification[] }) { + const { user } = useAuth(); + const { userId } = currentRouteParams(ROUTES.certifications); + const own = user?.id === userId; + + return ( + + + + {own && user ? ( + + ) : ( + + Certifications + + )} + void navigator.clipboard.writeText(window.location.href)} + > + + + + + {certifications.length === 0 ? ( + + {own && ( + + )} + + ) : ( + + {certifications.map((certification) => ( +
  • + +
  • + ))} + {own && ( + + + + + + + + )} +
    + )} +
    +
    + ); +} + +export function Certifications({ certifications = [] }: { certifications?: Certification[] }) { + return ( + + + + + + ); +} diff --git a/apps/web-app/src/views/exam.tsx b/apps/web-app/src/views/exam.tsx new file mode 100644 index 0000000..db56604 --- /dev/null +++ b/apps/web-app/src/views/exam.tsx @@ -0,0 +1,196 @@ +import { Box, Button, Flex, Heading, HStack, SimpleGrid, Stack, Text } from "@chakra-ui/react"; +import type { IconType } from "react-icons"; +import { + LuArrowRight, + LuAward, + LuCalendarCheck, + LuCalendarPlus, + LuCircle, + LuCircleCheck, + LuCircleHelp, + LuClock, + LuIdCard, + LuLock, + LuMonitor, + LuRotateCcw, + LuSearchX, + LuTarget, +} from "react-icons/lu"; + +import { AppRoot } from "@/components/app-root"; +import { BackLink } from "@/components/back-link"; +import { EmptyState } from "@/components/empty-state"; +import { PageShell } from "@/components/page-shell"; +import { ProtectedRoute } from "@/components/protected-route"; +import { buildPath, currentRouteParams, ROUTES } from "@/utils/routes"; +import type { ExamDetail } from "@/utils/types"; + +function StatTile({ icon: Icon, label, value }: { icon: IconType; label: string; value: string }) { + return ( + + + + + + {value} + + + ); +} + +function ExamActions({ exam }: { exam: ExamDetail }) { + const primary = { colorPalette: "brand", h: "14", fontWeight: "bold", fontSize: "17px" }; + + switch (exam.status) { + case "open": + return ( + + ); + case "registered": + return ( + <> + + + Registered + + {exam.attemptId && ( + + )} + + ); + case "passed": + return ( + + + Passed + + ); + case "locked": + return ( + + + Prerequisites needed + + ); + } +} + +function ExamDetails({ exam }: { exam: ExamDetail }) { + return ( + + + + + {exam.name} + + + {exam.description} + + + + + + + + + + + {exam.prerequisites.length > 0 && ( + + + Requires + + + {exam.prerequisites.map((prerequisite) => { + const KindIcon = prerequisite.kind === "identity" ? LuIdCard : LuAward; + return ( + + {prerequisite.met ? ( + + + + ) : ( + + + + )} + + + + {prerequisite.name} + + ); + })} + + + )} + + + + + + + Desktop app required + + + + ); +} + +function ExamPage({ exam }: { exam: ExamDetail | null }) { + const params = currentRouteParams(ROUTES.exam); + + return ( + + + + {exam ? ( + + ) : ( + + )} + + + ); +} + +export function Exam({ exam = null }: { exam?: ExamDetail | null }) { + return ( + + + + + + ); +} diff --git a/apps/web-app/src/views/exams.tsx b/apps/web-app/src/views/exams.tsx new file mode 100644 index 0000000..2271662 --- /dev/null +++ b/apps/web-app/src/views/exams.tsx @@ -0,0 +1,67 @@ +import { Flex, Heading, SimpleGrid, Stack } from "@chakra-ui/react"; +import { useState } from "react"; +import { LuClipboardList, LuSearch, LuSearchX } from "react-icons/lu"; + +import { AppRoot } from "@/components/app-root"; +import { EmptyState } from "@/components/empty-state"; +import { ExamCard } from "@/components/exam-card"; +import { IconField } from "@/components/icon-field"; +import { PageShell } from "@/components/page-shell"; +import { ProtectedRoute } from "@/components/protected-route"; +import { ROUTES } from "@/utils/routes"; +import type { ExamSummary } from "@/utils/types"; + +function ExamsPage({ exams }: { exams: ExamSummary[] }) { + const [query, setQuery] = useState(""); + const needle = query.trim().toLowerCase(); + const shown = exams.filter((exam) => exam.name.toLowerCase().includes(needle)); + + return ( + + + + + Exams + + + setQuery(e.target.value)} + /> + + + + {exams.length === 0 ? ( + + ) : shown.length === 0 ? ( + + ) : ( + + {shown.map((exam) => ( +
  • + +
  • + ))} +
    + )} +
    +
    + ); +} + +export function Exams({ exams = [] }: { exams?: ExamSummary[] }) { + return ( + + + + + + ); +} diff --git a/apps/web-app/src/views/home.test.tsx b/apps/web-app/src/views/home.test.tsx new file mode 100644 index 0000000..0f0e5f8 --- /dev/null +++ b/apps/web-app/src/views/home.test.tsx @@ -0,0 +1,34 @@ +/* @vitest-environment jsdom */ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, expect, test, vi } from "vitest"; + +import { redirect } from "@/utils/navigate"; +import { camperbot, mockSession } from "../../test-utils/session"; +import { Home } from "./home"; + +vi.mock("@/utils/navigate"); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +test("is public", async () => { + mockSession(null); + + const screen = render(); + + expect(await screen.findByRole("heading", { name: "Exam Platform" })).toBeInTheDocument(); + expect(screen.getAllByRole("link", { name: "Sign in" })).toHaveLength(2); + expect(redirect).not.toHaveBeenCalled(); +}); + +test("hides sign in from logged in users", async () => { + mockSession(camperbot); + + const screen = render(); + + expect(await screen.findByRole("link", { name: "Profile" })).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Sign in" })).not.toBeInTheDocument(); +}); diff --git a/apps/web-app/src/views/home.tsx b/apps/web-app/src/views/home.tsx new file mode 100644 index 0000000..6177849 --- /dev/null +++ b/apps/web-app/src/views/home.tsx @@ -0,0 +1,92 @@ +import { Button, Center, Flex, Heading, HStack, IconButton, Stack, Text } from "@chakra-ui/react"; +import type { IconType } from "react-icons"; +import { + LuAward, + LuCalendarPlus, + LuClipboardList, + LuIdCard, + LuLogIn, + LuMonitor, +} from "react-icons/lu"; + +import { AppRoot } from "@/components/app-root"; +import { PageShell } from "@/components/page-shell"; +import { useAuth } from "@/contexts/auth"; +import { ROUTES } from "@/utils/routes"; + +const STEPS: { label: string; icon: IconType; color: string }[] = [ + { label: "Verify", icon: LuIdCard, color: "notice.fg" }, + { label: "Register", icon: LuCalendarPlus, color: "lilac.fg" }, + { label: "Sit exam", icon: LuMonitor, color: "brand.fg" }, + { label: "Get certified", icon: LuAward, color: "positive.fg" }, +]; + +function HomePage() { + const { user } = useAuth(); + + return ( + + + + + Exam Platform + + + + {!user && ( + + + + + + )} + + + + + {STEPS.map(({ label, icon: Icon, color }, i) => ( + +
    + +
    + + + {String(i + 1).padStart(2, "0")} + + + {label} + + +
    + ))} +
    +
    +
    + ); +} + +export function Home() { + return ( + + + + ); +} diff --git a/apps/web-app/src/views/login.tsx b/apps/web-app/src/views/login.tsx new file mode 100644 index 0000000..7f86885 --- /dev/null +++ b/apps/web-app/src/views/login.tsx @@ -0,0 +1,147 @@ +import { + Alert, + Box, + Button, + Center, + Field, + Flex, + Heading, + HStack, + Spinner, + Stack, + Text, +} from "@chakra-ui/react"; +import { useMutation } from "@tanstack/react-query"; +import { useEffect, useState, type SubmitEventHandler } from "react"; +import { LuLogIn, LuMail, LuUserRound } from "react-icons/lu"; + +import { AppRoot } from "@/components/app-root"; +import { IconField } from "@/components/icon-field"; +import { SiteHeader } from "@/components/site-header"; +import { useAuth } from "@/contexts/auth"; +import { postDevLogin } from "@/utils/fetch"; +import { redirect } from "@/utils/navigate"; +import { ROUTES } from "@/utils/routes"; + +function LoginForm() { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + + const login = useMutation({ + mutationFn: postDevLogin, + retry: false, + onSuccess: () => redirect(ROUTES.home), + }); + + const onSubmit: SubmitEventHandler = (e) => { + e.preventDefault(); + login.mutate({ name, email }); + }; + + return ( + +
    + + setName(e.target.value)} + /> + + + setEmail(e.target.value)} + /> + {login.error?.message} + + +
    +
    + ); +} + +function LoginCard() { + const { user, isLoading, error } = useAuth(); + + useEffect(() => { + if (user) { + redirect(ROUTES.home); + } + }, [user]); + + return ( + + +
    + + +
    + +
    + + Sign in + +
    + {error && ( + + + + Could not check your session + {error.message} + + + )} + {isLoading || user ? ( + + + {user ? "Redirecting..." : "Checking session..."} + + ) : ( + + + + )} +
    +
    +
    + ); +} + +export function Login() { + return ( + + + + ); +} diff --git a/apps/web-app/src/views/profile.tsx b/apps/web-app/src/views/profile.tsx new file mode 100644 index 0000000..5198588 --- /dev/null +++ b/apps/web-app/src/views/profile.tsx @@ -0,0 +1,264 @@ +import { + Box, + Button, + Center, + Flex, + Heading, + HStack, + IconButton, + NativeSelect, + Stack, + Text, + VisuallyHidden, +} from "@chakra-ui/react"; +import { useMutation } from "@tanstack/react-query"; +import { useMemo, useState } from "react"; +import { + LuCake, + LuCheck, + LuGlobe, + LuIdCard, + LuLock, + LuLogOut, + LuMapPin, + LuMoon, + LuSun, + LuUpload, + LuUserRound, + LuUserX, +} from "react-icons/lu"; + +import { AppRoot } from "@/components/app-root"; +import { EmptyState } from "@/components/empty-state"; +import { IconField } from "@/components/icon-field"; +import { PageShell } from "@/components/page-shell"; +import { ProtectedRoute } from "@/components/protected-route"; +import { ToggleGroup } from "@/components/toggle-group"; +import { useColorMode, type ColorMode } from "@/components/ui/color-mode"; +import { useAuth } from "@/contexts/auth"; +import { deleteSession, type SessionUser } from "@/utils/fetch"; +import { initials } from "@/utils/initials"; +import { redirect } from "@/utils/navigate"; +import { countries } from "@/utils/countries"; +import { buildPath, currentRouteParams, ROUTES } from "@/utils/routes"; + +type Visibility = "public" | "private"; + +const MIN_AGE_YEARS = 18; + +function latestDateOfBirth() { + const date = new Date(); + date.setFullYear(date.getFullYear() - MIN_AGE_YEARS); + return date.toISOString().slice(0, 10); +} + +function ProfileForm({ user }: { user: SessionUser }) { + const { colorMode, setColorMode } = useColorMode(); + const [name, setName] = useState(user.name); + const [dateOfBirth, setDateOfBirth] = useState(""); + const [location, setLocation] = useState(""); + const countryOptions = useMemo(() => countries(), []); + const maxDateOfBirth = useMemo(() => latestDateOfBirth(), []); + const [document, setDocument] = useState(null); + const [visibility, setVisibility] = useState("private"); + + return ( + +
    e.preventDefault()}> + setName(e.target.value)} + /> + + + setDateOfBirth(e.target.value)} + /> + + + +
    + +
    + setLocation(e.target.value)} + > + + {countryOptions.map(({ code, name }) => ( + + ))} + + +
    +
    +
    + + +
    + +
    + + {document ?? "Identity document"} + +
    + +
    + + setDocument(e.target.files?.[0]?.name ?? null)} + /> + +
    + + + + legend="Theme" + value={colorMode} + onChange={setColorMode} + options={[ + { value: "light", label: "Light theme", icon: LuSun }, + { value: "dark", label: "Dark theme", icon: LuMoon }, + ]} + /> + + legend="Profile" + value={visibility} + onChange={setVisibility} + options={[ + { value: "public", label: "Public profile", icon: LuGlobe }, + { value: "private", label: "Private profile", icon: LuLock }, + ]} + /> + + + + + + +
    + ); +} + +function ProfilePage() { + const { user } = useAuth(); + const { userId } = currentRouteParams(ROUTES.profile); + const logout = useMutation({ + mutationFn: deleteSession, + onSuccess: () => redirect(ROUTES.login), + }); + + return ( + + {user && user.id === userId ? ( + + + +
    + {initials(user.name)} +
    + + Profile + +
    + logout.mutate()} + > + + +
    + +
    + ) : ( + + )} +
    + ); +} + +export function Profile() { + return ( + + + + + + ); +} diff --git a/apps/web-app/src/views/protected.test.tsx b/apps/web-app/src/views/protected.test.tsx new file mode 100644 index 0000000..76f57ed --- /dev/null +++ b/apps/web-app/src/views/protected.test.tsx @@ -0,0 +1,53 @@ +/* @vitest-environment jsdom */ +import { cleanup, render } from "@testing-library/react"; +import type { ComponentType } from "react"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { redirect } from "@/utils/navigate"; +import { camperbot, mockSession } from "../../test-utils/session"; +import { Attempt } from "./attempt"; +import { Certification } from "./certification"; +import { Certifications } from "./certifications"; +import { Exam } from "./exam"; +import { Exams } from "./exams"; +import { Profile } from "./profile"; + +vi.mock("@/utils/navigate"); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.unstubAllGlobals(); + window.history.replaceState(null, "", "/"); +}); + +const views: [string, ComponentType, string, string][] = [ + ["Exams", Exams, "/exams", "No exams"], + ["Exam", Exam, "/exams/e1", "Exam not found"], + ["Profile", Profile, "/users/camperbot-id/profile", "Profile"], + ["Certifications", Certifications, "/users/u1/certifications", "No certifications"], + ["Certification", Certification, "/users/u1/certifications/c1", "Certification not found"], + ["Attempt", Attempt, "/attempts/a1", "Attempt not found"], +]; + +describe.each(views)("%s", (_, View, path, text) => { + test("renders for logged in users", async () => { + window.history.replaceState(null, "", path); + mockSession(camperbot); + + const screen = render(); + + expect(await screen.findAllByText(text)).not.toHaveLength(0); + expect(redirect).not.toHaveBeenCalled(); + }); + + test("sends logged out users to /login", async () => { + window.history.replaceState(null, "", path); + mockSession(null); + + const screen = render(); + + await vi.waitFor(() => expect(redirect).toHaveBeenCalledWith("/login")); + expect(screen.queryAllByText(text)).toHaveLength(0); + }); +}); diff --git a/apps/web-app/test-utils/render.tsx b/apps/web-app/test-utils/render.tsx new file mode 100644 index 0000000..81bad76 --- /dev/null +++ b/apps/web-app/test-utils/render.tsx @@ -0,0 +1,10 @@ +import { ChakraUiProvider } from "@/components/ui/chakra-ui-provider"; +import { render as rtlRender } from "@testing-library/react"; + +export function render(ui: React.ReactNode) { + return rtlRender(<>{ui}, { + wrapper: (props: React.PropsWithChildren) => ( + {props.children} + ), + }); +} diff --git a/apps/web-app/test-utils/session.ts b/apps/web-app/test-utils/session.ts new file mode 100644 index 0000000..09070d1 --- /dev/null +++ b/apps/web-app/test-utils/session.ts @@ -0,0 +1,22 @@ +import { vi } from "vitest"; + +import type { SessionUser } from "@/utils/fetch"; + +export const camperbot: SessionUser = { + id: "camperbot-id", + name: "Camperbot", + email: "camperbot@freecodecamp.org", +}; + +export function mockFetchResponse(response: () => Response) { + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(response())), + ); +} + +export function mockSession(user: SessionUser | null) { + mockFetchResponse(() => + user ? Response.json(user) : new Response("not logged in", { status: 401 }), + ); +} diff --git a/apps/web-app/tsconfig.json b/apps/web-app/tsconfig.json index 3b9632c..011b14d 100644 --- a/apps/web-app/tsconfig.json +++ b/apps/web-app/tsconfig.json @@ -6,6 +6,9 @@ "jsx": "react-jsx", "jsxImportSource": "react", "noUncheckedIndexedAccess": true, - "types": ["bun"] + "types": ["bun", "@testing-library/jest-dom/vitest"], + "paths": { + "@/*": ["./src/*"] + } } } diff --git a/apps/web-app/vitest-setup.ts b/apps/web-app/vitest-setup.ts new file mode 100644 index 0000000..23d59b9 --- /dev/null +++ b/apps/web-app/vitest-setup.ts @@ -0,0 +1,18 @@ +import "@testing-library/jest-dom/vitest"; +import { vi } from "vitest"; + +Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), +}); + +globalThis.AUTH_API_URL = "http://127.0.0.1:13001"; diff --git a/apps/web-app/vitest.config.ts b/apps/web-app/vitest.config.ts new file mode 100644 index 0000000..2c11aba --- /dev/null +++ b/apps/web-app/vitest.config.ts @@ -0,0 +1,10 @@ +/// +import { getViteConfig } from "astro/config"; + +export default getViteConfig({ + test: { + environment: "jsdom", + environmentOptions: { jsdom: { url: "http://127.0.0.1:13003/" } }, + setupFiles: "vitest-setup.ts", + }, +}); diff --git a/bun.lock b/bun.lock index c23ac95..54026e6 100644 --- a/bun.lock +++ b/bun.lock @@ -67,22 +67,34 @@ }, "apps/web-app": { "name": "web-app", - "version": "0.2.0", + "version": "0.3.0", "dependencies": { "@astrojs/react": "catalog:astro", + "@chakra-ui/react": "catalog:astro", + "@emotion/react": "catalog:astro", "@tanstack/react-query": "catalog:", "astro": "catalog:astro", + "next-themes": "catalog:astro", "react": "catalog:", "react-dom": "catalog:", + "react-icons": "catalog:astro", }, "devDependencies": { "@astrojs/check": "catalog:astro", "@tanstack/react-query-devtools": "catalog:", + "@testing-library/dom": "catalog:astro", + "@testing-library/jest-dom": "catalog:astro", + "@testing-library/react": "catalog:astro", "@types/bun": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "jsdom": "catalog:astro", "oxfmt": "catalog:", + "oxlint": "catalog:", + "oxlint-config-presets": "catalog:", + "oxlint-tsgolint": "catalog:", "typescript": "catalog:astro", + "vitest": "catalog:", }, }, }, diff --git a/compose.dev.yaml b/compose.dev.yaml index 28b85a4..b1f41f0 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -16,6 +16,7 @@ x-edd-build: &edd-build x-aapi-build: &aapi-build context: . dockerfile: apps/auth-api/Dockerfile + args: { CARGO_PROFILE: dev } services: examiner-db: @@ -139,7 +140,9 @@ services: required: false environment: ENVIRONMENT: development + MOCK_AUTH: "true" LISTEN_ADDR: 0.0.0.0:13001 + WEB_APP_ORIGINS: http://127.0.0.1:13003,http://localhost:13003 EDB_DATABASE_URL: postgres://aapi:${EDB_AAPI_PASSWORD}@examiner-db:5432/examiner depends_on: migrate-edb-aapi: { condition: service_completed_successfully } diff --git a/databases/examiner/.sqlx/query-7f0805285ba229317cf1a4bb92377652ae1eb750d438b41dc8d8f6bd0623d86d.json b/databases/examiner/.sqlx/query-7f0805285ba229317cf1a4bb92377652ae1eb750d438b41dc8d8f6bd0623d86d.json new file mode 100644 index 0000000..5eda43f --- /dev/null +++ b/databases/examiner/.sqlx/query-7f0805285ba229317cf1a4bb92377652ae1eb750d438b41dc8d8f6bd0623d86d.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT candidate_id, expires_at FROM aapi.sessions WHERE id_hash = $1 AND expires_at > now()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "candidate_id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "aapi.sessions", + "name": "candidate_id" + } + } + }, + { + "ordinal": 1, + "name": "expires_at", + "type_info": "Timestamptz", + "origin": { + "Table": { + "table": "aapi.sessions", + "name": "expires_at" + } + } + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "7f0805285ba229317cf1a4bb92377652ae1eb750d438b41dc8d8f6bd0623d86d" +} diff --git a/databases/examiner/.sqlx/query-92edb8f2b2dd52196dee922d8be9973a1840b79b596fcf89fec7cf99575e2219.json b/databases/examiner/.sqlx/query-92edb8f2b2dd52196dee922d8be9973a1840b79b596fcf89fec7cf99575e2219.json new file mode 100644 index 0000000..f558d4c --- /dev/null +++ b/databases/examiner/.sqlx/query-92edb8f2b2dd52196dee922d8be9973a1840b79b596fcf89fec7cf99575e2219.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM aapi.sessions WHERE candidate_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "92edb8f2b2dd52196dee922d8be9973a1840b79b596fcf89fec7cf99575e2219" +} diff --git a/databases/examiner/.sqlx/query-c5397ebd41200e5c85056c58c28568bf90871ac74cb742e4d070aea3f8c8f3df.json b/databases/examiner/.sqlx/query-c5397ebd41200e5c85056c58c28568bf90871ac74cb742e4d070aea3f8c8f3df.json new file mode 100644 index 0000000..26e1762 --- /dev/null +++ b/databases/examiner/.sqlx/query-c5397ebd41200e5c85056c58c28568bf90871ac74cb742e4d070aea3f8c8f3df.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, email FROM aapi.candidates WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid", + "origin": { + "Table": { + "table": "aapi.candidates", + "name": "id" + } + } + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Text", + "origin": { + "Table": { + "table": "aapi.candidates", + "name": "name" + } + } + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text", + "origin": { + "Table": { + "table": "aapi.candidates", + "name": "email" + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "c5397ebd41200e5c85056c58c28568bf90871ac74cb742e4d070aea3f8c8f3df" +} diff --git a/databases/examiner/.sqlx/query-c5ada90823ea485c0a1ad4e53df3098d89ec8a39e552f2c0677703d691ad03b3.json b/databases/examiner/.sqlx/query-c5ada90823ea485c0a1ad4e53df3098d89ec8a39e552f2c0677703d691ad03b3.json new file mode 100644 index 0000000..ca6b04e --- /dev/null +++ b/databases/examiner/.sqlx/query-c5ada90823ea485c0a1ad4e53df3098d89ec8a39e552f2c0677703d691ad03b3.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH expired AS (DELETE FROM aapi.sessions WHERE expires_at <= now())\n INSERT INTO aapi.sessions (id_hash, candidate_id, expires_at) VALUES ($1, $2, $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bytea", + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "c5ada90823ea485c0a1ad4e53df3098d89ec8a39e552f2c0677703d691ad03b3" +} diff --git a/databases/examiner/migrations/0004_candidate_sessions.down.sql b/databases/examiner/migrations/0004_candidate_sessions.down.sql new file mode 100644 index 0000000..96fd8d8 --- /dev/null +++ b/databases/examiner/migrations/0004_candidate_sessions.down.sql @@ -0,0 +1 @@ +DROP TABLE aapi.sessions; diff --git a/databases/examiner/migrations/0004_candidate_sessions.up.sql b/databases/examiner/migrations/0004_candidate_sessions.up.sql new file mode 100644 index 0000000..1ed0285 --- /dev/null +++ b/databases/examiner/migrations/0004_candidate_sessions.up.sql @@ -0,0 +1,11 @@ +CREATE TABLE aapi.sessions ( + id_hash bytea PRIMARY KEY, + candidate_id uuid NOT NULL REFERENCES aapi.candidates (id) ON DELETE CASCADE, + expires_at timestamptz NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX sessions_candidate_id_idx ON aapi.sessions (candidate_id); +CREATE INDEX sessions_expires_at_idx ON aapi.sessions (expires_at); + +GRANT SELECT, INSERT, DELETE ON aapi.sessions TO aapi; diff --git a/databases/examiner/src/aapi/candidates.rs b/databases/examiner/src/aapi/candidates.rs index 4945853..378eb9b 100644 --- a/databases/examiner/src/aapi/candidates.rs +++ b/databases/examiner/src/aapi/candidates.rs @@ -30,6 +30,19 @@ impl Database { .await } + pub async fn find_candidate_by_id( + &self, + candidate_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as!( + Candidate, + "SELECT id, name, email FROM aapi.candidates WHERE id = $1", + candidate_id + ) + .fetch_optional(&self.pool) + .await + } + /// aAPI only. Inserts `candidate`, returning it with its new id. pub async fn insert_candidate( &self, @@ -76,6 +89,22 @@ mod tests { Ok(()) } + #[sqlx::test(migrations = false)] + async fn finds_candidate_by_id(pool: PgPool) -> sqlx::Result<()> { + setup(&pool).await?; + let db = database_as(&pool, Role::Aapi).await?; + let candidate = db + .insert_candidate(new_candidate("camper@freecodecamp.org")) + .await?; + + let found = db.find_candidate_by_id(candidate.id).await?; + let missing = db.find_candidate_by_id(Uuid::nil()).await?; + + assert_eq!(found.map(|c| c.email), Some(candidate.email)); + assert!(missing.is_none()); + Ok(()) + } + #[sqlx::test(migrations = false)] async fn rejects_email_differing_only_in_case(pool: PgPool) -> sqlx::Result<()> { setup(&pool).await?; diff --git a/databases/examiner/src/aapi/mod.rs b/databases/examiner/src/aapi/mod.rs index 553dbcf..3455399 100644 --- a/databases/examiner/src/aapi/mod.rs +++ b/databases/examiner/src/aapi/mod.rs @@ -1,4 +1,6 @@ //! Schema `aapi`: aAPI's tables. mod candidates; +mod sessions; pub use candidates::{Candidate, NewCandidate}; +pub use sessions::CandidateSession; diff --git a/databases/examiner/src/aapi/sessions.rs b/databases/examiner/src/aapi/sessions.rs new file mode 100644 index 0000000..2091ebc --- /dev/null +++ b/databases/examiner/src/aapi/sessions.rs @@ -0,0 +1,188 @@ +use time::OffsetDateTime; +use uuid::Uuid; + +use crate::{Database, session_id_hash}; + +#[derive(Debug, Clone)] +pub struct CandidateSession { + pub session_id: String, + pub candidate_id: Uuid, + pub expires_at: OffsetDateTime, +} + +impl Database { + pub async fn insert_candidate_session( + &self, + session: &CandidateSession, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + "WITH expired AS (DELETE FROM aapi.sessions WHERE expires_at <= now()) + INSERT INTO aapi.sessions (id_hash, candidate_id, expires_at) VALUES ($1, $2, $3)", + session_id_hash(&session.session_id), + session.candidate_id, + session.expires_at + ) + .execute(&self.pool) + .await?; + + Ok(()) + } + + pub async fn find_candidate_session( + &self, + session_id: &str, + ) -> Result, sqlx::Error> { + let row = sqlx::query!( + "SELECT candidate_id, expires_at FROM aapi.sessions WHERE id_hash = $1 AND expires_at > now()", + session_id_hash(session_id) + ) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|row| CandidateSession { + session_id: session_id.to_owned(), + candidate_id: row.candidate_id, + expires_at: row.expires_at, + })) + } + + pub async fn delete_candidate_sessions(&self, candidate_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query!( + "DELETE FROM aapi.sessions WHERE candidate_id = $1", + candidate_id + ) + .execute(&self.pool) + .await?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use sqlx::PgPool; + use time::Duration; + + use super::*; + use crate::{NewCandidate, testing::*}; + + async fn candidate_id(db: &Database) -> sqlx::Result { + let candidate = db + .insert_candidate(NewCandidate { + name: "Camper".to_string(), + email: "camper@example.com".to_string(), + }) + .await?; + Ok(candidate.id) + } + + fn session(id: &str, candidate_id: Uuid, expires_in: Duration) -> CandidateSession { + CandidateSession { + session_id: id.to_string(), + candidate_id, + expires_at: OffsetDateTime::now_utc() + expires_in, + } + } + + #[sqlx::test(migrations = false)] + async fn stores_only_the_hash(pool: PgPool) -> sqlx::Result<()> { + setup(&pool).await?; + let db = database_as(&pool, Role::Aapi).await?; + let candidate_id = candidate_id(&db).await?; + db.insert_candidate_session(&session("sid", candidate_id, Duration::hours(1))) + .await?; + + let stored: Vec = sqlx::query_scalar("SELECT id_hash FROM aapi.sessions") + .fetch_one(&pool) + .await?; + + assert_eq!(stored, session_id_hash("sid")); + assert_eq!( + db.find_candidate_session("sid") + .await? + .map(|s| s.candidate_id), + Some(candidate_id) + ); + Ok(()) + } + + #[sqlx::test(migrations = false)] + async fn skips_expired_sessions(pool: PgPool) -> sqlx::Result<()> { + setup(&pool).await?; + let db = database_as(&pool, Role::Aapi).await?; + let candidate_id = candidate_id(&db).await?; + db.insert_candidate_session(&session("old", candidate_id, Duration::seconds(-1))) + .await?; + + assert!(db.find_candidate_session("old").await?.is_none()); + Ok(()) + } + + #[sqlx::test(migrations = false)] + async fn insert_prunes_expired_sessions(pool: PgPool) -> sqlx::Result<()> { + setup(&pool).await?; + let db = database_as(&pool, Role::Aapi).await?; + let candidate_id = candidate_id(&db).await?; + db.insert_candidate_session(&session("old", candidate_id, Duration::seconds(-1))) + .await?; + db.insert_candidate_session(&session("new", candidate_id, Duration::hours(1))) + .await?; + + let count: i64 = sqlx::query_scalar("SELECT count(*) FROM aapi.sessions") + .fetch_one(&pool) + .await?; + + assert_eq!(count, 1); + Ok(()) + } + + #[sqlx::test(migrations = false)] + async fn deletes_all_candidate_sessions(pool: PgPool) -> sqlx::Result<()> { + setup(&pool).await?; + let db = database_as(&pool, Role::Aapi).await?; + let candidate_id = candidate_id(&db).await?; + for id in ["a", "b"] { + db.insert_candidate_session(&session(id, candidate_id, Duration::hours(1))) + .await?; + } + + db.delete_candidate_sessions(candidate_id).await?; + + assert!(db.find_candidate_session("a").await?.is_none()); + assert!(db.find_candidate_session("b").await?.is_none()); + Ok(()) + } + + #[sqlx::test(migrations = false)] + async fn deleting_candidate_cascades_to_sessions(pool: PgPool) -> sqlx::Result<()> { + setup(&pool).await?; + let db = database_as(&pool, Role::Aapi).await?; + let candidate_id = candidate_id(&db).await?; + db.insert_candidate_session(&session("sid", candidate_id, Duration::hours(1))) + .await?; + + sqlx::query("DELETE FROM aapi.candidates WHERE id = $1") + .bind(candidate_id) + .execute(&pool) + .await?; + + assert!(db.find_candidate_session("sid").await?.is_none()); + Ok(()) + } + + #[sqlx::test(migrations = false)] + async fn edd_cannot_read_candidate_sessions(pool: PgPool) -> sqlx::Result<()> { + setup(&pool).await?; + let mut tx = pool.begin().await?; + sqlx::query(set_local_role(Role::Edd)) + .execute(&mut *tx) + .await?; + + let result = sqlx::query("SELECT count(*) FROM aapi.sessions") + .execute(&mut *tx) + .await; + + assert_denied(result); + Ok(()) + } +} diff --git a/databases/examiner/src/edd/sessions.rs b/databases/examiner/src/edd/sessions.rs index de5fe39..2fbedd8 100644 --- a/databases/examiner/src/edd/sessions.rs +++ b/databases/examiner/src/edd/sessions.rs @@ -1,8 +1,7 @@ -use sha2::{Digest, Sha256}; use time::OffsetDateTime; use uuid::Uuid; -use crate::Database; +use crate::{Database, session_id_hash}; #[derive(Debug, Clone)] pub struct Session { @@ -12,17 +11,13 @@ pub struct Session { pub expires_at: OffsetDateTime, } -fn hash(session_id: &str) -> Vec { - Sha256::digest(session_id.as_bytes()).to_vec() -} - impl Database { /// Also deletes expired sessions: Postgres has no TTL index, and reads skip them anyway. pub async fn insert_session(&self, session: &Session) -> Result<(), sqlx::Error> { sqlx::query!( "WITH expired AS (DELETE FROM edd.sessions WHERE expires_at <= now()) INSERT INTO edd.sessions (id_hash, user_id, expires_at) VALUES ($1, $2, $3)", - hash(&session.session_id), + session_id_hash(&session.session_id), session.user_id, session.expires_at ) @@ -36,7 +31,7 @@ impl Database { pub async fn find_session(&self, session_id: &str) -> Result, sqlx::Error> { let row = sqlx::query!( "SELECT user_id, expires_at FROM edd.sessions WHERE id_hash = $1 AND expires_at > now()", - hash(session_id) + session_id_hash(session_id) ) .fetch_optional(&self.pool) .await?; @@ -97,7 +92,7 @@ mod tests { .fetch_one(&pool) .await?; - assert_eq!(stored, hash("sid")); + assert_eq!(stored, session_id_hash("sid")); assert_eq!( db.find_session("sid").await?.map(|s| s.user_id), Some(user_id) diff --git a/databases/examiner/src/lib.rs b/databases/examiner/src/lib.rs index 0ae4de3..82b42cd 100644 --- a/databases/examiner/src/lib.rs +++ b/databases/examiner/src/lib.rs @@ -7,6 +7,7 @@ //! rolled back eDd or aAPI must still run against the newer schema. use std::{str::FromStr, time::Duration}; +use sha2::{Digest, Sha256}; use sqlx::{ ConnectOptions, PgPool, migrate::{MigrateError, Migrator}, @@ -17,7 +18,7 @@ mod aapi; mod edd; mod shared; -pub use aapi::{Candidate, NewCandidate}; +pub use aapi::{Candidate, CandidateSession, NewCandidate}; pub use edd::{ InvalidObjectHash, NewObject, NewUser, Object, ObjectHash, ObjectKey, ObjectKeyLock, Session, User, @@ -49,6 +50,10 @@ pub async fn migrate(url: &str) -> Result<(), MigrateError> { migrator().run(&mut conn).await } +pub(crate) fn session_id_hash(session_id: &str) -> Vec { + Sha256::digest(session_id.as_bytes()).to_vec() +} + #[derive(Debug, Clone)] pub struct Database { pool: PgPool, diff --git a/deploy/exam-platform.toml b/deploy/exam-platform.toml index b79458b..2603805 100644 --- a/deploy/exam-platform.toml +++ b/deploy/exam-platform.toml @@ -33,6 +33,8 @@ IMAGE=[[EXAM_PLATFORM_AUTH_API_IMAGE]] ENVIRONMENT=staging SENTRY_DSN=[[EXAM_PLATFORM_AUTH_API_SENTRY_DSN]] EDB_DATABASE_URL=[[EXAM_PLATFORM_AUTH_API_EDB_DATABASE_URL]] +COOKIE_KEY=[[EXAM_PLATFORM_AUTH_API_COOKIE_KEY]] +WEB_APP_ORIGINS=https://web-app.freecodecamp.dev MIGRATE_IMAGE=[[EXAM_PLATFORM_AUTH_API_MIGRATE_IMAGE]] EDB_MIGRATOR_URL=[[EXAM_PLATFORM_EXAMINER_DB_MIGRATOR_URL]] """