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
/// for an athlete.
///

View file

@ -210,6 +210,24 @@ impl IntervalsClient {
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.
pub async fn athlete(&self, athlete_id: &str) -> Result<Value> {
let url = self.api_url(&format!("athlete/{athlete_id}"));

View file

@ -161,17 +161,20 @@ async fn sync_index(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
) -> Result<Html<String>, (axum::http::StatusCode, String)> {
let cookie = jar
.get("county_session")
.ok_or_else(|| http_error(anyhow!("not authenticated")))?;
let athletes = db::session_accessible_athletes(
&state.db,
cookie.value(),
)
let session = current_session_athlete(&state, &jar)
.await
.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(
r#"<!doctype html>
<html lang="de">
@ -228,20 +231,35 @@ a {
if athletes.is_empty() {
html.push_str(
r#"<div class="card notice">
Keine für diesen Authentication-Token bekannten Athleten.
Keine für diesen API-Key zugänglichen Athleten.
</div>"#,
);
} else {
html.push_str(&format!(
r#"<div class="card small">
{} Athlet{} für diesen Authentication-Token bekannt.
{} zugängliche{} Athlet{} gefunden.
</div>
<div class="grid">"#,
athletes.len(),
if athletes.len() == 1 { "" } else { "n" },
if athletes.len() == 1 { "" } else { "en" },
));
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!(
r#"<div class="card">
<h2>{}</h2>
@ -252,9 +270,9 @@ a {
</a>
</p>
</div>"#,
html::escape(&athlete.display_name),
html::escape(&athlete.intervals_athlete_id),
url_segment(&athlete.intervals_athlete_id),
html::escape(display_name),
html::escape(athlete_id),
url_segment(athlete_id),
));
}