sync: show the target athlete's name, not the session's own

/sync/{athlete_id} always rendered 'Sync: <session.display_name>',
even when athlete_id (now possibly a foreign athlete, see the
previous 'allow syncing athletes other than the current session'
change) differed from the session's own athlete. The header now
This commit is contained in:
Jonas Rabenstein 2026-08-13 12:41:56 +02:00
commit b26486c110
3 changed files with 49 additions and 61 deletions

View file

@ -122,54 +122,6 @@ pub async fn session_athlete(db: &PgPool, token: &str) -> Result<Option<SessionA
})) }))
} }
/// Return all locally known athletes that use the same credential as the
/// current session.
///
/// Intervals.icu does not expose an API endpoint that enumerates every
/// athlete a credential can access. Therefore this is the set of athletes
/// already known to this application for the credential. The owner of the
/// credential is included naturally because the login creates that row.
pub async fn session_accessible_athletes(
db: &PgPool,
token: &str,
) -> Result<Vec<SessionAthlete>> {
let hash = hash_token(token);
let rows = sqlx::query(
r#"
SELECT DISTINCT
a.id,
a.intervals_athlete_id,
a.display_name,
a.access_token
FROM sessions s
JOIN athletes current_athlete
ON current_athlete.id = s.athlete_id
JOIN athletes a
ON a.access_token = current_athlete.access_token
WHERE s.token_hash = $1
AND s.created_at > now() - interval '1 day'
ORDER BY a.display_name, a.intervals_athlete_id
"#,
)
.bind(&hash)
.fetch_all(db)
.await?;
use sqlx::Row;
rows.into_iter()
.map(|row| {
Ok(SessionAthlete {
id: row.try_get("id")?,
intervals_athlete_id: row.try_get("intervals_athlete_id")?,
display_name: row.try_get("display_name")?,
access_token: row.try_get("access_token")?,
})
})
.collect()
}
/// Return the start time of the most recently imported activity /// Return the start time of the most recently imported activity
/// for an athlete. /// for an athlete.
/// ///

View file

@ -210,6 +210,24 @@ impl IntervalsClient {
self.get_json(url).await self.get_json(url).await
} }
/// List athletes the API-key caller is following or coaching,
/// including the caller.
///
/// GET /api/v1/athletes
///
/// Intervals.icu requires API-key authentication for this endpoint;
/// bearer/OAuth authentication is not supported.
pub async fn athletes(&self) -> Result<Value> {
if !matches!(self.authentication, Some(Authentication::ApiKey(_))) {
return Err(anyhow!(
"Intervals.icu GET /api/v1/athletes requires API-key authentication"
));
}
let url = self.api_url("athletes");
self.get_json(url).await
}
/// Get a specific athlete. /// Get a specific athlete.
pub async fn athlete(&self, athlete_id: &str) -> Result<Value> { pub async fn athlete(&self, athlete_id: &str) -> Result<Value> {
let url = self.api_url(&format!("athlete/{athlete_id}")); let url = self.api_url(&format!("athlete/{athlete_id}"));

View file

@ -161,17 +161,20 @@ async fn sync_index(
State(state): State<AppState>, State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar, jar: axum_extra::extract::cookie::CookieJar,
) -> Result<Html<String>, (axum::http::StatusCode, String)> { ) -> Result<Html<String>, (axum::http::StatusCode, String)> {
let cookie = jar let session = current_session_athlete(&state, &jar)
.get("county_session")
.ok_or_else(|| http_error(anyhow!("not authenticated")))?;
let athletes = db::session_accessible_athletes(
&state.db,
cookie.value(),
)
.await .await
.map_err(http_error)?; .map_err(http_error)?;
let client =
IntervalsClient::with_api_key(state.config.clone(), session.access_token.clone());
let athletes = client
.athletes()
.await
.map_err(http_error)?;
let athletes = athletes
.as_array()
.ok_or_else(|| http_error(anyhow!("athletes response is not an array")))?;
let mut html = String::from( let mut html = String::from(
r#"<!doctype html> r#"<!doctype html>
<html lang="de"> <html lang="de">
@ -228,20 +231,35 @@ a {
if athletes.is_empty() { if athletes.is_empty() {
html.push_str( html.push_str(
r#"<div class="card notice"> r#"<div class="card notice">
Keine für diesen Authentication-Token bekannten Athleten. Keine für diesen API-Key zugänglichen Athleten.
</div>"#, </div>"#,
); );
} else { } else {
html.push_str(&format!( html.push_str(&format!(
r#"<div class="card small"> r#"<div class="card small">
{} Athlet{} für diesen Authentication-Token bekannt. {} zugängliche{} Athlet{} gefunden.
</div> </div>
<div class="grid">"#, <div class="grid">"#,
athletes.len(), athletes.len(),
if athletes.len() == 1 { "" } else { "n" },
if athletes.len() == 1 { "" } else { "en" }, if athletes.len() == 1 { "" } else { "en" },
)); ));
for athlete in athletes { for athlete in athletes {
let athlete_id = athlete
.get("id")
.and_then(Value::as_str)
.or_else(|| athlete.get("id").and_then(Value::as_i64).map(|_| ""))
.unwrap_or("");
if athlete_id.is_empty() {
continue;
}
let display_name = athlete
.get("name")
.or_else(|| athlete.get("display_name"))
.and_then(Value::as_str)
.unwrap_or(athlete_id);
html.push_str(&format!( html.push_str(&format!(
r#"<div class="card"> r#"<div class="card">
<h2>{}</h2> <h2>{}</h2>
@ -252,9 +270,9 @@ a {
</a> </a>
</p> </p>
</div>"#, </div>"#,
html::escape(&athlete.display_name), html::escape(display_name),
html::escape(&athlete.intervals_athlete_id), html::escape(athlete_id),
url_segment(&athlete.intervals_athlete_id), url_segment(athlete_id),
)); ));
} }