Allow setting custom names on sessions (#4459)

This commit is contained in:
Quentin Gliech
2025-04-30 15:32:25 +02:00
committed by GitHub
60 changed files with 1479 additions and 174 deletions

View File

@@ -305,7 +305,7 @@ impl Options {
let compat_session = repo
.compat_session()
.add(&mut rng, &clock, &user, device, None, admin)
.add(&mut rng, &clock, &user, device, None, admin, None)
.await?;
let token = TokenType::CompatAccessToken.generate(&mut rng);

View File

@@ -160,6 +160,7 @@ pub struct AuthorizationGrant {
pub response_type_id_token: bool,
pub created_at: DateTime<Utc>,
pub login_hint: Option<String>,
pub locale: Option<String>,
}
impl std::ops::Deref for AuthorizationGrant {
@@ -263,6 +264,7 @@ impl AuthorizationGrant {
response_type_id_token: false,
created_at: now,
login_hint: Some(String::from("mxid:@example-user:example.com")),
locale: Some(String::from("fr")),
}
}
}

View File

@@ -83,6 +83,7 @@ pub struct Session {
pub user_agent: Option<String>,
pub last_active_at: Option<DateTime<Utc>>,
pub last_active_ip: Option<IpAddr>,
pub human_name: Option<String>,
}
impl std::ops::Deref for Session {

View File

@@ -184,6 +184,9 @@ pub struct CompatSession {
/// The time this session was finished
pub finished_at: Option<DateTime<Utc>>,
/// The user-provided name, if any
pub human_name: Option<String>,
}
impl
@@ -210,6 +213,7 @@ impl
last_active_at: session.last_active_at,
last_active_ip: session.last_active_ip,
finished_at,
human_name: session.human_name,
}
}
}
@@ -237,6 +241,7 @@ impl CompatSession {
last_active_at: Some(DateTime::default()),
last_active_ip: Some([1, 2, 3, 4].into()),
finished_at: None,
human_name: Some("Laptop".to_owned()),
},
Self {
id: Ulid::from_bytes([0x02; 16]),
@@ -249,6 +254,7 @@ impl CompatSession {
last_active_at: Some(DateTime::default()),
last_active_ip: Some([1, 2, 3, 4].into()),
finished_at: Some(DateTime::default()),
human_name: None,
},
Self {
id: Ulid::from_bytes([0x03; 16]),
@@ -261,6 +267,7 @@ impl CompatSession {
last_active_at: None,
last_active_ip: None,
finished_at: None,
human_name: None,
},
]
}
@@ -301,6 +308,9 @@ pub struct OAuth2Session {
/// The last IP address used by the session
last_active_ip: Option<IpAddr>,
/// The user-provided name, if any
human_name: Option<String>,
}
impl From<mas_data_model::Session> for OAuth2Session {
@@ -316,6 +326,7 @@ impl From<mas_data_model::Session> for OAuth2Session {
user_agent: session.user_agent,
last_active_at: session.last_active_at,
last_active_ip: session.last_active_ip,
human_name: session.human_name,
}
}
}
@@ -335,6 +346,7 @@ impl OAuth2Session {
user_agent: Some("Mozilla/5.0".to_owned()),
last_active_at: Some(DateTime::default()),
last_active_ip: Some("127.0.0.1".parse().unwrap()),
human_name: Some("Laptop".to_owned()),
},
Self {
id: Ulid::from_bytes([0x02; 16]),
@@ -347,6 +359,7 @@ impl OAuth2Session {
user_agent: None,
last_active_at: None,
last_active_ip: None,
human_name: None,
},
Self {
id: Ulid::from_bytes([0x03; 16]),
@@ -359,6 +372,7 @@ impl OAuth2Session {
user_agent: Some("Mozilla/5.0".to_owned()),
last_active_at: Some(DateTime::default()),
last_active_ip: Some("127.0.0.1".parse().unwrap()),
human_name: None,
},
]
}

View File

@@ -107,7 +107,7 @@ mod tests {
let device = Device::generate(&mut rng);
let session = repo
.compat_session()
.add(&mut rng, &state.clock, &user, device, None, false)
.add(&mut rng, &state.clock, &user, device, None, false, None)
.await
.unwrap();
repo.save().await.unwrap();
@@ -119,7 +119,7 @@ mod tests {
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
assert_json_snapshot!(body, @r###"
assert_json_snapshot!(body, @r#"
{
"data": {
"type": "compat-session",
@@ -133,7 +133,8 @@ mod tests {
"user_agent": null,
"last_active_at": null,
"last_active_ip": null,
"finished_at": null
"finished_at": null,
"human_name": null
},
"links": {
"self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07"
@@ -143,7 +144,7 @@ mod tests {
"self": "/api/admin/v1/compat-sessions/01FSHN9AG0QHEHKX2JNQ2A2D07"
}
}
"###);
"#);
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]

View File

@@ -251,7 +251,7 @@ mod tests {
let device = Device::generate(&mut rng);
repo.compat_session()
.add(&mut rng, &state.clock, &alice, device, None, false)
.add(&mut rng, &state.clock, &alice, device, None, false, None)
.await
.unwrap();
let device = Device::generate(&mut rng);
@@ -260,7 +260,7 @@ mod tests {
let session = repo
.compat_session()
.add(&mut rng, &state.clock, &bob, device, None, false)
.add(&mut rng, &state.clock, &bob, device, None, false, None)
.await
.unwrap();
state.clock.advance(Duration::minutes(1));
@@ -276,7 +276,7 @@ mod tests {
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
assert_json_snapshot!(body, @r###"
assert_json_snapshot!(body, @r#"
{
"meta": {
"count": 2
@@ -294,7 +294,8 @@ mod tests {
"user_agent": null,
"last_active_at": null,
"last_active_ip": null,
"finished_at": null
"finished_at": null,
"human_name": null
},
"links": {
"self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y"
@@ -312,7 +313,8 @@ mod tests {
"user_agent": null,
"last_active_at": null,
"last_active_ip": null,
"finished_at": "2022-01-16T14:43:00Z"
"finished_at": "2022-01-16T14:43:00Z",
"human_name": null
},
"links": {
"self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW"
@@ -325,7 +327,7 @@ mod tests {
"last": "/api/admin/v1/compat-sessions?page[last]=10"
}
}
"###);
"#);
// Filter by user
let request = Request::get(format!(
@@ -337,7 +339,7 @@ mod tests {
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
assert_json_snapshot!(body, @r###"
assert_json_snapshot!(body, @r#"
{
"meta": {
"count": 1
@@ -355,7 +357,8 @@ mod tests {
"user_agent": null,
"last_active_at": null,
"last_active_ip": null,
"finished_at": null
"finished_at": null,
"human_name": null
},
"links": {
"self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y"
@@ -368,7 +371,7 @@ mod tests {
"last": "/api/admin/v1/compat-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10"
}
}
"###);
"#);
// Filter by status (active)
let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=active")
@@ -377,7 +380,7 @@ mod tests {
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
assert_json_snapshot!(body, @r###"
assert_json_snapshot!(body, @r#"
{
"meta": {
"count": 1
@@ -395,7 +398,8 @@ mod tests {
"user_agent": null,
"last_active_at": null,
"last_active_ip": null,
"finished_at": null
"finished_at": null,
"human_name": null
},
"links": {
"self": "/api/admin/v1/compat-sessions/01FSHNB530AAPR7PEV8KNBZD5Y"
@@ -408,7 +412,7 @@ mod tests {
"last": "/api/admin/v1/compat-sessions?filter[status]=active&page[last]=10"
}
}
"###);
"#);
// Filter by status (finished)
let request = Request::get("/api/admin/v1/compat-sessions?filter[status]=finished")
@@ -417,7 +421,7 @@ mod tests {
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
assert_json_snapshot!(body, @r###"
assert_json_snapshot!(body, @r#"
{
"meta": {
"count": 1
@@ -435,7 +439,8 @@ mod tests {
"user_agent": null,
"last_active_at": null,
"last_active_ip": null,
"finished_at": "2022-01-16T14:43:00Z"
"finished_at": "2022-01-16T14:43:00Z",
"human_name": null
},
"links": {
"self": "/api/admin/v1/compat-sessions/01FSHNCZP0PPF7X0EVMJNECPZW"
@@ -448,6 +453,6 @@ mod tests {
"last": "/api/admin/v1/compat-sessions?filter[status]=finished&page[last]=10"
}
}
"###);
"#);
}
}

View File

@@ -110,7 +110,7 @@ mod tests {
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
assert_eq!(body["data"]["type"], "oauth2-session");
insta::assert_json_snapshot!(body, @r###"
insta::assert_json_snapshot!(body, @r#"
{
"data": {
"type": "oauth2-session",
@@ -124,7 +124,8 @@ mod tests {
"scope": "urn:mas:admin",
"user_agent": null,
"last_active_at": null,
"last_active_ip": null
"last_active_ip": null,
"human_name": null
},
"links": {
"self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY"
@@ -134,7 +135,7 @@ mod tests {
"self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY"
}
}
"###);
"#);
}
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]

View File

@@ -331,7 +331,7 @@ mod tests {
let response = state.request(request).await;
response.assert_status(StatusCode::OK);
let body: serde_json::Value = response.json();
insta::assert_json_snapshot!(body, @r###"
insta::assert_json_snapshot!(body, @r#"
{
"meta": {
"count": 1
@@ -349,7 +349,8 @@ mod tests {
"scope": "urn:mas:admin",
"user_agent": null,
"last_active_at": null,
"last_active_ip": null
"last_active_ip": null,
"human_name": null
},
"links": {
"self": "/api/admin/v1/oauth2-sessions/01FSHN9AG0MKGTBNZ16RDR3PVY"
@@ -362,6 +363,6 @@ mod tests {
"last": "/api/admin/v1/oauth2-sessions?page[last]=10"
}
}
"###);
"#);
}
}

View File

@@ -116,6 +116,9 @@ pub struct RequestBody {
/// this is not specified.
#[serde(default, skip_serializing_if = "Option::is_none")]
device_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
initial_device_display_name: Option<String>,
}
#[derive(Debug, Serialize, Deserialize)]
@@ -309,18 +312,20 @@ pub(crate) async fn post(
user,
password,
input.device_id, // TODO check for validity
input.initial_device_display_name,
)
.await?
}
(_, Credentials::Token { token }) => {
token_login(
&mut repo,
&mut rng,
&clock,
&mut repo,
&homeserver,
&token,
input.device_id,
&homeserver,
&mut rng,
input.initial_device_display_name,
)
.await?
}
@@ -387,12 +392,13 @@ pub(crate) async fn post(
}
async fn token_login(
repo: &mut BoxRepository,
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
repo: &mut BoxRepository,
homeserver: &dyn HomeserverConnection,
token: &str,
requested_device_id: Option<String>,
homeserver: &dyn HomeserverConnection,
rng: &mut (dyn RngCore + Send),
initial_device_display_name: Option<String>,
) -> Result<(CompatSession, User), RouteError> {
let login = repo
.compat_sso_login()
@@ -467,7 +473,11 @@ async fn token_login(
};
let mxid = homeserver.mxid(&browser_session.user.username);
homeserver
.create_device(&mxid, device.as_str())
.create_device(
&mxid,
device.as_str(),
initial_device_display_name.as_deref(),
)
.await
.map_err(RouteError::ProvisionDeviceFailed)?;
@@ -484,6 +494,7 @@ async fn token_login(
device,
Some(&browser_session),
false,
initial_device_display_name,
)
.await?;
@@ -505,6 +516,7 @@ async fn user_password_login(
username: String,
password: String,
requested_device_id: Option<String>,
initial_device_display_name: Option<String>,
) -> Result<(CompatSession, User), RouteError> {
// Try getting the localpart out of the MXID
let username = homeserver.localpart(&username).unwrap_or(&username);
@@ -566,7 +578,11 @@ async fn user_password_login(
Device::generate(&mut rng)
};
homeserver
.create_device(&mxid, device.as_str())
.create_device(
&mxid,
device.as_str(),
initial_device_display_name.as_deref(),
)
.await
.map_err(RouteError::ProvisionDeviceFailed)?;
@@ -576,7 +592,15 @@ async fn user_password_login(
let session = repo
.compat_session()
.add(&mut rng, clock, &user, device, None, false)
.add(
&mut rng,
clock,
&user,
device,
None,
false,
initial_device_display_name,
)
.await?;
Ok((session, user))

View File

@@ -165,6 +165,11 @@ impl CompatSession {
pub async fn last_active_at(&self) -> Option<DateTime<Utc>> {
self.session.last_active_at
}
/// A human-provided name for the session.
pub async fn human_name(&self) -> Option<&str> {
self.session.human_name.as_deref()
}
}
/// A compat SSO login represents a login done through the legacy Matrix login

View File

@@ -128,6 +128,11 @@ impl OAuth2Session {
pub async fn last_active_at(&self) -> Option<DateTime<Utc>> {
self.0.last_active_at
}
/// The user-provided name for this session.
pub async fn human_name(&self) -> Option<&str> {
self.0.human_name.as_deref()
}
}
/// The application type advertised by the client.

View File

@@ -64,6 +64,54 @@ impl EndCompatSessionPayload {
}
}
/// The input of the `setCompatSessionName` mutation.
#[derive(InputObject)]
pub struct SetCompatSessionNameInput {
/// The ID of the session to set the name of.
compat_session_id: ID,
/// The new name of the session.
human_name: String,
}
/// The payload of the `setCompatSessionName` mutation.
pub enum SetCompatSessionNamePayload {
/// The session was not found.
NotFound,
/// The session was updated.
Updated(mas_data_model::CompatSession),
}
/// The status of the `setCompatSessionName` mutation.
#[derive(Enum, Copy, Clone, PartialEq, Eq, Debug)]
enum SetCompatSessionNameStatus {
/// The session was updated.
Updated,
/// The session was not found.
NotFound,
}
#[Object]
impl SetCompatSessionNamePayload {
/// The status of the mutation.
async fn status(&self) -> SetCompatSessionNameStatus {
match self {
Self::Updated(_) => SetCompatSessionNameStatus::Updated,
Self::NotFound => SetCompatSessionNameStatus::NotFound,
}
}
/// The session that was updated.
async fn oauth2_session(&self) -> Option<CompatSession> {
match self {
Self::Updated(session) => Some(CompatSession::new(session.clone())),
Self::NotFound => None,
}
}
}
#[Object]
impl CompatSessionMutations {
async fn end_compat_session(
@@ -105,4 +153,50 @@ impl CompatSessionMutations {
Ok(EndCompatSessionPayload::Ended(Box::new(session)))
}
async fn set_compat_session_name(
&self,
ctx: &Context<'_>,
input: SetCompatSessionNameInput,
) -> Result<SetCompatSessionNamePayload, async_graphql::Error> {
let state = ctx.state();
let compat_session_id = NodeType::CompatSession.extract_ulid(&input.compat_session_id)?;
let requester = ctx.requester();
let mut repo = state.repository().await?;
let homeserver = state.homeserver_connection();
let session = repo.compat_session().lookup(compat_session_id).await?;
let Some(session) = session else {
return Ok(SetCompatSessionNamePayload::NotFound);
};
if !requester.is_owner_or_admin(&session) {
return Ok(SetCompatSessionNamePayload::NotFound);
}
let user = repo
.user()
.lookup(session.user_id)
.await?
.context("User not found")?;
let session = repo
.compat_session()
.set_human_name(session, Some(input.human_name.clone()))
.await?;
// Update the device on the homeserver side
let mxid = homeserver.mxid(&user.username);
if let Some(device) = session.device.as_ref() {
homeserver
.update_device_display_name(&mxid, device.as_str(), &input.human_name)
.await
.context("Failed to provision device")?;
}
repo.save().await?;
Ok(SetCompatSessionNamePayload::Updated(session))
}
}

View File

@@ -110,6 +110,54 @@ impl EndOAuth2SessionPayload {
}
}
/// The input of the `setOauth2SessionName` mutation.
#[derive(InputObject)]
pub struct SetOAuth2SessionNameInput {
/// The ID of the session to set the name of.
oauth2_session_id: ID,
/// The new name of the session.
human_name: String,
}
/// The payload of the `setOauth2SessionName` mutation.
pub enum SetOAuth2SessionNamePayload {
/// The session was not found.
NotFound,
/// The session was updated.
Updated(mas_data_model::Session),
}
/// The status of the `setOauth2SessionName` mutation.
#[derive(Enum, Copy, Clone, PartialEq, Eq, Debug)]
enum SetOAuth2SessionNameStatus {
/// The session was updated.
Updated,
/// The session was not found.
NotFound,
}
#[Object]
impl SetOAuth2SessionNamePayload {
/// The status of the mutation.
async fn status(&self) -> SetOAuth2SessionNameStatus {
match self {
Self::Updated(_) => SetOAuth2SessionNameStatus::Updated,
Self::NotFound => SetOAuth2SessionNameStatus::NotFound,
}
}
/// The session that was updated.
async fn oauth2_session(&self) -> Option<OAuth2Session> {
match self {
Self::Updated(session) => Some(OAuth2Session(session.clone())),
Self::NotFound => None,
}
}
}
#[Object]
impl OAuth2SessionMutations {
/// Create a new arbitrary OAuth 2.0 Session.
@@ -168,7 +216,7 @@ impl OAuth2SessionMutations {
for scope in &*session.scope {
if let Some(device) = Device::from_scope_token(scope) {
homeserver
.create_device(&mxid, device.as_str())
.create_device(&mxid, device.as_str(), None)
.await
.context("Failed to provision device")?;
}
@@ -247,4 +295,54 @@ impl OAuth2SessionMutations {
Ok(EndOAuth2SessionPayload::Ended(session))
}
async fn set_oauth2_session_name(
&self,
ctx: &Context<'_>,
input: SetOAuth2SessionNameInput,
) -> Result<SetOAuth2SessionNamePayload, async_graphql::Error> {
let state = ctx.state();
let oauth2_session_id = NodeType::OAuth2Session.extract_ulid(&input.oauth2_session_id)?;
let requester = ctx.requester();
let mut repo = state.repository().await?;
let homeserver = state.homeserver_connection();
let session = repo.oauth2_session().lookup(oauth2_session_id).await?;
let Some(session) = session else {
return Ok(SetOAuth2SessionNamePayload::NotFound);
};
if !requester.is_owner_or_admin(&session) {
return Ok(SetOAuth2SessionNamePayload::NotFound);
}
let user_id = session.user_id.context("Session has no user")?;
let user = repo
.user()
.lookup(user_id)
.await?
.context("User not found")?;
let session = repo
.oauth2_session()
.set_human_name(session, Some(input.human_name.clone()))
.await?;
// Update the device on the homeserver side
let mxid = homeserver.mxid(&user.username);
for scope in &*session.scope {
if let Some(device) = Device::from_scope_token(scope) {
homeserver
.update_device_display_name(&mxid, device.as_str(), &input.human_name)
.await
.context("Failed to provision device")?;
}
}
repo.save().await?;
Ok(SetOAuth2SessionNamePayload::Updated(session))
}
}

View File

@@ -203,6 +203,7 @@ where
Encrypter: FromRef<S>,
reqwest::Client: FromRef<S>,
SiteConfig: FromRef<S>,
Templates: FromRef<S>,
Arc<dyn HomeserverConnection>: FromRef<S>,
BoxClock: FromRequestParts<S>,
BoxRng: FromRequestParts<S>,

View File

@@ -274,6 +274,7 @@ pub(crate) async fn get(
response_mode,
response_type.has_id_token(),
params.auth.login_hint,
Some(locale.to_string()),
)
.await?;
let continue_grant = PostAuthAction::continue_grant(grant.id);

View File

@@ -18,6 +18,7 @@ use mas_axum_utils::{
use mas_data_model::{
AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, SiteConfig, TokenType,
};
use mas_i18n::DataLocale;
use mas_keystore::{Encrypter, Keystore};
use mas_matrix::HomeserverConnection;
use mas_oidc_client::types::scope::ScopeToken;
@@ -31,6 +32,7 @@ use mas_storage::{
},
user::BrowserSessionRepository,
};
use mas_templates::{DeviceNameContext, TemplateContext, Templates};
use oauth2_types::{
errors::{ClientError, ClientErrorCode},
pkce::CodeChallengeError,
@@ -261,6 +263,8 @@ impl IntoResponse for RouteError {
}
}
impl_from_error_for_route!(mas_i18n::DataError);
impl_from_error_for_route!(mas_templates::TemplateError);
impl_from_error_for_route!(mas_storage::RepositoryError);
impl_from_error_for_route!(mas_policy::EvaluationError);
impl_from_error_for_route!(super::IdTokenSignatureError);
@@ -281,6 +285,7 @@ pub(crate) async fn post(
State(homeserver): State<Arc<dyn HomeserverConnection>>,
State(site_config): State<SiteConfig>,
State(encrypter): State<Encrypter>,
State(templates): State<Templates>,
policy: Policy,
user_agent: Option<TypedHeader<headers::UserAgent>>,
client_authorization: ClientAuthorization<AccessTokenRequest>,
@@ -334,6 +339,7 @@ pub(crate) async fn post(
&site_config,
repo,
&homeserver,
&templates,
user_agent,
)
.await?
@@ -415,6 +421,7 @@ async fn authorization_code_grant(
site_config: &SiteConfig,
mut repo: BoxRepository,
homeserver: &Arc<dyn HomeserverConnection>,
templates: &Templates,
user_agent: Option<String>,
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
// Check that the client is allowed to use this grant type
@@ -482,6 +489,11 @@ async fn authorization_code_grant(
.await?
.ok_or(RouteError::NoSuchOAuthSession(session_id))?;
// Generate a device name
let lang: DataLocale = authz_grant.locale.as_deref().unwrap_or("en").parse()?;
let ctx = DeviceNameContext::new(client.clone(), user_agent.clone()).with_language(lang);
let device_name = templates.render_device_name(&ctx)?;
if let Some(user_agent) = user_agent {
session = repo
.oauth2_session()
@@ -567,7 +579,7 @@ async fn authorization_code_grant(
for scope in &*session.scope {
if let Some(device) = Device::from_scope_token(scope) {
homeserver
.create_device(&mxid, device.as_str())
.create_device(&mxid, device.as_str(), Some(&device_name))
.await
.map_err(RouteError::ProvisionDeviceFailed)?;
}
@@ -943,7 +955,7 @@ async fn device_code_grant(
for scope in &*session.scope {
if let Some(device) = Device::from_scope_token(scope) {
homeserver
.create_device(&mxid, device.as_str())
.create_device(&mxid, device.as_str(), None)
.await
.map_err(RouteError::ProvisionDeviceFailed)?;
}
@@ -1042,6 +1054,7 @@ mod tests {
ResponseMode::Query,
false,
None,
None,
)
.await
.unwrap();
@@ -1141,6 +1154,7 @@ mod tests {
ResponseMode::Query,
false,
None,
None,
)
.await
.unwrap();

View File

@@ -11,7 +11,7 @@ mod translator;
pub use icu_calendar;
pub use icu_datetime;
pub use icu_locid::locale;
pub use icu_provider::DataLocale;
pub use icu_provider::{DataError, DataLocale};
pub use self::{
sprintf::{Argument, ArgumentList, Message},

View File

@@ -133,6 +133,11 @@ struct SynapseDevice {
dehydrated: Option<bool>,
}
#[derive(Serialize)]
struct SynapseUpdateDeviceRequest<'a> {
display_name: Option<&'a str>,
}
#[derive(Serialize)]
struct SynapseDeleteDevicesRequest {
devices: Vec<String>,
@@ -318,11 +323,16 @@ impl HomeserverConnection for SynapseConnection {
),
err(Debug),
)]
async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> {
let mxid = urlencoding::encode(mxid);
async fn create_device(
&self,
mxid: &str,
device_id: &str,
initial_display_name: Option<&str>,
) -> Result<(), anyhow::Error> {
let encoded_mxid = urlencoding::encode(mxid);
let response = self
.post(&format!("_synapse/admin/v2/users/{mxid}/devices"))
.post(&format!("_synapse/admin/v2/users/{encoded_mxid}/devices"))
.json(&SynapseDevice {
device_id: device_id.to_owned(),
dehydrated: None,
@@ -343,6 +353,56 @@ impl HomeserverConnection for SynapseConnection {
);
}
// It's annoying, but the POST endpoint doesn't let us set the display name
// of the device, so we have to do it manually.
if let Some(display_name) = initial_display_name {
self.update_device_display_name(mxid, device_id, display_name)
.await?;
}
Ok(())
}
#[tracing::instrument(
name = "homeserver.update_device_display_name",
skip_all,
fields(
matrix.homeserver = self.homeserver,
matrix.mxid = mxid,
matrix.device_id = device_id,
),
err(Debug),
)]
async fn update_device_display_name(
&self,
mxid: &str,
device_id: &str,
display_name: &str,
) -> Result<(), anyhow::Error> {
let device_id = urlencoding::encode(device_id);
let response = self
.put(&format!(
"_synapse/admin/v2/users/{mxid}/devices/{device_id}"
))
.json(&SynapseUpdateDeviceRequest {
display_name: Some(display_name),
})
.send_traced()
.await
.context("Failed to update device display name in Synapse")?;
let response = response
.error_for_synapse_error()
.await
.context("Unexpected HTTP response while updating device display name in Synapse")?;
if response.status() != StatusCode::OK {
bail!(
"Unexpected HTTP code while updating device display name in Synapse: {}",
response.status()
);
}
Ok(())
}
@@ -454,7 +514,7 @@ impl HomeserverConnection for SynapseConnection {
// Then, create the devices that are missing. There is no batching API to do
// this, so we do this sequentially, which is fine as the API is idempotent.
for device_id in devices.difference(&existing_devices) {
self.create_device(mxid, device_id).await?;
self.create_device(mxid, device_id, None).await?;
}
Ok(())

View File

@@ -254,7 +254,31 @@ pub trait HomeserverConnection: Send + Sync {
///
/// Returns an error if the homeserver is unreachable or the device could
/// not be created.
async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error>;
async fn create_device(
&self,
mxid: &str,
device_id: &str,
initial_display_name: Option<&str>,
) -> Result<(), anyhow::Error>;
/// Update the display name of a device for a user on the homeserver.
///
/// # Parameters
///
/// * `mxid` - The Matrix ID of the user to update a device for.
/// * `device_id` - The device ID to update.
/// * `display_name` - The new display name to set
///
/// # Errors
///
/// Returns an error if the homeserver is unreachable or the device could
/// not be updated.
async fn update_device_display_name(
&self,
mxid: &str,
device_id: &str,
display_name: &str,
) -> Result<(), anyhow::Error>;
/// Delete a device for a user on the homeserver.
///
@@ -364,8 +388,26 @@ impl<T: HomeserverConnection + Send + Sync + ?Sized> HomeserverConnection for &T
(**self).is_localpart_available(localpart).await
}
async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> {
(**self).create_device(mxid, device_id).await
async fn create_device(
&self,
mxid: &str,
device_id: &str,
initial_display_name: Option<&str>,
) -> Result<(), anyhow::Error> {
(**self)
.create_device(mxid, device_id, initial_display_name)
.await
}
async fn update_device_display_name(
&self,
mxid: &str,
device_id: &str,
display_name: &str,
) -> Result<(), anyhow::Error> {
(**self)
.update_device_display_name(mxid, device_id, display_name)
.await
}
async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> {
@@ -420,8 +462,26 @@ impl<T: HomeserverConnection + ?Sized> HomeserverConnection for Arc<T> {
(**self).is_localpart_available(localpart).await
}
async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> {
(**self).create_device(mxid, device_id).await
async fn create_device(
&self,
mxid: &str,
device_id: &str,
initial_display_name: Option<&str>,
) -> Result<(), anyhow::Error> {
(**self)
.create_device(mxid, device_id, initial_display_name)
.await
}
async fn update_device_display_name(
&self,
mxid: &str,
device_id: &str,
display_name: &str,
) -> Result<(), anyhow::Error> {
(**self)
.update_device_display_name(mxid, device_id, display_name)
.await
}
async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> {

View File

@@ -107,13 +107,30 @@ impl crate::HomeserverConnection for HomeserverConnection {
Ok(!users.contains_key(&mxid))
}
async fn create_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> {
async fn create_device(
&self,
mxid: &str,
device_id: &str,
_initial_display_name: Option<&str>,
) -> Result<(), anyhow::Error> {
let mut users = self.users.write().await;
let user = users.get_mut(mxid).context("User not found")?;
user.devices.insert(device_id.to_owned());
Ok(())
}
async fn update_device_display_name(
&self,
mxid: &str,
device_id: &str,
_display_name: &str,
) -> Result<(), anyhow::Error> {
let mut users = self.users.write().await;
let user = users.get_mut(mxid).context("User not found")?;
user.devices.get(device_id).context("Device not found")?;
Ok(())
}
async fn delete_device(&self, mxid: &str, device_id: &str) -> Result<(), anyhow::Error> {
let mut users = self.users.write().await;
let user = users.get_mut(mxid).context("User not found")?;
@@ -191,7 +208,7 @@ mod tests {
assert_eq!(conn.mxid("test"), mxid);
assert!(conn.query_user(mxid).await.is_err());
assert!(conn.create_device(mxid, device).await.is_err());
assert!(conn.create_device(mxid, device, None).await.is_err());
assert!(conn.delete_device(mxid, device).await.is_err());
let request = ProvisionRequest::new("@test:example.org", "test")
@@ -222,9 +239,9 @@ mod tests {
assert!(conn.delete_device(mxid, device).await.is_ok());
// Create the device
assert!(conn.create_device(mxid, device).await.is_ok());
assert!(conn.create_device(mxid, device, None).await.is_ok());
// Create the same device again
assert!(conn.create_device(mxid, device).await.is_ok());
assert!(conn.create_device(mxid, device, None).await.is_ok());
// XXX: there is no API to query devices yet in the trait
// Delete the device

View File

@@ -40,10 +40,24 @@ impl<C: HomeserverConnection> HomeserverConnection for ReadOnlyHomeserverConnect
self.inner.is_localpart_available(localpart).await
}
async fn create_device(&self, _mxid: &str, _device_id: &str) -> Result<(), anyhow::Error> {
async fn create_device(
&self,
_mxid: &str,
_device_id: &str,
_initial_display_name: Option<&str>,
) -> Result<(), anyhow::Error> {
anyhow::bail!("Device creation is not supported in read-only mode");
}
async fn update_device_display_name(
&self,
_mxid: &str,
_device_id: &str,
_display_name: &str,
) -> Result<(), anyhow::Error> {
anyhow::bail!("Device display name update is not supported in read-only mode");
}
async fn delete_device(&self, _mxid: &str, _device_id: &str) -> Result<(), anyhow::Error> {
anyhow::bail!("Device deletion is not supported in read-only mode");
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT oauth2_session_id\n , user_id\n , user_session_id\n , oauth2_client_id\n , scope_list\n , created_at\n , finished_at\n , user_agent\n , last_active_at\n , last_active_ip as \"last_active_ip: IpAddr\"\n FROM oauth2_sessions\n\n WHERE oauth2_session_id = $1\n ",
"query": "\n SELECT oauth2_session_id\n , user_id\n , user_session_id\n , oauth2_client_id\n , scope_list\n , created_at\n , finished_at\n , user_agent\n , last_active_at\n , last_active_ip as \"last_active_ip: IpAddr\"\n , human_name\n FROM oauth2_sessions\n\n WHERE oauth2_session_id = $1\n ",
"describe": {
"columns": [
{
@@ -52,6 +52,11 @@
"ordinal": 9,
"name": "last_active_ip: IpAddr",
"type_info": "Inet"
},
{
"ordinal": 10,
"name": "human_name",
"type_info": "Text"
}
],
"parameters": {
@@ -69,8 +74,9 @@
true,
true,
true,
true,
true
]
},
"hash": "5a2e9b5002c1927c0035c22e393172b36ab46a4377b46618205151ea041886d5"
"hash": "6b8d28b76d7ab33178b46dbb28c11e41d86f22b3fa899a952cad00129e59bee6"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO oauth2_authorization_grants (\n oauth2_authorization_grant_id,\n oauth2_client_id,\n redirect_uri,\n scope,\n state,\n nonce,\n response_mode,\n code_challenge,\n code_challenge_method,\n response_type_code,\n response_type_id_token,\n authorization_code,\n login_hint,\n created_at\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\n ",
"query": "\n INSERT INTO oauth2_authorization_grants (\n oauth2_authorization_grant_id,\n oauth2_client_id,\n redirect_uri,\n scope,\n state,\n nonce,\n response_mode,\n code_challenge,\n code_challenge_method,\n response_type_code,\n response_type_id_token,\n authorization_code,\n login_hint,\n locale,\n created_at\n )\n VALUES\n ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ",
"describe": {
"columns": [],
"parameters": {
@@ -18,10 +18,11 @@
"Bool",
"Text",
"Text",
"Text",
"Timestamptz"
]
},
"nullable": []
},
"hash": "96208afd8b81e00f2700b0ded40c1eb529d321d0aa543be70f86ef96d0d8ff28"
"hash": "7a0641df5058927c5cd67d4cdaa59fe609112afbabcbfcc0e7f96c1e531b6567"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE oauth2_sessions\n SET human_name = $2\n WHERE oauth2_session_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "8afada5220fefb0d01ed6f87d3d0ee8fca86b5cdce9320e190e3d3b8fd9f63bc"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE authorization_code = $1\n ",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , locale\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE authorization_code = $1\n ",
"describe": {
"columns": [
{
@@ -90,6 +90,11 @@
},
{
"ordinal": 17,
"name": "locale",
"type_info": "Text"
},
{
"ordinal": 18,
"name": "oauth2_session_id",
"type_info": "Uuid"
}
@@ -117,8 +122,9 @@
true,
true,
true,
true,
true
]
},
"hash": "890516adeeaf2d6a4fe83a69e42e18b8817d1bb511e08ee761a031fa12d27251"
"hash": "8ef27901b96b73826a431ad6c5fabecc18c36d8cdba8db3b47953855fa5c9035"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE oauth2_authorization_grant_id = $1\n ",
"query": "\n SELECT oauth2_authorization_grant_id\n , created_at\n , cancelled_at\n , fulfilled_at\n , exchanged_at\n , scope\n , state\n , redirect_uri\n , response_mode\n , nonce\n , oauth2_client_id\n , authorization_code\n , response_type_code\n , response_type_id_token\n , code_challenge\n , code_challenge_method\n , login_hint\n , locale\n , oauth2_session_id\n FROM\n oauth2_authorization_grants\n\n WHERE oauth2_authorization_grant_id = $1\n ",
"describe": {
"columns": [
{
@@ -90,6 +90,11 @@
},
{
"ordinal": 17,
"name": "locale",
"type_info": "Text"
},
{
"ordinal": 18,
"name": "oauth2_session_id",
"type_info": "Uuid"
}
@@ -117,8 +122,9 @@
true,
true,
true,
true,
true
]
},
"hash": "bf6d1e3e3145438c988b1a47fc13f0168a63e278d8f8c947cb3ab65173f92ae4"
"hash": "c960f4f5571ee68816c49898125979f3c78c2caca52cb4b8dc9880e669a1f23e"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO compat_sessions\n (compat_session_id, user_id, device_id,\n user_session_id, created_at, is_synapse_admin)\n VALUES ($1, $2, $3, $4, $5, $6)\n ",
"query": "\n INSERT INTO compat_sessions\n (compat_session_id, user_id, device_id,\n user_session_id, created_at, is_synapse_admin,\n human_name)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ",
"describe": {
"columns": [],
"parameters": {
@@ -10,10 +10,11 @@
"Text",
"Uuid",
"Timestamptz",
"Bool"
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "cf1273b8aaaccedeb212a971d5e8e0dd23bfddab0ec08ee192783e103a1c4766"
"hash": "e99ab37ab3e03ad9c48792772b09bac77b09f67e623d5371ab4dadbe2d41fa1c"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE compat_sessions\n SET human_name = $2\n WHERE compat_session_id = $1\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "eb095f64bec5ac885683a8c6708320760971317c4519fae7af9d44e2be50985d"
}

View File

@@ -0,0 +1,8 @@
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- Track the locale of the user which asked for the authorization grant
ALTER TABLE oauth2_authorization_grants
ADD COLUMN locale TEXT;

View File

@@ -0,0 +1,8 @@
-- Copyright 2025 New Vector Ltd.
--
-- SPDX-License-Identifier: AGPL-3.0-only
-- Please see LICENSE in the repository root for full details.
-- Add a user-provided human name to OAuth 2.0 sessions
ALTER TABLE oauth2_sessions
ADD COLUMN human_name TEXT;

View File

@@ -192,6 +192,7 @@ impl TryFrom<AppSessionLookup> for AppSession {
user_agent,
last_active_at,
last_active_ip,
human_name,
};
Ok(AppSession::OAuth2(Box::new(session)))
@@ -299,7 +300,10 @@ impl AppSessionRepository for PgAppSessionRepository<'_> {
AppSessionLookupIden::ScopeList,
)
.expr_as(Expr::cust("NULL"), AppSessionLookupIden::DeviceId)
.expr_as(Expr::cust("NULL"), AppSessionLookupIden::HumanName)
.expr_as(
Expr::col((OAuth2Sessions::Table, OAuth2Sessions::HumanName)),
AppSessionLookupIden::HumanName,
)
.expr_as(
Expr::col((OAuth2Sessions::Table, OAuth2Sessions::CreatedAt)),
AppSessionLookupIden::CreatedAt,
@@ -571,7 +575,7 @@ mod tests {
let device = Device::generate(&mut rng);
let compat_session = repo
.compat_session()
.add(&mut rng, &clock, &user, device.clone(), None, false)
.add(&mut rng, &clock, &user, device.clone(), None, false, None)
.await
.unwrap();

View File

@@ -79,7 +79,7 @@ mod tests {
let device_str = device.as_str().to_owned();
let session = repo
.compat_session()
.add(&mut rng, &clock, &user, device.clone(), None, false)
.add(&mut rng, &clock, &user, device.clone(), None, false, None)
.await
.unwrap();
assert_eq!(session.user_id, user.id);
@@ -227,6 +227,7 @@ mod tests {
device,
Some(&browser_session),
false,
None,
)
.await
.unwrap();
@@ -331,7 +332,7 @@ mod tests {
let device = Device::generate(&mut rng);
let session = repo
.compat_session()
.add(&mut rng, &clock, &user, device, None, false)
.add(&mut rng, &clock, &user, device, None, false, None)
.await
.unwrap();
@@ -452,7 +453,7 @@ mod tests {
let device = Device::generate(&mut rng);
let session = repo
.compat_session()
.add(&mut rng, &clock, &user, device, None, false)
.add(&mut rng, &clock, &user, device, None, false, None)
.await
.unwrap();
@@ -618,7 +619,7 @@ mod tests {
let device = Device::generate(&mut rng);
let compat_session = repo
.compat_session()
.add(&mut rng, &clock, &user, device, None, false)
.add(&mut rng, &clock, &user, device, None, false, None)
.await
.unwrap();

View File

@@ -305,6 +305,7 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> {
device: Device,
browser_session: Option<&BrowserSession>,
is_synapse_admin: bool,
human_name: Option<String>,
) -> Result<CompatSession, Self::Error> {
let created_at = clock.now();
let id = Ulid::from_datetime_with_source(created_at.into(), rng);
@@ -314,8 +315,9 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> {
r#"
INSERT INTO compat_sessions
(compat_session_id, user_id, device_id,
user_session_id, created_at, is_synapse_admin)
VALUES ($1, $2, $3, $4, $5, $6)
user_session_id, created_at, is_synapse_admin,
human_name)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
Uuid::from(id),
Uuid::from(user.id),
@@ -323,6 +325,7 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> {
browser_session.map(|s| Uuid::from(s.id)),
created_at,
is_synapse_admin,
human_name.as_deref(),
)
.traced()
.execute(&mut *self.conn)
@@ -333,7 +336,7 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> {
state: CompatSessionState::default(),
user_id: user.id,
device: Some(device),
human_name: None,
human_name,
user_session_id: browser_session.map(|s| s.id),
created_at,
is_synapse_admin,
@@ -622,4 +625,38 @@ impl CompatSessionRepository for PgCompatSessionRepository<'_> {
Ok(compat_session)
}
#[tracing::instrument(
name = "repository.compat_session.set_human_name",
skip(self),
fields(
compat_session.id = %compat_session.id,
compat_session.human_name = ?human_name,
),
err,
)]
async fn set_human_name(
&mut self,
mut compat_session: CompatSession,
human_name: Option<String>,
) -> Result<CompatSession, Self::Error> {
let res = sqlx::query!(
r#"
UPDATE compat_sessions
SET human_name = $2
WHERE compat_session_id = $1
"#,
Uuid::from(compat_session.id),
human_name.as_deref(),
)
.traced()
.execute(&mut *self.conn)
.await?;
compat_session.human_name = human_name;
DatabaseError::ensure_affected_rows(&res, 1)?;
Ok(compat_session)
}
}

View File

@@ -83,6 +83,7 @@ pub enum OAuth2Sessions {
UserAgent,
LastActiveAt,
LastActiveIp,
HumanName,
}
#[derive(sea_query::Iden)]

View File

@@ -52,6 +52,7 @@ struct GrantLookup {
code_challenge: Option<String>,
code_challenge_method: Option<String>,
login_hint: Option<String>,
locale: Option<String>,
oauth2_client_id: Uuid,
oauth2_session_id: Option<Uuid>,
}
@@ -162,6 +163,7 @@ impl TryFrom<GrantLookup> for AuthorizationGrant {
created_at: value.created_at,
response_type_id_token: value.response_type_id_token,
login_hint: value.login_hint,
locale: value.locale,
})
}
}
@@ -194,6 +196,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
response_mode: ResponseMode,
response_type_id_token: bool,
login_hint: Option<String>,
locale: Option<String>,
) -> Result<AuthorizationGrant, Self::Error> {
let code_challenge = code
.as_ref()
@@ -225,10 +228,11 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
response_type_id_token,
authorization_code,
login_hint,
locale,
created_at
)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
"#,
Uuid::from(id),
Uuid::from(client.id),
@@ -243,6 +247,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
response_type_id_token,
code_str,
login_hint,
locale,
created_at,
)
.traced()
@@ -262,6 +267,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
created_at,
response_type_id_token,
login_hint,
locale,
})
}
@@ -295,6 +301,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
, code_challenge
, code_challenge_method
, login_hint
, locale
, oauth2_session_id
FROM
oauth2_authorization_grants
@@ -344,6 +351,7 @@ impl OAuth2AuthorizationGrantRepository for PgOAuth2AuthorizationGrantRepository
, code_challenge
, code_challenge_method
, login_hint
, locale
, oauth2_session_id
FROM
oauth2_authorization_grants

View File

@@ -138,6 +138,7 @@ mod tests {
ResponseMode::Query,
true,
None,
None,
)
.await
.unwrap();

View File

@@ -55,6 +55,7 @@ struct OAuthSessionLookup {
user_agent: Option<String>,
last_active_at: Option<DateTime<Utc>>,
last_active_ip: Option<IpAddr>,
human_name: Option<String>,
}
impl TryFrom<OAuthSessionLookup> for Session {
@@ -90,6 +91,7 @@ impl TryFrom<OAuthSessionLookup> for Session {
user_agent: value.user_agent,
last_active_at: value.last_active_at,
last_active_ip: value.last_active_ip,
human_name: value.human_name,
})
}
}
@@ -195,6 +197,7 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> {
, user_agent
, last_active_at
, last_active_ip as "last_active_ip: IpAddr"
, human_name
FROM oauth2_sessions
WHERE oauth2_session_id = $1
@@ -270,6 +273,7 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> {
user_agent: None,
last_active_at: None,
last_active_ip: None,
human_name: None,
})
}
@@ -392,6 +396,10 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> {
Expr::col((OAuth2Sessions::Table, OAuth2Sessions::LastActiveIp)),
OAuthSessionLookupIden::LastActiveIp,
)
.expr_as(
Expr::col((OAuth2Sessions::Table, OAuth2Sessions::HumanName)),
OAuthSessionLookupIden::HumanName,
)
.from(OAuth2Sessions::Table)
.apply_filter(filter)
.generate_pagination(
@@ -521,4 +529,38 @@ impl OAuth2SessionRepository for PgOAuth2SessionRepository<'_> {
Ok(session)
}
#[tracing::instrument(
name = "repository.oauth2_session.set_human_name",
skip(self),
fields(
client.id = %session.client_id,
session.human_name = ?human_name,
),
err,
)]
async fn set_human_name(
&mut self,
mut session: Session,
human_name: Option<String>,
) -> Result<Session, Self::Error> {
let res = sqlx::query!(
r#"
UPDATE oauth2_sessions
SET human_name = $2
WHERE oauth2_session_id = $1
"#,
Uuid::from(session.id),
human_name.as_deref(),
)
.traced()
.execute(&mut *self.conn)
.await?;
session.human_name = human_name;
DatabaseError::ensure_affected_rows(&res, 1)?;
Ok(session)
}
}

View File

@@ -215,10 +215,13 @@ pub trait CompatSessionRepository: Send + Sync {
/// * `device`: The device ID of this session
/// * `browser_session`: The browser session which created this session
/// * `is_synapse_admin`: Whether the session is a synapse admin session
/// * `human_name`: The human-readable name of the session provided by the
/// client or the user
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
#[expect(clippy::too_many_arguments)]
async fn add(
&mut self,
rng: &mut (dyn RngCore + Send),
@@ -227,6 +230,7 @@ pub trait CompatSessionRepository: Send + Sync {
device: Device,
browser_session: Option<&BrowserSession>,
is_synapse_admin: bool,
human_name: Option<String>,
) -> Result<CompatSession, Self::Error>;
/// End a compat session
@@ -324,6 +328,22 @@ pub trait CompatSessionRepository: Send + Sync {
compat_session: CompatSession,
user_agent: String,
) -> Result<CompatSession, Self::Error>;
/// Set the human name of a compat session
///
/// # Parameters
///
/// * `compat_session`: The compat session to set the human name for
/// * `human_name`: The human name to set
///
/// # Errors
///
/// Returns [`Self::Error`] if the underlying repository fails
async fn set_human_name(
&mut self,
compat_session: CompatSession,
human_name: Option<String>,
) -> Result<CompatSession, Self::Error>;
}
repository_impl!(CompatSessionRepository:
@@ -337,6 +357,7 @@ repository_impl!(CompatSessionRepository:
device: Device,
browser_session: Option<&BrowserSession>,
is_synapse_admin: bool,
human_name: Option<String>,
) -> Result<CompatSession, Self::Error>;
async fn finish(
@@ -369,4 +390,10 @@ repository_impl!(CompatSessionRepository:
compat_session: CompatSession,
user_agent: String,
) -> Result<CompatSession, Self::Error>;
async fn set_human_name(
&mut self,
compat_session: CompatSession,
human_name: Option<String>,
) -> Result<CompatSession, Self::Error>;
);

View File

@@ -39,6 +39,8 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync {
/// * `response_type_id_token`: Whether the `id_token` `response_type` was
/// requested
/// * `login_hint`: The login_hint the client sent, if set
/// * `locale`: The locale the detected when the user asked for the
/// authorization grant
///
/// # Errors
///
@@ -57,6 +59,7 @@ pub trait OAuth2AuthorizationGrantRepository: Send + Sync {
response_mode: ResponseMode,
response_type_id_token: bool,
login_hint: Option<String>,
locale: Option<String>,
) -> Result<AuthorizationGrant, Self::Error>;
/// Lookup an authorization grant by its ID
@@ -140,6 +143,7 @@ repository_impl!(OAuth2AuthorizationGrantRepository:
response_mode: ResponseMode,
response_type_id_token: bool,
login_hint: Option<String>,
locale: Option<String>,
) -> Result<AuthorizationGrant, Self::Error>;
async fn lookup(&mut self, id: Ulid) -> Result<Option<AuthorizationGrant>, Self::Error>;

View File

@@ -430,6 +430,18 @@ pub trait OAuth2SessionRepository: Send + Sync {
session: Session,
user_agent: String,
) -> Result<Session, Self::Error>;
/// Set the human name of a [`Session`]
///
/// # Parameters
///
/// * `session`: The [`Session`] to set the human name for
/// * `human_name`: The human name to set
async fn set_human_name(
&mut self,
session: Session,
human_name: Option<String>,
) -> Result<Session, Self::Error>;
}
repository_impl!(OAuth2SessionRepository:
@@ -489,4 +501,10 @@ repository_impl!(OAuth2SessionRepository:
session: Session,
user_agent: String,
) -> Result<Session, Self::Error>;
async fn set_human_name(
&mut self,
session: Session,
human_name: Option<String>,
) -> Result<Session, Self::Error>;
);

View File

@@ -1564,6 +1564,39 @@ impl TemplateContext for AccountInactiveContext {
}
}
/// Context used by the `device_name.txt` template
#[derive(Serialize)]
pub struct DeviceNameContext {
client: Client,
raw_user_agent: String,
}
impl DeviceNameContext {
/// Constructs a new context with a client and user agent
#[must_use]
pub fn new(client: Client, user_agent: Option<String>) -> Self {
Self {
client,
raw_user_agent: user_agent.unwrap_or_default(),
}
}
}
impl TemplateContext for DeviceNameContext {
fn sample(now: chrono::DateTime<Utc>, rng: &mut impl Rng) -> Vec<Self>
where
Self: Sized,
{
Client::samples(now, rng)
.into_iter()
.map(|client| DeviceNameContext {
client,
raw_user_agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned(),
})
.collect()
}
}
/// Context used by the `form_post.html` template
#[derive(Serialize)]
pub struct FormPostContext<T> {

View File

@@ -40,6 +40,7 @@ pub fn register(
env.add_filter("to_params", filter_to_params);
env.add_filter("simplify_url", filter_simplify_url);
env.add_filter("add_slashes", filter_add_slashes);
env.add_filter("parse_user_agent", filter_parse_user_agent);
env.add_function("add_params_to_url", function_add_params_to_url);
env.add_function("counter", || Ok(Value::from_object(Counter::default())));
env.add_global(
@@ -133,6 +134,12 @@ fn filter_simplify_url(url: &str, kwargs: Kwargs) -> Result<String, minijinja::E
}
}
/// Filter which parses a user-agent string
fn filter_parse_user_agent(user_agent: String) -> Value {
let user_agent = mas_data_model::UserAgent::parse(user_agent);
Value::from_serialize(user_agent)
}
enum ParamsWhere {
Fragment,
Query,

View File

@@ -35,13 +35,13 @@ mod macros;
pub use self::{
context::{
AccountInactiveContext, ApiDocContext, AppContext, CompatSsoContext, ConsentContext,
DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, EmailRecoveryContext,
EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
LoginContext, LoginFormField, NotFoundContext, PasswordRegisterContext,
PolicyViolationContext, PostAuthContext, PostAuthContextInner, RecoveryExpiredContext,
RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext,
RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField,
RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
DeviceConsentContext, DeviceLinkContext, DeviceLinkFormField, DeviceNameContext,
EmailRecoveryContext, EmailVerificationContext, EmptyContext, ErrorContext,
FormPostContext, IndexContext, LoginContext, LoginFormField, NotFoundContext,
PasswordRegisterContext, PolicyViolationContext, PostAuthContext, PostAuthContextInner,
RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField,
RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext,
RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField,
RegisterStepsEmailInUseContext, RegisterStepsVerifyEmailContext,
RegisterStepsVerifyEmailFormField, SiteBranding, SiteConfigExt, SiteFeatures,
TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamRegisterFormField,
@@ -417,6 +417,9 @@ register_templates! {
/// Render the 'account logged out' page
pub fn render_account_logged_out(WithLanguage<WithCsrf<AccountInactiveContext>>) { "pages/account/logged_out.html" }
/// Render the automatic device name for OAuth 2.0 client
pub fn render_device_name(WithLanguage<DeviceNameContext>) { "device_name.txt" }
}
impl Templates {
@@ -459,6 +462,7 @@ impl Templates {
check::render_upstream_oauth2_link_mismatch(self, now, rng)?;
check::render_upstream_oauth2_suggest_link(self, now, rng)?;
check::render_upstream_oauth2_do_register(self, now, rng)?;
check::render_device_name(self, now, rng)?;
Ok(())
}
}

View File

@@ -132,7 +132,8 @@
"user_agent": "Mozilla/5.0",
"last_active_at": "1970-01-01T00:00:00Z",
"last_active_ip": "1.2.3.4",
"finished_at": null
"finished_at": null,
"human_name": "Laptop"
},
"links": {
"self": "/api/admin/v1/compat-sessions/01040G2081040G2081040G2081"
@@ -150,7 +151,8 @@
"user_agent": "Mozilla/5.0",
"last_active_at": "1970-01-01T00:00:00Z",
"last_active_ip": "1.2.3.4",
"finished_at": "1970-01-01T00:00:00Z"
"finished_at": "1970-01-01T00:00:00Z",
"human_name": null
},
"links": {
"self": "/api/admin/v1/compat-sessions/02081040G2081040G2081040G2"
@@ -168,7 +170,8 @@
"user_agent": null,
"last_active_at": null,
"last_active_ip": null,
"finished_at": null
"finished_at": null,
"human_name": null
},
"links": {
"self": "/api/admin/v1/compat-sessions/030C1G60R30C1G60R30C1G60R3"
@@ -245,7 +248,8 @@
"user_agent": "Mozilla/5.0",
"last_active_at": "1970-01-01T00:00:00Z",
"last_active_ip": "1.2.3.4",
"finished_at": null
"finished_at": null,
"human_name": "Laptop"
},
"links": {
"self": "/api/admin/v1/compat-sessions/01040G2081040G2081040G2081"
@@ -430,7 +434,8 @@
"scope": "openid",
"user_agent": "Mozilla/5.0",
"last_active_at": "1970-01-01T00:00:00Z",
"last_active_ip": "127.0.0.1"
"last_active_ip": "127.0.0.1",
"human_name": "Laptop"
},
"links": {
"self": "/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081"
@@ -448,7 +453,8 @@
"scope": "urn:mas:admin",
"user_agent": null,
"last_active_at": null,
"last_active_ip": null
"last_active_ip": null,
"human_name": null
},
"links": {
"self": "/api/admin/v1/oauth2-sessions/02081040G2081040G2081040G2"
@@ -466,7 +472,8 @@
"scope": "urn:matrix:org.matrix.msc2967.client:api:*",
"user_agent": "Mozilla/5.0",
"last_active_at": "1970-01-01T00:00:00Z",
"last_active_ip": "127.0.0.1"
"last_active_ip": "127.0.0.1",
"human_name": null
},
"links": {
"self": "/api/admin/v1/oauth2-sessions/030C1G60R30C1G60R30C1G60R3"
@@ -560,7 +567,8 @@
"scope": "openid",
"user_agent": "Mozilla/5.0",
"last_active_at": "1970-01-01T00:00:00Z",
"last_active_ip": "127.0.0.1"
"last_active_ip": "127.0.0.1",
"human_name": "Laptop"
},
"links": {
"self": "/api/admin/v1/oauth2-sessions/01040G2081040G2081040G2081"
@@ -2726,6 +2734,11 @@
"type": "string",
"format": "date-time",
"nullable": true
},
"human_name": {
"description": "The user-provided name, if any",
"type": "string",
"nullable": true
}
}
},
@@ -3001,6 +3014,11 @@
"type": "string",
"format": "ip",
"nullable": true
},
"human_name": {
"description": "The user-provided name, if any",
"type": "string",
"nullable": true
}
}
},

View File

@@ -258,6 +258,11 @@
"last_active_label": "Last Active",
"name_for_platform": "{{name}} for {{platform}}",
"scopes_label": "Scopes",
"set_device_name": {
"help": "Set a name that will help you identify this device.",
"label": "Device name",
"title": "Edit device name"
},
"signed_in_label": "Signed in",
"title": "Device details",
"unknown_browser": "Unknown browser",

View File

@@ -370,6 +370,10 @@ type CompatSession implements Node & CreationEvent {
The last time the session was active.
"""
lastActiveAt: DateTime
"""
A human-provided name for the session.
"""
humanName: String
}
type CompatSessionConnection {
@@ -937,7 +941,13 @@ type Mutation {
input: CreateOAuth2SessionInput!
): CreateOAuth2SessionPayload!
endOauth2Session(input: EndOAuth2SessionInput!): EndOAuth2SessionPayload!
setOauth2SessionName(
input: SetOAuth2SessionNameInput!
): SetOAuth2SessionNamePayload!
endCompatSession(input: EndCompatSessionInput!): EndCompatSessionPayload!
setCompatSessionName(
input: SetCompatSessionNameInput!
): SetCompatSessionNamePayload!
endBrowserSession(input: EndBrowserSessionInput!): EndBrowserSessionPayload!
"""
Set the display name of a user
@@ -1060,6 +1070,10 @@ type Oauth2Session implements Node & CreationEvent {
The last time the session was active.
"""
lastActiveAt: DateTime
"""
The user-provided name for this session.
"""
humanName: String
}
type Oauth2SessionConnection {
@@ -1426,6 +1440,45 @@ type SetCanRequestAdminPayload {
user: User
}
"""
The input of the `setCompatSessionName` mutation.
"""
input SetCompatSessionNameInput {
"""
The ID of the session to set the name of.
"""
compatSessionId: ID!
"""
The new name of the session.
"""
humanName: String!
}
type SetCompatSessionNamePayload {
"""
The status of the mutation.
"""
status: SetCompatSessionNameStatus!
"""
The session that was updated.
"""
oauth2Session: CompatSession
}
"""
The status of the `setCompatSessionName` mutation.
"""
enum SetCompatSessionNameStatus {
"""
The session was updated.
"""
UPDATED
"""
The session was not found.
"""
NOT_FOUND
}
"""
The input for the `addEmail` mutation
"""
@@ -1468,6 +1521,45 @@ enum SetDisplayNameStatus {
INVALID
}
"""
The input of the `setOauth2SessionName` mutation.
"""
input SetOAuth2SessionNameInput {
"""
The ID of the session to set the name of.
"""
oauth2SessionId: ID!
"""
The new name of the session.
"""
humanName: String!
}
type SetOAuth2SessionNamePayload {
"""
The status of the mutation.
"""
status: SetOAuth2SessionNameStatus!
"""
The session that was updated.
"""
oauth2Session: Oauth2Session
}
"""
The status of the `setOauth2SessionName` mutation.
"""
enum SetOAuth2SessionNameStatus {
"""
The session was updated.
"""
UPDATED
"""
The session was not found.
"""
NOT_FOUND
}
"""
The input for the `setPasswordByRecovery` mutation.
"""

View File

@@ -22,6 +22,7 @@ export const FRAGMENT = graphql(/* GraphQL */ `
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndCompatSessionButton_session
userAgent {
name
@@ -42,9 +43,11 @@ const CompatSession: React.FC<{
const { t } = useTranslation();
const data = useFragment(FRAGMENT, session);
const clientName = data.ssoLogin?.redirectUri
? simplifyUrl(data.ssoLogin.redirectUri)
: undefined;
const clientName =
data.humanName ??
(data.ssoLogin?.redirectUri
? simplifyUrl(data.ssoLogin.redirectUri)
: undefined);
const deviceType = data.userAgent?.deviceType ?? "UNKNOWN";

View File

@@ -16,6 +16,7 @@ export const FRAGMENT = graphql(/* GraphQL */ `
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndOAuth2SessionButton_session
@@ -72,6 +73,7 @@ const OAuth2Session: React.FC<Props> = ({ session }) => {
const clientName = data.client.clientName || data.client.clientId;
const deviceName =
data.humanName ??
data.userAgent?.model ??
(data.userAgent?.name
? data.userAgent?.os

View File

@@ -6,6 +6,7 @@
// @vitest-environment happy-dom
import { TooltipProvider } from "@vector-im/compound-web";
import { beforeAll, describe, expect, it } from "vitest";
import { makeFragmentData } from "../../gql";
import { mockLocale } from "../../test-utils/mockLocale";
@@ -33,7 +34,9 @@ describe("<CompatSessionDetail>", () => {
const data = makeFragmentData({ ...baseSession }, FRAGMENT);
const { container, getByText, queryByText } = render(
<CompatSessionDetail session={data} />,
<TooltipProvider>
<CompatSessionDetail session={data} />
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
@@ -51,7 +54,9 @@ describe("<CompatSessionDetail>", () => {
);
const { container, getByText, queryByText } = render(
<CompatSessionDetail session={data} />,
<TooltipProvider>
<CompatSessionDetail session={data} />
</TooltipProvider>,
);
expect(container).toMatchSnapshot();
@@ -69,7 +74,9 @@ describe("<CompatSessionDetail>", () => {
);
const { container, getByText, queryByText } = render(
<CompatSessionDetail session={data} />,
<TooltipProvider>
<CompatSessionDetail session={data} />
</TooltipProvider>,
);
expect(container).toMatchSnapshot();

View File

@@ -4,17 +4,28 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { VisualList } from "@vector-im/compound-web";
import { parseISO } from "date-fns";
import { useTranslation } from "react-i18next";
import { type FragmentType, graphql, useFragment } from "../../gql";
import { graphqlRequest } from "../../graphql";
import simplifyUrl from "../../utils/simplifyUrl";
import DateTime from "../DateTime";
import EndCompatSessionButton from "../Session/EndCompatSessionButton";
import LastActive from "../Session/LastActive";
import EditSessionName from "./EditSessionName";
import SessionHeader from "./SessionHeader";
import * as Info from "./SessionInfo";
const SET_SESSION_NAME_MUTATION = graphql(/* GraphQL */ `
mutation SetCompatSessionName($sessionId: ID!, $displayName: String!) {
setCompatSessionName(input: { compatSessionId: $sessionId, humanName: $displayName }) {
status
}
}
`);
export const FRAGMENT = graphql(/* GraphQL */ `
fragment CompatSession_detail on CompatSession {
id
@@ -23,6 +34,7 @@ export const FRAGMENT = graphql(/* GraphQL */ `
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndCompatSessionButton_session
@@ -46,6 +58,19 @@ type Props = {
const CompatSessionDetail: React.FC<Props> = ({ session }) => {
const data = useFragment(FRAGMENT, session);
const { t } = useTranslation();
const queryClient = useQueryClient();
const setDisplayName = useMutation({
mutationFn: (displayName: string) =>
graphqlRequest({
query: SET_SESSION_NAME_MUTATION,
variables: { sessionId: data.id, displayName },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["sessionDetail", data.id] });
queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] });
},
});
const deviceName =
data.userAgent?.model ??
@@ -62,10 +87,13 @@ const CompatSessionDetail: React.FC<Props> = ({ session }) => {
? simplifyUrl(data.ssoLogin.redirectUri)
: data.deviceId || data.id;
const sessionName = data.humanName ?? `${clientName}: ${deviceName}`;
return (
<div className="flex flex-col gap-10">
<SessionHeader to="/sessions">
{clientName}: {deviceName}
{sessionName}
<EditSessionName mutation={setDisplayName} deviceName={sessionName} />
</SessionHeader>
<Info.DataSection>
<Info.DataSectionHeader>
@@ -141,10 +169,12 @@ const CompatSessionDetail: React.FC<Props> = ({ session }) => {
</Info.DataLabel>
<Info.DataValue>{deviceName}</Info.DataValue>
</Info.Data>
<Info.Data>
<Info.DataLabel>{t("frontend.session.uri_label")}</Info.DataLabel>
<Info.DataValue>{data.ssoLogin?.redirectUri}</Info.DataValue>
</Info.Data>
{data.ssoLogin && (
<Info.Data>
<Info.DataLabel>{t("frontend.session.uri_label")}</Info.DataLabel>
<Info.DataValue>{data.ssoLogin?.redirectUri}</Info.DataValue>
</Info.Data>
)}
</Info.DataList>
</Info.DataSection>

View File

@@ -0,0 +1,100 @@
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import IconEdit from "@vector-im/compound-design-tokens/assets/web/icons/edit";
import { Button, Form, IconButton, Tooltip } from "@vector-im/compound-web";
import {
type ComponentPropsWithoutRef,
forwardRef,
useRef,
useState,
} from "react";
import * as Dialog from "../Dialog";
import LoadingSpinner from "../LoadingSpinner";
import type { UseMutationResult } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
// This needs to be its own component because else props and refs aren't passed properly in the trigger
const EditButton = forwardRef<
HTMLButtonElement,
{ label: string } & ComponentPropsWithoutRef<"button">
>(({ label, ...props }, ref) => (
<Tooltip label={label}>
<IconButton
ref={ref}
type="button"
size="var(--cpd-space-6x)"
style={{ marginInline: "var(--cpd-space-2x)" }}
{...props}
>
<IconEdit />
</IconButton>
</Tooltip>
));
type Props = {
mutation: UseMutationResult<unknown, unknown, string, unknown>;
deviceName: string;
};
const EditSessionName: React.FC<Props> = ({ mutation, deviceName }) => {
const { t } = useTranslation();
const fieldRef = useRef<HTMLInputElement>(null);
const [open, setOpen] = useState(false);
const onSubmit = async (
event: React.FormEvent<HTMLFormElement>,
): Promise<void> => {
event.preventDefault();
const form = event.currentTarget;
const formData = new FormData(form);
const displayName = formData.get("name") as string;
await mutation.mutateAsync(displayName);
setOpen(false);
};
return (
<Dialog.Dialog
trigger={<EditButton label={t("action.edit")} />}
open={open}
onOpenChange={(open) => {
// Reset the form when the dialog is opened or closed
fieldRef.current?.form?.reset();
setOpen(open);
}}
>
<Dialog.Title>{t("frontend.session.set_device_name.title")}</Dialog.Title>
<Form.Root onSubmit={onSubmit}>
<Form.Field name="name">
<Form.Label>{t("frontend.session.set_device_name.label")}</Form.Label>
<Form.TextControl
type="text"
required
defaultValue={deviceName}
ref={fieldRef}
/>
<Form.HelpMessage>
{t("frontend.session.set_device_name.help")}
</Form.HelpMessage>
</Form.Field>
<Form.Submit disabled={mutation.isPending}>
{mutation.isPending && <LoadingSpinner inline />}
{t("action.save")}
</Form.Submit>
</Form.Root>
<Dialog.Close asChild>
<Button kind="tertiary">{t("action.cancel")}</Button>
</Dialog.Close>
</Dialog.Dialog>
);
};
export default EditSessionName;

View File

@@ -11,6 +11,7 @@ import { beforeAll, describe, expect, it } from "vitest";
import { makeFragmentData } from "../../gql";
import { mockLocale } from "../../test-utils/mockLocale";
import { TooltipProvider } from "@vector-im/compound-web";
import render from "../../test-utils/render";
import OAuth2SessionDetail, { FRAGMENT } from "./OAuth2SessionDetail";
@@ -39,7 +40,9 @@ describe("<OAuth2SessionDetail>", () => {
const data = makeFragmentData(baseSession, FRAGMENT);
const { asFragment, getByText, queryByText } = render(
<OAuth2SessionDetail session={data} />,
<TooltipProvider>
<OAuth2SessionDetail session={data} />
</TooltipProvider>,
);
expect(asFragment()).toMatchSnapshot();
@@ -57,7 +60,9 @@ describe("<OAuth2SessionDetail>", () => {
);
const { asFragment, getByText, queryByText } = render(
<OAuth2SessionDetail session={data} />,
<TooltipProvider>
<OAuth2SessionDetail session={data} />
</TooltipProvider>,
);
expect(asFragment()).toMatchSnapshot();

View File

@@ -4,17 +4,28 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { parseISO } from "date-fns";
import { useTranslation } from "react-i18next";
import { type FragmentType, graphql, useFragment } from "../../gql";
import { graphqlRequest } from "../../graphql";
import { getDeviceIdFromScope } from "../../utils/deviceIdFromScope";
import DateTime from "../DateTime";
import ClientAvatar from "../Session/ClientAvatar";
import EndOAuth2SessionButton from "../Session/EndOAuth2SessionButton";
import LastActive from "../Session/LastActive";
import EditSessionName from "./EditSessionName";
import SessionHeader from "./SessionHeader";
import * as Info from "./SessionInfo";
const SET_SESSION_NAME_MUTATION = graphql(/* GraphQL */ `
mutation SetOAuth2SessionName($sessionId: ID!, $displayName: String!) {
setOauth2SessionName(input: { oauth2SessionId: $sessionId, humanName: $displayName }) {
status
}
}
`);
export const FRAGMENT = graphql(/* GraphQL */ `
fragment OAuth2Session_detail on Oauth2Session {
id
@@ -23,6 +34,7 @@ export const FRAGMENT = graphql(/* GraphQL */ `
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndOAuth2SessionButton_session
@@ -49,11 +61,25 @@ type Props = {
const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
const data = useFragment(FRAGMENT, session);
const { t } = useTranslation();
const queryClient = useQueryClient();
const setDisplayName = useMutation({
mutationFn: (displayName: string) =>
graphqlRequest({
query: SET_SESSION_NAME_MUTATION,
variables: { sessionId: data.id, displayName },
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["sessionDetail", data.id] });
queryClient.invalidateQueries({ queryKey: ["sessionsOverview"] });
},
});
const deviceId = getDeviceIdFromScope(data.scope);
const clientName = data.client.clientName || data.client.clientId;
const deviceName =
data.humanName ??
data.userAgent?.model ??
(data.userAgent?.name
? data.userAgent?.os
@@ -68,7 +94,9 @@ const OAuth2SessionDetail: React.FC<Props> = ({ session }) => {
<div className="flex flex-col gap-10">
<SessionHeader to="/sessions">
{clientName}: {deviceName}
<EditSessionName mutation={setDisplayName} deviceName={deviceName} />
</SessionHeader>
<Info.DataSection>
<Info.DataSectionHeader>
{t("frontend.session.title")}

View File

@@ -27,9 +27,38 @@ exports[`<CompatSessionDetail> > renders a compatability session details 1`] = `
<h3
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
element.io
:
Unknown device
element.io: Unknown device
<button
aria-controls="radix-«r0»"
aria-expanded="false"
aria-haspopup="dialog"
aria-labelledby="«r3»"
class="_icon-button_m2erp_8"
data-state="closed"
role="button"
style="--cpd-icon-button-size: var(--cpd-space-6x);"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</div>
</button>
</h3>
</header>
<section
@@ -238,7 +267,7 @@ exports[`<CompatSessionDetail> > renders a compatability session details 1`] = `
</ul>
</section>
<button
aria-controls="radix-«r0»"
aria-controls="radix-«r8»"
aria-expanded="false"
aria-haspopup="dialog"
class="_button_vczzf_8 _has-icon_vczzf_57 _destructive_vczzf_107"
@@ -294,9 +323,38 @@ exports[`<CompatSessionDetail> > renders a compatability session without an ssoL
<h3
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
abcd1234
:
Unknown device
abcd1234: Unknown device
<button
aria-controls="radix-«rc»"
aria-expanded="false"
aria-haspopup="dialog"
aria-labelledby="«rf»"
class="_icon-button_m2erp_8"
data-state="closed"
role="button"
style="--cpd-icon-button-size: var(--cpd-space-6x);"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</div>
</button>
</h3>
</header>
<section
@@ -488,22 +546,10 @@ exports[`<CompatSessionDetail> > renders a compatability session without an ssoL
Unknown device
</p>
</li>
<li
class="flex flex-col min-w-0"
>
<h5
class="_typography_6v6n8_153 _font-body-sm-regular_6v6n8_31 text-secondary"
>
Uri
</h5>
<p
class="_typography_6v6n8_153 _font-body-md-regular_6v6n8_50 text-ellipsis overflow-hidden"
/>
</li>
</ul>
</section>
<button
aria-controls="radix-«r3»"
aria-controls="radix-«rk»"
aria-expanded="false"
aria-haspopup="dialog"
class="_button_vczzf_8 _has-icon_vczzf_57 _destructive_vczzf_107"
@@ -559,9 +605,38 @@ exports[`<CompatSessionDetail> > renders a finished compatability session detail
<h3
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
element.io
:
Unknown device
element.io: Unknown device
<button
aria-controls="radix-«ro»"
aria-expanded="false"
aria-haspopup="dialog"
aria-labelledby="«rr»"
class="_icon-button_m2erp_8"
data-state="closed"
role="button"
style="--cpd-icon-button-size: var(--cpd-space-6x);"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</div>
</button>
</h3>
</header>
<section

View File

@@ -28,6 +28,37 @@ exports[`<OAuth2SessionDetail> > renders a finished session details 1`] = `
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Element: Unknown device
<button
aria-controls="radix-«rc»"
aria-expanded="false"
aria-haspopup="dialog"
aria-labelledby="«rf»"
class="_icon-button_m2erp_8"
data-state="closed"
role="button"
style="--cpd-icon-button-size: var(--cpd-space-6x);"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</div>
</button>
</h3>
</header>
<section
@@ -307,6 +338,37 @@ exports[`<OAuth2SessionDetail> > renders session details 1`] = `
class="_typography_6v6n8_153 _font-heading-md-semibold_6v6n8_112"
>
Element: Unknown device
<button
aria-controls="radix-«r0»"
aria-expanded="false"
aria-haspopup="dialog"
aria-labelledby="«r3»"
class="_icon-button_m2erp_8"
data-state="closed"
role="button"
style="--cpd-icon-button-size: var(--cpd-space-6x);"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_zr2a0_17"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M15.706 2.637a2 2 0 0 1 2.829 0l2.828 2.828a2 2 0 0 1 0 2.829L9.605 20.052a1 1 0 0 1-.465.263L3.483 21.73a1 1 0 0 1-1.212-1.213l1.414-5.657a1 1 0 0 1 .263-.465zm1.224 7.262L14.102 7.07l-8.544 8.544-.943 3.771 3.771-.943z"
fill-rule="evenodd"
/>
</svg>
</div>
</button>
</h3>
</header>
<section
@@ -537,7 +599,7 @@ exports[`<OAuth2SessionDetail> > renders session details 1`] = `
</ul>
</section>
<button
aria-controls="radix-«r0»"
aria-controls="radix-«r8»"
aria-expanded="false"
aria-haspopup="dialog"
class="_button_vczzf_8 _has-icon_vczzf_57 _destructive_vczzf_107"

View File

@@ -21,10 +21,10 @@ type Documents = {
"\n fragment PasswordChange_siteConfig on SiteConfig {\n passwordChangeAllowed\n }\n": typeof types.PasswordChange_SiteConfigFragmentDoc,
"\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n deviceType\n name\n os\n model\n }\n lastActiveAt\n }\n": typeof types.BrowserSession_SessionFragmentDoc,
"\n fragment OAuth2Client_detail on Oauth2Client {\n id\n clientId\n clientName\n clientUri\n logoUri\n tosUri\n policyUri\n redirectUris\n }\n": typeof types.OAuth2Client_DetailFragmentDoc,
"\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": typeof types.CompatSession_SessionFragmentDoc,
"\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": typeof types.CompatSession_SessionFragmentDoc,
"\n fragment Footer_siteConfig on SiteConfig {\n id\n imprint\n tosUri\n policyUri\n }\n": typeof types.Footer_SiteConfigFragmentDoc,
"\n query Footer {\n siteConfig {\n id\n ...Footer_siteConfig\n }\n }\n": typeof types.FooterDocument,
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": typeof types.OAuth2Session_SessionFragmentDoc,
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": typeof types.OAuth2Session_SessionFragmentDoc,
"\n fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {\n id\n minimumPasswordComplexity\n }\n": typeof types.PasswordCreationDoubleInput_SiteConfigFragmentDoc,
"\n fragment EndBrowserSessionButton_session on BrowserSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n }\n": typeof types.EndBrowserSessionButton_SessionFragmentDoc,
"\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n }\n }\n }\n": typeof types.EndBrowserSessionDocument,
@@ -33,8 +33,10 @@ type Documents = {
"\n fragment EndOAuth2SessionButton_session on Oauth2Session {\n id\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": typeof types.EndOAuth2SessionButton_SessionFragmentDoc,
"\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n }\n }\n }\n": typeof types.EndOAuth2SessionDocument,
"\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n": typeof types.BrowserSession_DetailFragmentDoc,
"\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndCompatSessionButton_session\n\n userAgent {\n name\n os\n model\n }\n\n ssoLogin {\n id\n redirectUri\n }\n }\n": typeof types.CompatSession_DetailFragmentDoc,
"\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n }\n\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n": typeof types.OAuth2Session_DetailFragmentDoc,
"\n mutation SetCompatSessionName($sessionId: ID!, $displayName: String!) {\n setCompatSessionName(input: { compatSessionId: $sessionId, humanName: $displayName }) {\n status\n }\n }\n": typeof types.SetCompatSessionNameDocument,
"\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndCompatSessionButton_session\n\n userAgent {\n name\n os\n model\n }\n\n ssoLogin {\n id\n redirectUri\n }\n }\n": typeof types.CompatSession_DetailFragmentDoc,
"\n mutation SetOAuth2SessionName($sessionId: ID!, $displayName: String!) {\n setOauth2SessionName(input: { oauth2SessionId: $sessionId, humanName: $displayName }) {\n status\n }\n }\n": typeof types.SetOAuth2SessionNameDocument,
"\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n }\n\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n": typeof types.OAuth2Session_DetailFragmentDoc,
"\n fragment UserEmail_email on UserEmail {\n id\n email\n }\n": typeof types.UserEmail_EmailFragmentDoc,
"\n mutation RemoveEmail($id: ID!, $password: String) {\n removeEmail(input: { userEmailId: $id, password: $password }) {\n status\n\n user {\n id\n }\n }\n }\n": typeof types.RemoveEmailDocument,
"\n fragment UserGreeting_user on User {\n id\n matrix {\n mxid\n displayName\n }\n }\n": typeof types.UserGreeting_UserFragmentDoc,
@@ -75,10 +77,10 @@ const documents: Documents = {
"\n fragment PasswordChange_siteConfig on SiteConfig {\n passwordChangeAllowed\n }\n": types.PasswordChange_SiteConfigFragmentDoc,
"\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n deviceType\n name\n os\n model\n }\n lastActiveAt\n }\n": types.BrowserSession_SessionFragmentDoc,
"\n fragment OAuth2Client_detail on Oauth2Client {\n id\n clientId\n clientName\n clientUri\n logoUri\n tosUri\n policyUri\n redirectUris\n }\n": types.OAuth2Client_DetailFragmentDoc,
"\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_SessionFragmentDoc,
"\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_SessionFragmentDoc,
"\n fragment Footer_siteConfig on SiteConfig {\n id\n imprint\n tosUri\n policyUri\n }\n": types.Footer_SiteConfigFragmentDoc,
"\n query Footer {\n siteConfig {\n id\n ...Footer_siteConfig\n }\n }\n": types.FooterDocument,
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": types.OAuth2Session_SessionFragmentDoc,
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": types.OAuth2Session_SessionFragmentDoc,
"\n fragment PasswordCreationDoubleInput_siteConfig on SiteConfig {\n id\n minimumPasswordComplexity\n }\n": types.PasswordCreationDoubleInput_SiteConfigFragmentDoc,
"\n fragment EndBrowserSessionButton_session on BrowserSession {\n id\n userAgent {\n name\n os\n model\n deviceType\n }\n }\n": types.EndBrowserSessionButton_SessionFragmentDoc,
"\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n }\n }\n }\n": types.EndBrowserSessionDocument,
@@ -87,8 +89,10 @@ const documents: Documents = {
"\n fragment EndOAuth2SessionButton_session on Oauth2Session {\n id\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n": types.EndOAuth2SessionButton_SessionFragmentDoc,
"\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n }\n }\n }\n": types.EndOAuth2SessionDocument,
"\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n ...EndBrowserSessionButton_session\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n": types.BrowserSession_DetailFragmentDoc,
"\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndCompatSessionButton_session\n\n userAgent {\n name\n os\n model\n }\n\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_DetailFragmentDoc,
"\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n }\n\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n": types.OAuth2Session_DetailFragmentDoc,
"\n mutation SetCompatSessionName($sessionId: ID!, $displayName: String!) {\n setCompatSessionName(input: { compatSessionId: $sessionId, humanName: $displayName }) {\n status\n }\n }\n": types.SetCompatSessionNameDocument,
"\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndCompatSessionButton_session\n\n userAgent {\n name\n os\n model\n }\n\n ssoLogin {\n id\n redirectUri\n }\n }\n": types.CompatSession_DetailFragmentDoc,
"\n mutation SetOAuth2SessionName($sessionId: ID!, $displayName: String!) {\n setOauth2SessionName(input: { oauth2SessionId: $sessionId, humanName: $displayName }) {\n status\n }\n }\n": types.SetOAuth2SessionNameDocument,
"\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n }\n\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n": types.OAuth2Session_DetailFragmentDoc,
"\n fragment UserEmail_email on UserEmail {\n id\n email\n }\n": types.UserEmail_EmailFragmentDoc,
"\n mutation RemoveEmail($id: ID!, $password: String) {\n removeEmail(input: { userEmailId: $id, password: $password }) {\n status\n\n user {\n id\n }\n }\n }\n": types.RemoveEmailDocument,
"\n fragment UserGreeting_user on User {\n id\n matrix {\n mxid\n displayName\n }\n }\n": types.UserGreeting_UserFragmentDoc,
@@ -150,7 +154,7 @@ export function graphql(source: "\n fragment OAuth2Client_detail on Oauth2Clien
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n"): typeof import('./graphql').CompatSession_SessionFragmentDoc;
export function graphql(source: "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n ...EndCompatSessionButton_session\n userAgent {\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n"): typeof import('./graphql').CompatSession_SessionFragmentDoc;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -162,7 +166,7 @@ export function graphql(source: "\n query Footer {\n siteConfig {\n id\
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n"): typeof import('./graphql').OAuth2Session_SessionFragmentDoc;
export function graphql(source: "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n"): typeof import('./graphql').OAuth2Session_SessionFragmentDoc;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@@ -198,11 +202,19 @@ export function graphql(source: "\n fragment BrowserSession_detail on BrowserSe
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndCompatSessionButton_session\n\n userAgent {\n name\n os\n model\n }\n\n ssoLogin {\n id\n redirectUri\n }\n }\n"): typeof import('./graphql').CompatSession_DetailFragmentDoc;
export function graphql(source: "\n mutation SetCompatSessionName($sessionId: ID!, $displayName: String!) {\n setCompatSessionName(input: { compatSessionId: $sessionId, humanName: $displayName }) {\n status\n }\n }\n"): typeof import('./graphql').SetCompatSessionNameDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n }\n\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n"): typeof import('./graphql').OAuth2Session_DetailFragmentDoc;
export function graphql(source: "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndCompatSessionButton_session\n\n userAgent {\n name\n os\n model\n }\n\n ssoLogin {\n id\n redirectUri\n }\n }\n"): typeof import('./graphql').CompatSession_DetailFragmentDoc;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation SetOAuth2SessionName($sessionId: ID!, $displayName: String!) {\n setOauth2SessionName(input: { oauth2SessionId: $sessionId, humanName: $displayName }) {\n status\n }\n }\n"): typeof import('./graphql').SetOAuth2SessionNameDocument;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n humanName\n\n ...EndOAuth2SessionButton_session\n\n userAgent {\n name\n model\n os\n }\n\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n"): typeof import('./graphql').OAuth2Session_DetailFragmentDoc;
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@@ -238,6 +238,8 @@ export type CompatSession = CreationEvent & Node & {
deviceId?: Maybe<Scalars['String']['output']>;
/** When the session ended. */
finishedAt?: Maybe<Scalars['DateTime']['output']>;
/** A human-provided name for the session. */
humanName?: Maybe<Scalars['String']['output']>;
/** ID of the object. */
id: Scalars['ID']['output'];
/** The last time the session was active. */
@@ -580,8 +582,10 @@ export type Mutation = {
* administrators.
*/
setCanRequestAdmin: SetCanRequestAdminPayload;
setCompatSessionName: SetCompatSessionNamePayload;
/** Set the display name of a user */
setDisplayName: SetDisplayNamePayload;
setOauth2SessionName: SetOAuth2SessionNamePayload;
/**
* Set the password for a user.
*
@@ -689,12 +693,24 @@ export type MutationSetCanRequestAdminArgs = {
};
/** The mutations root of the GraphQL interface. */
export type MutationSetCompatSessionNameArgs = {
input: SetCompatSessionNameInput;
};
/** The mutations root of the GraphQL interface. */
export type MutationSetDisplayNameArgs = {
input: SetDisplayNameInput;
};
/** The mutations root of the GraphQL interface. */
export type MutationSetOauth2SessionNameArgs = {
input: SetOAuth2SessionNameInput;
};
/** The mutations root of the GraphQL interface. */
export type MutationSetPasswordArgs = {
input: SetPasswordInput;
@@ -774,6 +790,8 @@ export type Oauth2Session = CreationEvent & Node & {
createdAt: Scalars['DateTime']['output'];
/** When the session ended. */
finishedAt?: Maybe<Scalars['DateTime']['output']>;
/** The user-provided name for this session. */
humanName?: Maybe<Scalars['String']['output']>;
/** ID of the object. */
id: Scalars['ID']['output'];
/** The last time the session was active. */
@@ -1082,6 +1100,29 @@ export type SetCanRequestAdminPayload = {
user?: Maybe<User>;
};
/** The input of the `setCompatSessionName` mutation. */
export type SetCompatSessionNameInput = {
/** The ID of the session to set the name of. */
compatSessionId: Scalars['ID']['input'];
/** The new name of the session. */
humanName: Scalars['String']['input'];
};
export type SetCompatSessionNamePayload = {
__typename?: 'SetCompatSessionNamePayload';
/** The session that was updated. */
oauth2Session?: Maybe<CompatSession>;
/** The status of the mutation. */
status: SetCompatSessionNameStatus;
};
/** The status of the `setCompatSessionName` mutation. */
export type SetCompatSessionNameStatus =
/** The session was not found. */
| 'NOT_FOUND'
/** The session was updated. */
| 'UPDATED';
/** The input for the `addEmail` mutation */
export type SetDisplayNameInput = {
/** The display name to set. If `None`, the display name will be removed. */
@@ -1106,6 +1147,29 @@ export type SetDisplayNameStatus =
/** The display name was set */
| 'SET';
/** The input of the `setOauth2SessionName` mutation. */
export type SetOAuth2SessionNameInput = {
/** The new name of the session. */
humanName: Scalars['String']['input'];
/** The ID of the session to set the name of. */
oauth2SessionId: Scalars['ID']['input'];
};
export type SetOAuth2SessionNamePayload = {
__typename?: 'SetOAuth2SessionNamePayload';
/** The session that was updated. */
oauth2Session?: Maybe<Oauth2Session>;
/** The status of the mutation. */
status: SetOAuth2SessionNameStatus;
};
/** The status of the `setOauth2SessionName` mutation. */
export type SetOAuth2SessionNameStatus =
/** The session was not found. */
| 'NOT_FOUND'
/** The session was updated. */
| 'UPDATED';
/** The input for the `setPasswordByRecovery` mutation. */
export type SetPasswordByRecoveryInput = {
/** The new password for the user. */
@@ -1650,7 +1714,7 @@ export type BrowserSession_SessionFragment = (
export type OAuth2Client_DetailFragment = { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, clientUri?: string | null, logoUri?: string | null, tosUri?: string | null, policyUri?: string | null, redirectUris: Array<string> } & { ' $fragmentName'?: 'OAuth2Client_DetailFragment' };
export type CompatSession_SessionFragment = (
{ __typename?: 'CompatSession', id: string, createdAt: string, deviceId?: string | null, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null, deviceType: DeviceType } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null }
{ __typename?: 'CompatSession', id: string, createdAt: string, deviceId?: string | null, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, humanName?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null, deviceType: DeviceType } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null }
& { ' $fragmentRefs'?: { 'EndCompatSessionButton_SessionFragment': EndCompatSessionButton_SessionFragment } }
) & { ' $fragmentName'?: 'CompatSession_SessionFragment' };
@@ -1665,7 +1729,7 @@ export type FooterQuery = { __typename?: 'Query', siteConfig: (
) };
export type OAuth2Session_SessionFragment = (
{ __typename?: 'Oauth2Session', id: string, scope: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, model?: string | null, os?: string | null, deviceType: DeviceType } | null, client: { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, applicationType?: Oauth2ApplicationType | null, logoUri?: string | null } }
{ __typename?: 'Oauth2Session', id: string, scope: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, humanName?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, model?: string | null, os?: string | null, deviceType: DeviceType } | null, client: { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, applicationType?: Oauth2ApplicationType | null, logoUri?: string | null } }
& { ' $fragmentRefs'?: { 'EndOAuth2SessionButton_SessionFragment': EndOAuth2SessionButton_SessionFragment } }
) & { ' $fragmentName'?: 'OAuth2Session_SessionFragment' };
@@ -1703,13 +1767,29 @@ export type BrowserSession_DetailFragment = (
& { ' $fragmentRefs'?: { 'EndBrowserSessionButton_SessionFragment': EndBrowserSessionButton_SessionFragment } }
) & { ' $fragmentName'?: 'BrowserSession_DetailFragment' };
export type SetCompatSessionNameMutationVariables = Exact<{
sessionId: Scalars['ID']['input'];
displayName: Scalars['String']['input'];
}>;
export type SetCompatSessionNameMutation = { __typename?: 'Mutation', setCompatSessionName: { __typename?: 'SetCompatSessionNamePayload', status: SetCompatSessionNameStatus } };
export type CompatSession_DetailFragment = (
{ __typename?: 'CompatSession', id: string, createdAt: string, deviceId?: string | null, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null }
{ __typename?: 'CompatSession', id: string, createdAt: string, deviceId?: string | null, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, humanName?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, os?: string | null, model?: string | null } | null, ssoLogin?: { __typename?: 'CompatSsoLogin', id: string, redirectUri: string } | null }
& { ' $fragmentRefs'?: { 'EndCompatSessionButton_SessionFragment': EndCompatSessionButton_SessionFragment } }
) & { ' $fragmentName'?: 'CompatSession_DetailFragment' };
export type SetOAuth2SessionNameMutationVariables = Exact<{
sessionId: Scalars['ID']['input'];
displayName: Scalars['String']['input'];
}>;
export type SetOAuth2SessionNameMutation = { __typename?: 'Mutation', setOauth2SessionName: { __typename?: 'SetOAuth2SessionNamePayload', status: SetOAuth2SessionNameStatus } };
export type OAuth2Session_DetailFragment = (
{ __typename?: 'Oauth2Session', id: string, scope: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, model?: string | null, os?: string | null } | null, client: { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, clientUri?: string | null, logoUri?: string | null } }
{ __typename?: 'Oauth2Session', id: string, scope: string, createdAt: string, finishedAt?: string | null, lastActiveIp?: string | null, lastActiveAt?: string | null, humanName?: string | null, userAgent?: { __typename?: 'UserAgent', name?: string | null, model?: string | null, os?: string | null } | null, client: { __typename?: 'Oauth2Client', id: string, clientId: string, clientName?: string | null, clientUri?: string | null, logoUri?: string | null } }
& { ' $fragmentRefs'?: { 'EndOAuth2SessionButton_SessionFragment': EndOAuth2SessionButton_SessionFragment } }
) & { ' $fragmentName'?: 'OAuth2Session_DetailFragment' };
@@ -2056,6 +2136,7 @@ export const CompatSession_SessionFragmentDoc = new TypedDocumentString(`
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndCompatSessionButton_session
userAgent {
name
@@ -2114,6 +2195,7 @@ export const OAuth2Session_SessionFragmentDoc = new TypedDocumentString(`
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndOAuth2SessionButton_session
userAgent {
name
@@ -2183,6 +2265,7 @@ export const CompatSession_DetailFragmentDoc = new TypedDocumentString(`
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndCompatSessionButton_session
userAgent {
name
@@ -2215,6 +2298,7 @@ export const OAuth2Session_DetailFragmentDoc = new TypedDocumentString(`
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndOAuth2SessionButton_session
userAgent {
name
@@ -2363,6 +2447,24 @@ export const EndOAuth2SessionDocument = new TypedDocumentString(`
}
}
`) as unknown as TypedDocumentString<EndOAuth2SessionMutation, EndOAuth2SessionMutationVariables>;
export const SetCompatSessionNameDocument = new TypedDocumentString(`
mutation SetCompatSessionName($sessionId: ID!, $displayName: String!) {
setCompatSessionName(
input: {compatSessionId: $sessionId, humanName: $displayName}
) {
status
}
}
`) as unknown as TypedDocumentString<SetCompatSessionNameMutation, SetCompatSessionNameMutationVariables>;
export const SetOAuth2SessionNameDocument = new TypedDocumentString(`
mutation SetOAuth2SessionName($sessionId: ID!, $displayName: String!) {
setOauth2SessionName(
input: {oauth2SessionId: $sessionId, humanName: $displayName}
) {
status
}
}
`) as unknown as TypedDocumentString<SetOAuth2SessionNameMutation, SetOAuth2SessionNameMutationVariables>;
export const RemoveEmailDocument = new TypedDocumentString(`
mutation RemoveEmail($id: ID!, $password: String) {
removeEmail(input: {userEmailId: $id, password: $password}) {
@@ -2587,6 +2689,7 @@ export const AppSessionsListDocument = new TypedDocumentString(`
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndCompatSessionButton_session
userAgent {
name
@@ -2606,6 +2709,7 @@ fragment OAuth2Session_session on Oauth2Session {
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndOAuth2SessionButton_session
userAgent {
name
@@ -2880,6 +2984,7 @@ fragment CompatSession_detail on CompatSession {
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndCompatSessionButton_session
userAgent {
name
@@ -2898,6 +3003,7 @@ fragment OAuth2Session_detail on Oauth2Session {
finishedAt
lastActiveIp
lastActiveAt
humanName
...EndOAuth2SessionButton_session
userAgent {
name
@@ -3022,6 +3128,50 @@ export const mockEndOAuth2SessionMutation = (resolver: GraphQLResponseResolver<E
options
)
/**
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
* @see https://mswjs.io/docs/basics/response-resolver
* @example
* mockSetCompatSessionNameMutation(
* ({ query, variables }) => {
* const { sessionId, displayName } = variables;
* return HttpResponse.json({
* data: { setCompatSessionName }
* })
* },
* requestOptions
* )
*/
export const mockSetCompatSessionNameMutation = (resolver: GraphQLResponseResolver<SetCompatSessionNameMutation, SetCompatSessionNameMutationVariables>, options?: RequestHandlerOptions) =>
graphql.mutation<SetCompatSessionNameMutation, SetCompatSessionNameMutationVariables>(
'SetCompatSessionName',
resolver,
options
)
/**
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))
* @see https://mswjs.io/docs/basics/response-resolver
* @example
* mockSetOAuth2SessionNameMutation(
* ({ query, variables }) => {
* const { sessionId, displayName } = variables;
* return HttpResponse.json({
* data: { setOauth2SessionName }
* })
* },
* requestOptions
* )
*/
export const mockSetOAuth2SessionNameMutation = (resolver: GraphQLResponseResolver<SetOAuth2SessionNameMutation, SetOAuth2SessionNameMutationVariables>, options?: RequestHandlerOptions) =>
graphql.mutation<SetOAuth2SessionNameMutation, SetOAuth2SessionNameMutationVariables>(
'SetOAuth2SessionName',
resolver,
options
)
/**
* @param resolver A function that accepts [resolver arguments](https://mswjs.io/docs/api/graphql#resolver-argument) and must always return the instruction on what to do with the intercepted request. ([see more](https://mswjs.io/docs/concepts/response-resolver#resolver-instructions))
* @param options Options object to customize the behavior of the mock. ([see more](https://mswjs.io/docs/api/graphql#handler-options))

28
templates/device_name.txt Normal file
View File

@@ -0,0 +1,28 @@
{#
Copyright 2024, 2025 New Vector Ltd.
Copyright 2021-2024 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only
Please see LICENSE in the repository root for full details.
-#}
{%- set _ = translator(lang) -%}
{%- set client_name = client.client_name or client.client_id -%}
{%- set user_agent = raw_user_agent | parse_user_agent() -%}
{%- set device_name -%}
{%- if user_agent.model -%}
{{- user_agent.model -}}
{%- elif user_agent.name -%}
{%- if user_agent.os -%}
{{- _("mas.device_display_name.name_for_platform", name=user_agent.name, platform=user_agent.os) -}}
{%- else -%}
{{- user_agent.name -}}
{%- endif -%}
{%- else -%}
{{- _("mas.device_display_name.unknown_device") -}}
{%- endif -%}
{%- endset -%}
{{- _("mas.device_display_name.client_on_device", client_name=client_name, device_name=device_name) -}}

View File

@@ -12,6 +12,7 @@ Please see LICENSE in the repository root for full details.
{% block content %}
{% set client_name = client.client_name or client.client_id %}
{% set user_agent = grant.user_agent | parse_user_agent() %}
{% if grant.state == "pending" %}
<header class="page-heading">
@@ -27,13 +28,13 @@ Please see LICENSE in the repository root for full details.
<h1 class="title">{{ _("mas.consent.heading") }}</h1>
<div class="session-card my-4">
<div class="card-header" {%- if grant.user_agent %} title="{{ grant.user_agent.raw }}"{% endif %}>
<div class="card-header" {%- if user_agent %} title="{{ user_agent.raw }}"{% endif %}>
<div class="device-type-icon">
{% if grant.user_agent.device_type == "mobile" %}
{% if user_agent.device_type == "mobile" %}
{{ icon.mobile() }}
{% elif grant.user_agent.device_type == "tablet" %}
{% elif user_agent.device_type == "tablet" %}
{{ icon.web_browser() }}
{% elif grant.user_agent.device_type == "pc" %}
{% elif user_agent.device_type == "pc" %}
{{ icon.computer() }}
{% else %}
{{ icon.unknown_solid() }}
@@ -41,31 +42,31 @@ Please see LICENSE in the repository root for full details.
</div>
<div class="content auto">
{% if grant.user_agent.model %}
<div>{{ grant.user_agent.model }}</div>
{% if user_agent.model %}
<div>{{ user_agent.model }}</div>
{% endif %}
{% if grant.user_agent.os %}
{% if user_agent.os %}
<div>
{{ grant.user_agent.os }}
{% if grant.user_agent.os_version %}
{{ grant.user_agent.os_version }}
{{ user_agent.os }}
{% if user_agent.os_version %}
{{ user_agent.os_version }}
{% endif %}
</div>
{% endif %}
{# If we haven't detected a model, it's probably a browser, so show the name #}
{% if not grant.user_agent.model and grant.user_agent.name %}
{% if not user_agent.model and user_agent.name %}
<div>
{{ grant.user_agent.name }}
{% if grant.user_agent.version %}
{{ grant.user_agent.version }}
{{ user_agent.name }}
{% if user_agent.version %}
{{ user_agent.version }}
{% endif %}
</div>
{% endif %}
{# If we couldn't detect anything, show a generic "Device" #}
{% if not grant.user_agent.model and not grant.user_agent.name and not grant.user_agent.os %}
{% if not user_agent.model and not user_agent.name and not user_agent.os %}
<div>{{ _("mas.device_card.generic_device") }}</div>
{% endif %}
</div>

View File

@@ -6,11 +6,11 @@
},
"cancel": "Cancel",
"@cancel": {
"context": "pages/consent.html:69:11-29, pages/device_consent.html:126:13-31, pages/policy_violation.html:44:13-31"
"context": "pages/consent.html:69:11-29, pages/device_consent.html:127:13-31, pages/policy_violation.html:44:13-31"
},
"continue": "Continue",
"@continue": {
"context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:123:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48"
"context": "form_post.html:25:28-48, pages/consent.html:57:28-48, pages/device_consent.html:124:13-33, pages/device_link.html:40:26-46, pages/login.html:68:30-50, pages/reauth.html:32:28-48, pages/recovery/start.html:38:26-46, pages/register/password.html:74:26-46, pages/register/steps/display_name.html:43:28-48, pages/register/steps/verify_email.html:51:26-46, pages/sso.html:37:28-48"
},
"create_account": "Create Account",
"@create_account": {
@@ -22,7 +22,7 @@
},
"sign_out": "Sign out",
"@sign_out": {
"context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:135:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46"
"context": "pages/account/logged_out.html:22:28-48, pages/consent.html:65:28-48, pages/device_consent.html:136:30-50, pages/index.html:28:28-48, pages/policy_violation.html:38:28-48, pages/sso.html:45:28-48, pages/upstream_oauth2/link_mismatch.html:24:24-44, pages/upstream_oauth2/suggest_link.html:32:26-46"
},
"skip": "Skip",
"@skip": {
@@ -195,37 +195,37 @@
},
"heading": "Allow access to your account?",
"@heading": {
"context": "pages/consent.html:25:27-51, pages/device_consent.html:27:29-53"
"context": "pages/consent.html:25:27-51, pages/device_consent.html:28:29-53"
},
"make_sure_you_trust": "Make sure that you trust <span>%(client_name)s</span>.",
"@make_sure_you_trust": {
"context": "pages/consent.html:38:81-142, pages/device_consent.html:103:83-144"
"context": "pages/consent.html:38:81-142, pages/device_consent.html:104:83-144"
},
"this_will_allow": "This will allow <span>%(client_name)s</span> to:",
"@this_will_allow": {
"context": "pages/consent.html:28:11-68, pages/device_consent.html:93:13-70"
"context": "pages/consent.html:28:11-68, pages/device_consent.html:94:13-70"
},
"you_may_be_sharing": "You may be sharing sensitive information with this site or app.",
"@you_may_be_sharing": {
"context": "pages/consent.html:39:7-42, pages/device_consent.html:104:9-44"
"context": "pages/consent.html:39:7-42, pages/device_consent.html:105:9-44"
}
},
"device_card": {
"access_requested": "Access requested",
"@access_requested": {
"context": "pages/device_consent.html:81:34-71"
"context": "pages/device_consent.html:82:34-71"
},
"device_code": "Code",
"@device_code": {
"context": "pages/device_consent.html:85:34-66"
"context": "pages/device_consent.html:86:34-66"
},
"generic_device": "Device",
"@generic_device": {
"context": "pages/device_consent.html:69:22-57"
"context": "pages/device_consent.html:70:22-57"
},
"ip_address": "IP address",
"@ip_address": {
"context": "pages/device_consent.html:76:36-67"
"context": "pages/device_consent.html:77:36-67"
}
},
"device_code_link": {
@@ -241,29 +241,45 @@
"device_consent": {
"another_device_access": "Another device wants to access your account.",
"@another_device_access": {
"context": "pages/device_consent.html:92:13-58"
"context": "pages/device_consent.html:93:13-58"
},
"denied": {
"description": "You denied access to %(client_name)s. You can close this window.",
"@description": {
"context": "pages/device_consent.html:146:27-94"
"context": "pages/device_consent.html:147:27-94"
},
"heading": "Access denied",
"@heading": {
"context": "pages/device_consent.html:145:29-67"
"context": "pages/device_consent.html:146:29-67"
}
},
"granted": {
"description": "You granted access to %(client_name)s. You can close this window.",
"@description": {
"context": "pages/device_consent.html:157:27-95"
"context": "pages/device_consent.html:158:27-95"
},
"heading": "Access granted",
"@heading": {
"context": "pages/device_consent.html:156:29-68"
"context": "pages/device_consent.html:157:29-68"
}
}
},
"device_display_name": {
"client_on_device": "%(client_name)s on %(device_name)s",
"@client_on_device": {
"context": "device_name.txt:28:4-99",
"description": "The automatic device name generated for a client, e.g. 'Element on iPhone'"
},
"name_for_platform": "%(name)s for %(platform)s",
"@name_for_platform": {
"context": "device_name.txt:19:10-102",
"description": "Part of the automatic device name for the platfom, e.g. 'Safari for macOS'"
},
"unknown_device": "Unknown device",
"@unknown_device": {
"context": "device_name.txt:24:8-51"
}
},
"email_in_use": {
"description": "If you have forgotten your account credentials, you can recover your account. You can also start over and use a different email address.",
"@description": {
@@ -469,7 +485,7 @@
},
"not_you": "Not %(username)s?",
"@not_you": {
"context": "pages/consent.html:62:11-67, pages/device_consent.html:132:13-69, pages/sso.html:42:11-67",
"context": "pages/consent.html:62:11-67, pages/device_consent.html:133:13-69, pages/sso.html:42:11-67",
"description": "Suggestions for the user to log in as a different user"
},
"or_separator": "Or",