1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-31 09:24:31 +03:00

Parse User Agents on the backend side (#2388)

* Parse user agents on the server side

* Parse and expose user agents on the backend

* Use the parsed user agent in the device consent page

* Fix the device icon tests

* Fix clippy warnings

* Box stuff to avoid large enum variants

* Ignore a clippy warning

* Fix the requester boxing
This commit is contained in:
Quentin Gliech
2024-02-23 16:47:48 +01:00
committed by GitHub
parent f171d76dc5
commit f3cbd3b315
58 changed files with 1019 additions and 855 deletions

12
Cargo.lock generated
View File

@ -3070,10 +3070,12 @@ dependencies = [
"oauth2-types",
"rand 0.8.5",
"rand_chacha 0.3.1",
"regex",
"serde",
"thiserror",
"ulid",
"url",
"woothee",
]
[[package]]
@ -7198,6 +7200,16 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "woothee"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "896174c6a4779d4d7d4523dd27aef7d46609eda2497e370f6c998325c6bf6971"
dependencies = [
"lazy_static",
"regex",
]
[[package]]
name = "writeable"
version = "0.5.4"

View File

@ -20,6 +20,8 @@ crc = "3.0.1"
ulid.workspace = true
rand.workspace = true
rand_chacha = "0.3.1"
regex = "1.10.3"
woothee = "0.13.0"
mas-iana.workspace = true
mas-jose.workspace = true

View File

@ -1,4 +1,4 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@ -12,4 +12,15 @@
// See the License for the specific language governing permissions and
// limitations under the License.
export * from "./useCurrentBrowserSessionId";
use mas_data_model::UserAgent;
/// Simple command-line tool to try out user-agent parsing
///
/// It parses user-agents from stdin and prints the parsed user-agent to stdout.
fn main() {
for line in std::io::stdin().lines() {
let user_agent = line.unwrap();
let user_agent = UserAgent::parse(user_agent);
println!("{user_agent:?}");
}
}

View File

@ -19,7 +19,7 @@ use serde::Serialize;
use ulid::Ulid;
use super::Device;
use crate::InvalidTransitionError;
use crate::{InvalidTransitionError, UserAgent};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub enum CompatSessionState {
@ -83,7 +83,7 @@ pub struct CompatSession {
pub user_session_id: Option<Ulid>,
pub created_at: DateTime<Utc>,
pub is_synapse_admin: bool,
pub user_agent: Option<String>,
pub user_agent: Option<UserAgent>,
pub last_active_at: Option<DateTime<Utc>>,
pub last_active_ip: Option<IpAddr>,
}

View File

@ -20,6 +20,7 @@ pub(crate) mod compat;
pub(crate) mod oauth2;
pub(crate) mod tokens;
pub(crate) mod upstream_oauth2;
pub(crate) mod user_agent;
pub(crate) mod users;
/// Error when an invalid state transition is attempted.
@ -46,6 +47,7 @@ pub use self::{
UpstreamOAuthProviderImportAction, UpstreamOAuthProviderImportPreference,
UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderSubjectPreference,
},
user_agent::{DeviceType, UserAgent},
users::{
Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail,
UserEmailVerification, UserEmailVerificationState,

View File

@ -19,7 +19,7 @@ use oauth2_types::scope::Scope;
use serde::Serialize;
use ulid::Ulid;
use crate::{BrowserSession, InvalidTransitionError, Session};
use crate::{BrowserSession, InvalidTransitionError, Session, UserAgent};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case", tag = "state")]
@ -200,7 +200,7 @@ pub struct DeviceCodeGrant {
pub ip_address: Option<IpAddr>,
/// The user agent used to request this device code grant.
pub user_agent: Option<String>,
pub user_agent: Option<UserAgent>,
}
impl std::ops::Deref for DeviceCodeGrant {

View File

@ -19,7 +19,7 @@ use oauth2_types::scope::Scope;
use serde::Serialize;
use ulid::Ulid;
use crate::InvalidTransitionError;
use crate::{InvalidTransitionError, UserAgent};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub enum SessionState {
@ -75,7 +75,7 @@ pub struct Session {
pub user_session_id: Option<Ulid>,
pub client_id: Ulid,
pub scope: Scope,
pub user_agent: Option<String>,
pub user_agent: Option<UserAgent>,
pub last_active_at: Option<DateTime<Utc>>,
pub last_active_ip: Option<IpAddr>,
}

View File

@ -0,0 +1,217 @@
// Copyright 2024 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use serde::Serialize;
use woothee::{parser::Parser, woothee::VALUE_UNKNOWN};
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum DeviceType {
Pc,
Mobile,
Tablet,
Unknown,
}
#[derive(Debug, Serialize, Clone, PartialEq, Eq)]
pub struct UserAgent {
pub name: Option<String>,
pub version: Option<String>,
pub os: Option<String>,
pub os_version: Option<String>,
pub model: Option<String>,
pub device_type: DeviceType,
pub raw: String,
}
impl std::ops::Deref for UserAgent {
type Target = str;
fn deref(&self) -> &Self::Target {
&self.raw
}
}
impl UserAgent {
fn parse_custom(user_agent: &str) -> Option<(&str, &str, &str, &str, Option<&str>)> {
let regex = regex::Regex::new(r"^(?P<name>[^/]+)/(?P<version>[^ ]+) \((?P<segments>.+)\)$")
.unwrap();
let captures = regex.captures(user_agent)?;
let name = captures.name("name")?.as_str();
let version = captures.name("version")?.as_str();
let segments: Vec<&str> = captures
.name("segments")?
.as_str()
.split(';')
.map(str::trim)
.collect();
match segments[..] {
["Linux", "U", os, model, ..] | [model, os, ..] => {
// Most android model have a `/[build version]` suffix we don't care about
let model = model.split_once('/').map_or(model, |(model, _)| model);
// Some android version also have `Build/[build version]` suffix we don't care
// about
let model = model.strip_suffix("Build").unwrap_or(model);
// And let's trim any leftovers
let model = model.trim();
let (os, os_version) = if let Some((os, version)) = os.split_once(' ') {
(os, Some(version))
} else {
(os, None)
};
Some((name, version, model, os, os_version))
}
_ => None,
}
}
#[must_use]
pub fn parse(user_agent: String) -> Self {
if !user_agent.contains("Mozilla/") {
if let Some((name, version, model, os, os_version)) =
UserAgent::parse_custom(&user_agent)
{
let mut device_type = DeviceType::Unknown;
// Handle mobile simple mobile devices
if os == "Android" || os == "iOS" {
device_type = DeviceType::Mobile;
}
// Handle iPads
if model.contains("iPad") {
device_type = DeviceType::Tablet;
}
return Self {
name: Some(name.to_owned()),
version: Some(version.to_owned()),
os: Some(os.to_owned()),
os_version: os_version.map(std::borrow::ToOwned::to_owned),
model: Some(model.to_owned()),
device_type,
raw: user_agent,
};
}
}
let mut model = None;
let Some(mut result) = Parser::new().parse(&user_agent) else {
return Self {
raw: user_agent,
name: None,
version: None,
os: None,
os_version: None,
model: None,
device_type: DeviceType::Unknown,
};
};
let mut device_type = match result.category {
"pc" => DeviceType::Pc,
"smartphone" | "mobilephone" => DeviceType::Mobile,
_ => DeviceType::Unknown,
};
// Special handling for Chrome user-agent reduction cases
// https://www.chromium.org/updates/ua-reduction/
match (result.os, &*result.os_version) {
// Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/533.88 (KHTML, like Gecko)
// Chrome/109.1.2342.76 Safari/533.88
("Windows 10", "NT 10.0") if user_agent.contains("Windows NT 10.0; Win64; x64") => {
result.os = "Windows";
result.os_version = VALUE_UNKNOWN.into();
}
// Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like
// Gecko) Chrome/100.0.4896.133 Safari/537.36
("Mac OSX", "10.15.7") if user_agent.contains("Macintosh; Intel Mac OS X 10_15_7") => {
result.os = "macOS";
result.os_version = VALUE_UNKNOWN.into();
}
// Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko)
// Chrome/100.0.0.0 Safari/537.36
("Linux", _) if user_agent.contains("X11; Linux x86_64") => {
result.os = "Linux";
result.os_version = VALUE_UNKNOWN.into();
}
// Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko)
// Chrome/107.0.0.0 Safari/537.36
("ChromeOS", _) if user_agent.contains("X11; CrOS x86_64 14541.0.0") => {
result.os = "Chrome OS";
result.os_version = VALUE_UNKNOWN.into();
}
// Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko)
// Chrome/100.0.0.0 Mobile Safari/537.36
("Android", "10") if user_agent.contains("Linux; Android 10; K") => {
result.os = "Android";
result.os_version = VALUE_UNKNOWN.into();
}
// Safari also freezes the OS version
// Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like
// Gecko) Version/17.3.1 Safari/605.1.15
("Mac OSX", "10.15.7") if user_agent.contains("Macintosh; Intel Mac OS X 10_15_7") => {
result.os = "macOS";
result.os_version = VALUE_UNKNOWN.into();
}
// Woothee identifies iPhone and iPod in the OS, but we want to map them to iOS and use
// them as model
("iPhone" | "iPod", _) => {
model = Some(result.os.to_owned());
result.os = "iOS";
}
("iPad", _) => {
model = Some(result.os.to_owned());
device_type = DeviceType::Tablet;
result.os = "iPadOS";
}
// Also map `Mac OSX` to `macOS`
("Mac OSX", _) => {
result.os = "macOS";
}
_ => {}
}
// For some reason, the version on Windows is on the OS field
// This transforms `Windows 10` into `Windows` and `10`
if let Some(version) = result.os.strip_prefix("Windows ") {
result.os = "Windows";
result.os_version = version.into();
}
Self {
name: (result.name != VALUE_UNKNOWN).then(|| result.name.to_owned()),
version: (result.version != VALUE_UNKNOWN).then(|| result.version.to_owned()),
os: (result.os != VALUE_UNKNOWN).then(|| result.os.to_owned()),
os_version: (result.os_version != VALUE_UNKNOWN)
.then(|| result.os_version.into_owned()),
device_type,
model,
raw: user_agent,
}
}
}

View File

@ -19,6 +19,8 @@ use rand::{Rng, SeedableRng};
use serde::Serialize;
use ulid::Ulid;
use crate::UserAgent;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct User {
pub id: Ulid,
@ -83,7 +85,7 @@ pub struct BrowserSession {
pub user: User,
pub created_at: DateTime<Utc>,
pub finished_at: Option<DateTime<Utc>>,
pub user_agent: Option<String>,
pub user_agent: Option<UserAgent>,
pub last_active_at: Option<DateTime<Utc>>,
pub last_active_ip: Option<IpAddr>,
}
@ -105,7 +107,9 @@ impl BrowserSession {
user,
created_at: now,
finished_at: None,
user_agent: Some("Mozilla/5.0".to_owned()),
user_agent: Some(UserAgent::parse(
"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()
)),
last_active_at: Some(now),
last_active_ip: None,
})

View File

@ -49,10 +49,10 @@ pub enum Requester {
Anonymous,
/// The requester is a browser session, stored in a cookie.
BrowserSession(BrowserSession),
BrowserSession(Box<BrowserSession>),
/// The requester is a OAuth2 session, with an access token.
OAuth2Session(Session, Option<User>),
OAuth2Session(Box<(Session, Option<User>)>),
}
trait OwnerId {
@ -108,21 +108,21 @@ impl Requester {
fn browser_session(&self) -> Option<&BrowserSession> {
match self {
Self::BrowserSession(session) => Some(session),
Self::OAuth2Session(_, _) | Self::Anonymous => None,
Self::OAuth2Session(_) | Self::Anonymous => None,
}
}
fn user(&self) -> Option<&User> {
match self {
Self::BrowserSession(session) => Some(&session.user),
Self::OAuth2Session(_session, user) => user.as_ref(),
Self::OAuth2Session(tuple) => tuple.1.as_ref(),
Self::Anonymous => None,
}
}
fn oauth2_session(&self) -> Option<&Session> {
match self {
Self::OAuth2Session(session, _) => Some(session),
Self::OAuth2Session(tuple) => Some(&tuple.0),
Self::BrowserSession(_) | Self::Anonymous => None,
}
}
@ -148,10 +148,10 @@ impl Requester {
fn is_admin(&self) -> bool {
match self {
Self::OAuth2Session(session, _user) => {
Self::OAuth2Session(tuple) => {
// TODO: is this the right scope?
// This has to be in sync with the policy
session.scope.contains("urn:mas:admin")
tuple.0.scope.contains("urn:mas:admin")
}
Self::BrowserSession(_) | Self::Anonymous => false,
}
@ -160,7 +160,7 @@ impl Requester {
impl From<BrowserSession> for Requester {
fn from(session: BrowserSession) -> Self {
Self::BrowserSession(session)
Self::BrowserSession(Box::new(session))
}
}

View File

@ -24,7 +24,7 @@ use mas_storage::{
use super::{
AppSession, CompatSession, Cursor, NodeCursor, NodeType, OAuth2Session, PreloadedTotalCount,
SessionState, User,
SessionState, User, UserAgent,
};
use crate::state::ContextExt;
@ -87,9 +87,9 @@ impl BrowserSession {
}
}
/// The user-agent string with which the session was created.
pub async fn user_agent(&self) -> Option<&str> {
self.0.user_agent.as_deref()
/// The user-agent with which the session was created.
pub async fn user_agent(&self) -> Option<UserAgent> {
self.0.user_agent.clone().map(UserAgent::from)
}
/// The last IP address used by the session.

View File

@ -18,7 +18,7 @@ use chrono::{DateTime, Utc};
use mas_storage::{compat::CompatSessionRepository, user::UserRepository};
use url::Url;
use super::{BrowserSession, NodeType, SessionState, User};
use super::{BrowserSession, NodeType, SessionState, User, UserAgent};
use crate::state::ContextExt;
/// Lazy-loaded reverse reference.
@ -103,6 +103,11 @@ impl CompatSession {
self.session.finished_at()
}
/// The user-agent with which the session was created.
pub async fn user_agent(&self) -> Option<UserAgent> {
self.session.user_agent.clone().map(UserAgent::from)
}
/// The associated SSO login, if any.
pub async fn sso_login(
&self,

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use async_graphql::{Enum, Interface, Object};
use async_graphql::{Enum, Interface, Object, SimpleObject};
use chrono::{DateTime, Utc};
mod browser_sessions;
@ -73,3 +73,69 @@ pub enum SessionState {
/// The session is no longer active.
Finished,
}
/// The type of a user agent
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
pub enum DeviceType {
/// A personal computer, laptop or desktop
Pc,
/// A mobile phone. Can also sometimes be a tablet.
Mobile,
/// A tablet
Tablet,
/// Unknown device type
Unknown,
}
impl From<mas_data_model::DeviceType> for DeviceType {
fn from(device_type: mas_data_model::DeviceType) -> Self {
match device_type {
mas_data_model::DeviceType::Pc => Self::Pc,
mas_data_model::DeviceType::Mobile => Self::Mobile,
mas_data_model::DeviceType::Tablet => Self::Tablet,
mas_data_model::DeviceType::Unknown => Self::Unknown,
}
}
}
/// A parsed user agent string
#[derive(SimpleObject)]
pub struct UserAgent {
/// The user agent string
pub raw: String,
/// The name of the browser
pub name: Option<String>,
/// The version of the browser
pub version: Option<String>,
/// The operating system name
pub os: Option<String>,
/// The operating system version
pub os_version: Option<String>,
/// The device model
pub model: Option<String>,
/// The device type
pub device_type: DeviceType,
}
impl From<mas_data_model::UserAgent> for UserAgent {
fn from(ua: mas_data_model::UserAgent) -> Self {
Self {
raw: ua.raw,
name: ua.name,
version: ua.version,
os: ua.os,
os_version: ua.os_version,
model: ua.model,
device_type: ua.device_type.into(),
}
}
}

View File

@ -20,7 +20,7 @@ use oauth2_types::{oidc::ApplicationType, scope::Scope};
use ulid::Ulid;
use url::Url;
use super::{BrowserSession, NodeType, SessionState, User};
use super::{BrowserSession, NodeType, SessionState, User, UserAgent};
use crate::{state::ContextExt, UserId};
/// An OAuth 2.0 session represents a client session which used the OAuth APIs
@ -67,6 +67,11 @@ impl OAuth2Session {
}
}
/// The user-agent with which the session was created.
pub async fn user_agent(&self) -> Option<UserAgent> {
self.0.user_agent.clone().map(UserAgent::from)
}
/// The state of the session.
pub async fn state(&self) -> SessionState {
match &self.0.state {

View File

@ -40,7 +40,7 @@ pub struct EndCompatSessionInput {
/// The payload of the `endCompatSession` mutation.
pub enum EndCompatSessionPayload {
NotFound,
Ended(mas_data_model::CompatSession),
Ended(Box<mas_data_model::CompatSession>),
}
/// The status of the `endCompatSession` mutation.
@ -66,7 +66,7 @@ impl EndCompatSessionPayload {
/// Returns the ended session.
async fn compat_session(&self) -> Option<CompatSession> {
match self {
Self::Ended(session) => Some(CompatSession::new(session.clone())),
Self::Ended(session) => Some(CompatSession::new(*session.clone())),
Self::NotFound => None,
}
}
@ -110,6 +110,6 @@ impl CompatSessionMutations {
repo.save().await?;
Ok(EndCompatSessionPayload::Ended(session))
Ok(EndCompatSessionPayload::Ended(Box::new(session)))
}
}

View File

@ -31,8 +31,11 @@ impl ViewerQuery {
match requester {
Requester::BrowserSession(session) => Viewer::user(session.user.clone()),
Requester::OAuth2Session(_session, Some(user)) => Viewer::user(user.clone()),
Requester::OAuth2Session(_, None) | Requester::Anonymous => Viewer::anonymous(),
Requester::OAuth2Session(tuple) => match &tuple.1 {
Some(user) => Viewer::user(user.clone()),
None => Viewer::anonymous(),
},
Requester::Anonymous => Viewer::anonymous(),
}
}
@ -41,10 +44,8 @@ impl ViewerQuery {
let requester = ctx.requester();
match requester {
Requester::BrowserSession(session) => ViewerSession::browser_session(session.clone()),
Requester::OAuth2Session(session, _user) => {
ViewerSession::oauth2_session(session.clone())
}
Requester::BrowserSession(session) => ViewerSession::browser_session(*session.clone()),
Requester::OAuth2Session(tuple) => ViewerSession::oauth2_session(tuple.0.clone()),
Requester::Anonymous => ViewerSession::anonymous(),
}
}

View File

@ -16,7 +16,7 @@ use axum::{extract::State, response::IntoResponse, Json, TypedHeader};
use chrono::Duration;
use hyper::StatusCode;
use mas_axum_utils::sentry::SentryEventID;
use mas_data_model::{CompatSession, CompatSsoLoginState, Device, TokenType, User};
use mas_data_model::{CompatSession, CompatSsoLoginState, Device, TokenType, User, UserAgent};
use mas_storage::{
compat::{
CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionRepository,
@ -220,7 +220,7 @@ pub(crate) async fn post(
user_agent: Option<TypedHeader<headers::UserAgent>>,
Json(input): Json<RequestBody>,
) -> Result<impl IntoResponse, RouteError> {
let user_agent = user_agent.map(|ua| ua.to_string());
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
let (mut session, user) = match (password_manager.is_enabled(), input.credentials) {
(
true,

View File

@ -237,7 +237,7 @@ async fn get_requester(
return Err(RouteError::MissingScope);
}
Requester::OAuth2Session(session, user)
Requester::OAuth2Session(Box::new((session, user)))
} else {
let maybe_session = session_info.load_session(&mut repo).await?;

View File

@ -14,13 +14,14 @@
use axum::{extract::State, response::IntoResponse, Json, TypedHeader};
use chrono::Duration;
use headers::{CacheControl, Pragma, UserAgent};
use headers::{CacheControl, Pragma};
use hyper::StatusCode;
use mas_axum_utils::{
client_authorization::{ClientAuthorization, CredentialsVerificationError},
http_client_factory::HttpClientFactory,
sentry::SentryEventID,
};
use mas_data_model::UserAgent;
use mas_keystore::Encrypter;
use mas_router::UrlBuilder;
use mas_storage::{oauth2::OAuth2DeviceCodeGrantParams, BoxClock, BoxRepository, BoxRng};
@ -84,7 +85,7 @@ pub(crate) async fn post(
mut rng: BoxRng,
clock: BoxClock,
mut repo: BoxRepository,
user_agent: Option<TypedHeader<UserAgent>>,
user_agent: Option<TypedHeader<headers::UserAgent>>,
activity_tracker: BoundActivityTracker,
State(url_builder): State<UrlBuilder>,
State(http_client_factory): State<HttpClientFactory>,
@ -125,7 +126,7 @@ pub(crate) async fn post(
let expires_in = Duration::minutes(20);
let user_agent = user_agent.map(|ua| ua.0.to_string());
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
let ip_address = activity_tracker.ip();
let device_code = Alphanumeric.sample_string(&mut rng, 32);

View File

@ -21,7 +21,9 @@ use mas_axum_utils::{
http_client_factory::HttpClientFactory,
sentry::SentryEventID,
};
use mas_data_model::{AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, TokenType};
use mas_data_model::{
AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, TokenType, UserAgent,
};
use mas_keystore::{Encrypter, Keystore};
use mas_oidc_client::types::scope::ScopeToken;
use mas_policy::Policy;
@ -233,7 +235,7 @@ pub(crate) async fn post(
user_agent: Option<TypedHeader<headers::UserAgent>>,
client_authorization: ClientAuthorization<AccessTokenRequest>,
) -> Result<impl IntoResponse, RouteError> {
let user_agent = user_agent.map(|ua| ua.to_string());
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
let client = client_authorization
.credentials
.fetch(&mut repo)
@ -335,7 +337,7 @@ async fn authorization_code_grant(
url_builder: &UrlBuilder,
site_config: &SiteConfig,
mut repo: BoxRepository,
user_agent: Option<String>,
user_agent: Option<UserAgent>,
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
// Check that the client is allowed to use this grant type
if !client.grant_types.contains(&GrantType::AuthorizationCode) {
@ -504,7 +506,7 @@ async fn refresh_token_grant(
client: &Client,
site_config: &SiteConfig,
mut repo: BoxRepository,
user_agent: Option<String>,
user_agent: Option<UserAgent>,
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
// Check that the client is allowed to use this grant type
if !client.grant_types.contains(&GrantType::RefreshToken) {
@ -587,7 +589,7 @@ async fn client_credentials_grant(
site_config: &SiteConfig,
mut repo: BoxRepository,
mut policy: Policy,
user_agent: Option<String>,
user_agent: Option<UserAgent>,
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
// Check that the client is allowed to use this grant type
if !client.grant_types.contains(&GrantType::ClientCredentials) {
@ -656,7 +658,7 @@ async fn device_code_grant(
url_builder: &UrlBuilder,
site_config: &SiteConfig,
mut repo: BoxRepository,
user_agent: Option<String>,
user_agent: Option<UserAgent>,
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
// Check that the client is allowed to use this grant type
if !client.grant_types.contains(&GrantType::DeviceCode) {

View File

@ -24,7 +24,7 @@ use mas_axum_utils::{
sentry::SentryEventID,
FancyError, SessionInfoExt,
};
use mas_data_model::User;
use mas_data_model::{User, UserAgent};
use mas_jose::jwt::Jwt;
use mas_policy::Policy;
use mas_router::UrlBuilder;
@ -200,7 +200,7 @@ pub(crate) async fn get(
user_agent: Option<TypedHeader<headers::UserAgent>>,
Path(link_id): Path<Ulid>,
) -> Result<impl IntoResponse, RouteError> {
let user_agent = user_agent.map(|ua| ua.as_str().to_owned());
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar);
let (session_id, post_auth_action) = sessions_cookie
.lookup_link(link_id)
@ -481,7 +481,7 @@ pub(crate) async fn post(
Path(link_id): Path<Ulid>,
Form(form): Form<ProtectedForm<FormData>>,
) -> Result<Response, RouteError> {
let user_agent = user_agent.map(|ua| ua.as_str().to_owned());
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
let form = cookie_jar.verify_form(&clock, form)?;
let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar);

View File

@ -17,14 +17,13 @@ use axum::{
response::{Html, IntoResponse, Response},
TypedHeader,
};
use headers::UserAgent;
use hyper::StatusCode;
use mas_axum_utils::{
cookies::CookieJar,
csrf::{CsrfExt, CsrfToken, ProtectedForm},
FancyError, SessionInfoExt,
};
use mas_data_model::BrowserSession;
use mas_data_model::{BrowserSession, UserAgent};
use mas_i18n::DataLocale;
use mas_router::{UpstreamOAuth2Authorize, UrlBuilder};
use mas_storage::{
@ -123,10 +122,10 @@ pub(crate) async fn post(
activity_tracker: BoundActivityTracker,
Query(query): Query<OptionalPostAuthAction>,
cookie_jar: CookieJar,
user_agent: Option<TypedHeader<UserAgent>>,
user_agent: Option<TypedHeader<headers::UserAgent>>,
Form(form): Form<ProtectedForm<LoginForm>>,
) -> Result<Response, FancyError> {
let user_agent = user_agent.map(|ua| ua.as_str().to_owned());
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
if !password_manager.is_enabled() {
// XXX: is it necessary to have better errors here?
return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
@ -216,7 +215,7 @@ async fn login(
clock: &impl Clock,
username: &str,
password: &str,
user_agent: Option<String>,
user_agent: Option<UserAgent>,
) -> Result<BrowserSession, FormError> {
// XXX: we're loosing the error context here
// First, lookup the user

View File

@ -19,7 +19,6 @@ use axum::{
response::{Html, IntoResponse, Response},
TypedHeader,
};
use headers::UserAgent;
use hyper::StatusCode;
use lettre::Address;
use mas_axum_utils::{
@ -27,6 +26,7 @@ use mas_axum_utils::{
csrf::{CsrfExt, CsrfToken, ProtectedForm},
FancyError, SessionInfoExt,
};
use mas_data_model::UserAgent;
use mas_i18n::DataLocale;
use mas_policy::Policy;
use mas_router::UrlBuilder;
@ -116,10 +116,10 @@ pub(crate) async fn post(
activity_tracker: BoundActivityTracker,
Query(query): Query<OptionalPostAuthAction>,
cookie_jar: CookieJar,
user_agent: Option<TypedHeader<UserAgent>>,
user_agent: Option<TypedHeader<headers::UserAgent>>,
Form(form): Form<ProtectedForm<RegisterForm>>,
) -> Result<Response, FancyError> {
let user_agent = user_agent.map(|ua| ua.as_str().to_owned());
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
if !password_manager.is_enabled() {
return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
}

View File

@ -15,7 +15,7 @@
//! A module containing PostgreSQL implementation of repositories for sessions
use async_trait::async_trait;
use mas_data_model::{CompatSession, CompatSessionState, Device, Session, SessionState};
use mas_data_model::{CompatSession, CompatSessionState, Device, Session, SessionState, UserAgent};
use mas_storage::{
app_session::{AppSession, AppSessionFilter, AppSessionRepository},
Page, Pagination,
@ -84,6 +84,7 @@ use priv_::{AppSessionLookup, AppSessionLookupIden};
impl TryFrom<AppSessionLookup> for AppSession {
type Error = DatabaseError;
#[allow(clippy::too_many_lines)]
fn try_from(value: AppSessionLookup) -> Result<Self, Self::Error> {
// This is annoying to do, but we have to match on all the fields to determine
// whether it's a compat session or an oauth2 session
@ -104,6 +105,7 @@ impl TryFrom<AppSessionLookup> for AppSession {
last_active_ip,
} = value;
let user_agent = user_agent.map(UserAgent::parse);
let user_session_id = user_session_id.map(Ulid::from);
match (

View File

@ -28,7 +28,7 @@ pub use self::{
#[cfg(test)]
mod tests {
use chrono::Duration;
use mas_data_model::Device;
use mas_data_model::{Device, UserAgent};
use mas_storage::{
clock::MockClock,
compat::{
@ -133,7 +133,7 @@ mod tests {
assert!(session_lookup.user_agent.is_none());
let session = repo
.compat_session()
.record_user_agent(session_lookup, "Mozilla/5.0".to_owned())
.record_user_agent(session_lookup, UserAgent::parse("Mozilla/5.0".to_owned()))
.await
.unwrap();
assert_eq!(session.user_agent.as_deref(), Some("Mozilla/5.0"));

View File

@ -18,7 +18,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{
BrowserSession, CompatSession, CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device,
User,
User, UserAgent,
};
use mas_storage::{
compat::{CompatSessionFilter, CompatSessionRepository},
@ -90,7 +90,7 @@ impl TryFrom<CompatSessionLookup> for CompatSession {
device,
created_at: value.created_at,
is_synapse_admin: value.is_synapse_admin,
user_agent: value.user_agent,
user_agent: value.user_agent.map(UserAgent::parse),
last_active_at: value.last_active_at,
last_active_ip: value.last_active_ip,
};
@ -145,7 +145,7 @@ impl TryFrom<CompatSessionAndSsoLoginLookup> for (CompatSession, Option<CompatSs
user_session_id: value.user_session_id.map(Ulid::from),
created_at: value.created_at,
is_synapse_admin: value.is_synapse_admin,
user_agent: value.user_agent,
user_agent: value.user_agent.map(UserAgent::parse),
last_active_at: value.last_active_at,
last_active_ip: value.last_active_ip,
};
@ -575,7 +575,7 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> {
async fn record_user_agent(
&mut self,
mut compat_session: CompatSession,
user_agent: String,
user_agent: UserAgent,
) -> Result<CompatSession, Self::Error> {
let res = sqlx::query!(
r#"
@ -584,7 +584,7 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> {
WHERE compat_session_id = $1
"#,
Uuid::from(compat_session.id),
user_agent,
&*user_agent,
)
.traced()
.execute(&mut *self.conn)

View File

@ -16,7 +16,7 @@ use std::net::IpAddr;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{BrowserSession, DeviceCodeGrant, DeviceCodeGrantState, Session};
use mas_data_model::{BrowserSession, DeviceCodeGrant, DeviceCodeGrantState, Session, UserAgent};
use mas_storage::{
oauth2::{OAuth2DeviceCodeGrantParams, OAuth2DeviceCodeGrantRepository},
Clock,
@ -140,7 +140,7 @@ impl TryFrom<OAuth2DeviceGrantLookup> for DeviceCodeGrant {
created_at,
expires_at,
ip_address,
user_agent,
user_agent: user_agent.map(UserAgent::parse),
})
}
}

View File

@ -32,7 +32,7 @@ pub use self::{
#[cfg(test)]
mod tests {
use chrono::Duration;
use mas_data_model::AuthorizationCode;
use mas_data_model::{AuthorizationCode, UserAgent};
use mas_storage::{
clock::MockClock,
oauth2::{OAuth2DeviceCodeGrantParams, OAuth2SessionFilter, OAuth2SessionRepository},
@ -371,7 +371,7 @@ mod tests {
assert!(session.user_agent.is_none());
let session = repo
.oauth2_session()
.record_user_agent(session, "Mozilla/5.0".to_owned())
.record_user_agent(session, UserAgent::parse("Mozilla/5.0".to_owned()))
.await
.unwrap();
assert_eq!(session.user_agent.as_deref(), Some("Mozilla/5.0"));

View File

@ -16,7 +16,7 @@ use std::net::IpAddr;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{BrowserSession, Client, Session, SessionState, User};
use mas_data_model::{BrowserSession, Client, Session, SessionState, User, UserAgent};
use mas_storage::{
oauth2::{OAuth2SessionFilter, OAuth2SessionRepository},
Clock, Page, Pagination,
@ -94,7 +94,7 @@ impl TryFrom<OAuthSessionLookup> for Session {
user_id: value.user_id.map(Ulid::from),
user_session_id: value.user_session_id.map(Ulid::from),
scope,
user_agent: value.user_agent,
user_agent: value.user_agent.map(UserAgent::parse),
last_active_at: value.last_active_at,
last_active_ip: value.last_active_ip,
})
@ -444,14 +444,14 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> {
%session.id,
%session.scope,
client.id = %session.client_id,
session.user_agent = %user_agent,
session.user_agent = %user_agent.raw,
),
err,
)]
async fn record_user_agent(
&mut self,
mut session: Session,
user_agent: String,
user_agent: UserAgent,
) -> Result<Session, Self::Error> {
let res = sqlx::query!(
r#"
@ -460,7 +460,7 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> {
WHERE oauth2_session_id = $1
"#,
Uuid::from(session.id),
user_agent,
&*user_agent,
)
.traced()
.execute(&mut *self.conn)

View File

@ -18,7 +18,7 @@ use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{
Authentication, AuthenticationMethod, BrowserSession, Password,
UpstreamOAuthAuthorizationSession, User,
UpstreamOAuthAuthorizationSession, User, UserAgent,
};
use mas_storage::{user::BrowserSessionRepository, Clock, Page, Pagination};
use rand::RngCore;
@ -86,7 +86,7 @@ impl TryFrom<SessionLookup> for BrowserSession {
user,
created_at: value.user_session_created_at,
finished_at: value.user_session_finished_at,
user_agent: value.user_session_user_agent,
user_agent: value.user_session_user_agent.map(UserAgent::parse),
last_active_at: value.user_session_last_active_at,
last_active_ip: value.user_session_last_active_ip,
})
@ -189,7 +189,7 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> {
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
user: &User,
user_agent: Option<String>,
user_agent: Option<UserAgent>,
) -> Result<BrowserSession, Self::Error> {
let created_at = clock.now();
let id = Ulid::from_datetime_with_source(created_at.into(), rng);
@ -203,7 +203,7 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> {
Uuid::from(id),
Uuid::from(user.id),
created_at,
user_agent,
user_agent.as_deref(),
)
.traced()
.execute(&mut *self.conn)

View File

@ -16,7 +16,7 @@ use std::net::IpAddr;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, Device, User};
use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, Device, User, UserAgent};
use rand_core::RngCore;
use ulid::Ulid;
@ -266,7 +266,7 @@ pub trait CompatSessionRepository: Send + Sync {
async fn record_user_agent(
&mut self,
compat_session: CompatSession,
user_agent: String,
user_agent: UserAgent,
) -> Result<CompatSession, Self::Error>;
}
@ -305,6 +305,6 @@ repository_impl!(CompatSessionRepository:
async fn record_user_agent(
&mut self,
compat_session: CompatSession,
user_agent: String,
user_agent: UserAgent,
) -> Result<CompatSession, Self::Error>;
);

View File

@ -16,7 +16,7 @@ use std::net::IpAddr;
use async_trait::async_trait;
use chrono::Duration;
use mas_data_model::{BrowserSession, Client, DeviceCodeGrant, Session};
use mas_data_model::{BrowserSession, Client, DeviceCodeGrant, Session, UserAgent};
use oauth2_types::scope::Scope;
use rand_core::RngCore;
use ulid::Ulid;
@ -44,7 +44,7 @@ pub struct OAuth2DeviceCodeGrantParams<'a> {
pub ip_address: Option<IpAddr>,
/// The user agent from which the request was made
pub user_agent: Option<String>,
pub user_agent: Option<UserAgent>,
}
/// An [`OAuth2DeviceCodeGrantRepository`] helps interacting with

View File

@ -16,7 +16,7 @@ use std::net::IpAddr;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{BrowserSession, Client, Session, User};
use mas_data_model::{BrowserSession, Client, Session, User, UserAgent};
use oauth2_types::scope::Scope;
use rand_core::RngCore;
use ulid::Ulid;
@ -296,7 +296,7 @@ pub trait OAuth2SessionRepository: Send + Sync {
async fn record_user_agent(
&mut self,
session: Session,
user_agent: String,
user_agent: UserAgent,
) -> Result<Session, Self::Error>;
}
@ -349,6 +349,6 @@ repository_impl!(OAuth2SessionRepository:
async fn record_user_agent(
&mut self,
session: Session,
user_agent: String,
user_agent: UserAgent,
) -> Result<Session, Self::Error>;
);

View File

@ -17,7 +17,7 @@ use std::net::IpAddr;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use mas_data_model::{
Authentication, BrowserSession, Password, UpstreamOAuthAuthorizationSession, User,
Authentication, BrowserSession, Password, UpstreamOAuthAuthorizationSession, User, UserAgent,
};
use rand_core::RngCore;
use ulid::Ulid;
@ -127,7 +127,7 @@ pub trait BrowserSessionRepository: Send + Sync {
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
user: &User,
user_agent: Option<String>,
user_agent: Option<UserAgent>,
) -> Result<BrowserSession, Self::Error>;
/// Finish a [`BrowserSession`]
@ -254,7 +254,7 @@ repository_impl!(BrowserSessionRepository:
rng: &mut (dyn RngCore + Send),
clock: &dyn Clock,
user: &User,
user_agent: Option<String>,
user_agent: Option<UserAgent>,
) -> Result<BrowserSession, Self::Error>;
async fn finish(
&mut self,

View File

@ -25,7 +25,7 @@ use chrono::{DateTime, Duration, Utc};
use http::{Method, Uri, Version};
use mas_data_model::{
AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState,
DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserEmail,
DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserAgent, UserEmail,
UserEmailVerification,
};
use mas_i18n::DataLocale;
@ -1164,7 +1164,7 @@ impl TemplateContext for DeviceConsentContext {
created_at: now - Duration::minutes(5),
expires_at: now + Duration::minutes(25),
ip_address: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
user_agent: Some("Mozilla/5.0".to_owned()),
user_agent: Some(UserAgent::parse("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())),
};
Self { grant, client }
})

View File

@ -102,7 +102,7 @@ module.exports = {
exceptions: {
// The '*Connection', '*Edge', '*Payload' and 'PageInfo' types don't have IDs
// XXX: Maybe the MatrixUser type should have an ID?
types: ["PageInfo", "MatrixUser"],
types: ["PageInfo", "MatrixUser", "UserAgent"],
suffixes: ["Connection", "Edge", "Payload"],
},
},

View File

@ -55,10 +55,10 @@
"session_details_title": "Session"
},
"device_type_icon_label": {
"desktop": "Desktop",
"mobile": "Mobile",
"unknown": "Unknown device type",
"web": "Web"
"pc": "Computer",
"tablet": "Tablet",
"unknown": "Unknown device type"
},
"end_session_button": {
"confirmation_modal_title": "Are you sure you want to end this session?",

View File

@ -212,9 +212,9 @@ type BrowserSession implements Node & CreationEvent {
"""
state: SessionState!
"""
The user-agent string with which the session was created.
The user-agent with which the session was created.
"""
userAgent: String
userAgent: UserAgent
"""
The last IP address used by the session.
"""
@ -314,6 +314,10 @@ type CompatSession implements Node & CreationEvent {
"""
finishedAt: DateTime
"""
The user-agent with which the session was created.
"""
userAgent: UserAgent
"""
The associated SSO login, if any.
"""
ssoLogin: CompatSsoLogin
@ -500,6 +504,28 @@ The input/output is a string in RFC3339 format.
"""
scalar DateTime
"""
The type of a user agent
"""
enum DeviceType {
"""
A personal computer, laptop or desktop
"""
PC
"""
A mobile phone. Can also sometimes be a tablet.
"""
MOBILE
"""
A tablet
"""
TABLET
"""
Unknown device type
"""
UNKNOWN
}
"""
The input of the `endBrowserSession` mutation.
"""
@ -822,6 +848,10 @@ type Oauth2Session implements Node & CreationEvent {
"""
finishedAt: DateTime
"""
The user-agent with which the session was created.
"""
userAgent: UserAgent
"""
The state of the session.
"""
state: SessionState!
@ -1529,6 +1559,40 @@ type User implements Node {
): AppSessionConnection!
}
"""
A parsed user agent string
"""
type UserAgent {
"""
The user agent string
"""
raw: String!
"""
The name of the browser
"""
name: String
"""
The version of the browser
"""
version: String
"""
The operating system name
"""
os: String
"""
The operating system version
"""
osVersion: String
"""
The device model
"""
model: String
"""
The device type
"""
deviceType: DeviceType!
}
"""
A user email address
"""

View File

@ -17,10 +17,6 @@ import { useCallback } from "react";
import { useMutation } from "urql";
import { FragmentType, graphql, useFragment } from "../gql";
import {
parseUserAgent,
sessionNameFromDeviceInformation,
} from "../utils/parseUserAgent";
import EndSessionButton from "./Session/EndSessionButton";
import Session from "./Session/Session";
@ -30,7 +26,13 @@ const FRAGMENT = graphql(/* GraphQL */ `
id
createdAt
finishedAt
userAgent
userAgent {
raw
name
os
model
deviceType
}
lastActiveIp
lastActiveAt
lastAuthentication {
@ -83,9 +85,16 @@ const BrowserSession: React.FC<Props> = ({ session, isCurrent }) => {
const lastActiveAt = data.lastActiveAt
? parseISO(data.lastActiveAt)
: undefined;
const deviceInformation = parseUserAgent(data.userAgent || undefined);
const sessionName =
sessionNameFromDeviceInformation(deviceInformation) || "Browser session";
let sessionName = "Browser session";
if (data.userAgent) {
if (data.userAgent.model && data.userAgent.name) {
sessionName = `${data.userAgent.name} on ${data.userAgent.model}`;
} else if (data.userAgent.name && data.userAgent.os) {
sessionName = `${data.userAgent.name} on ${data.userAgent.os}`;
} else if (data.userAgent.name) {
sessionName = data.userAgent.name;
}
}
return (
<Session
@ -94,7 +103,7 @@ const BrowserSession: React.FC<Props> = ({ session, isCurrent }) => {
createdAt={createdAt}
finishedAt={finishedAt}
isCurrent={isCurrent}
deviceType={deviceInformation?.deviceType}
deviceType={data.userAgent?.deviceType}
lastActiveIp={data.lastActiveIp || undefined}
lastActiveAt={lastActiveAt}
>

View File

@ -28,6 +28,13 @@ export const FRAGMENT = graphql(/* GraphQL */ `
finishedAt
lastActiveIp
lastActiveAt
userAgent {
raw
name
os
model
deviceType
}
ssoLogin {
id
redirectUri
@ -78,10 +85,20 @@ const CompatSession: React.FC<{
await endCompatSession({ id: data.id });
};
const clientName = data.ssoLogin?.redirectUri
let clientName = data.ssoLogin?.redirectUri
? simplifyUrl(data.ssoLogin.redirectUri)
: undefined;
if (data.userAgent) {
if (data.userAgent.model && data.userAgent.name) {
clientName = `${data.userAgent.name} on ${data.userAgent.model}`;
} else if (data.userAgent.name && data.userAgent.os) {
clientName = `${data.userAgent.name} on ${data.userAgent.os}`;
} else if (data.userAgent.name) {
clientName = data.userAgent.name;
}
}
const createdAt = parseISO(data.createdAt);
const finishedAt = data.finishedAt ? parseISO(data.finishedAt) : undefined;
const lastActiveAt = data.lastActiveAt
@ -95,6 +112,7 @@ const CompatSession: React.FC<{
createdAt={createdAt}
finishedAt={finishedAt}
clientName={clientName}
deviceType={data.userAgent?.deviceType}
lastActiveIp={data.lastActiveIp || undefined}
lastActiveAt={lastActiveAt}
>

View File

@ -16,9 +16,8 @@ import { parseISO } from "date-fns";
import { useMutation } from "urql";
import { FragmentType, graphql, useFragment } from "../gql";
import { Oauth2ApplicationType } from "../gql/graphql";
import { DeviceType, Oauth2ApplicationType } from "../gql/graphql";
import { getDeviceIdFromScope } from "../utils/deviceIdFromScope";
import { DeviceType } from "../utils/parseUserAgent";
import { Session } from "./Session";
import EndSessionButton from "./Session/EndSessionButton";
@ -31,6 +30,14 @@ export const FRAGMENT = graphql(/* GraphQL */ `
finishedAt
lastActiveIp
lastActiveAt
userAgent {
model
os
osVersion
deviceType
}
client {
id
clientId
@ -57,7 +64,7 @@ const getDeviceTypeFromClientAppType = (
appType?: Oauth2ApplicationType | null,
): DeviceType => {
if (appType === Oauth2ApplicationType.Web) {
return DeviceType.Web;
return DeviceType.Pc;
}
if (appType === Oauth2ApplicationType.Native) {
return DeviceType.Mobile;
@ -85,9 +92,17 @@ const OAuth2Session: React.FC<Props> = ({ session }) => {
? parseISO(data.lastActiveAt)
: undefined;
const deviceType = getDeviceTypeFromClientAppType(
data.client.applicationType,
);
const deviceType =
(data.userAgent?.deviceType === DeviceType.Unknown
? null
: data.userAgent?.deviceType) ??
getDeviceTypeFromClientAppType(data.client.applicationType);
let clientName = data.client.clientName || data.client.clientId || undefined;
if (data.userAgent?.model) {
clientName = `${clientName} on ${data.userAgent.model}`;
}
return (
<Session
@ -95,7 +110,7 @@ const OAuth2Session: React.FC<Props> = ({ session }) => {
name={deviceId}
createdAt={createdAt}
finishedAt={finishedAt}
clientName={data.client.clientName || data.client.clientId || undefined}
clientName={clientName}
clientLogoUri={data.client.logoUri || undefined}
deviceType={deviceType}
lastActiveIp={data.lastActiveIp || undefined}

View File

@ -14,7 +14,7 @@
import type { Meta, StoryObj } from "@storybook/react";
import { DeviceType } from "../../utils/parseUserAgent";
import { DeviceType } from "../../gql/graphql";
import DeviceTypeIcon from "./DeviceTypeIcon";
@ -30,9 +30,9 @@ const meta = {
control: "select",
options: [
DeviceType.Unknown,
DeviceType.Desktop,
DeviceType.Pc,
DeviceType.Mobile,
DeviceType.Web,
DeviceType.Tablet,
],
},
},
@ -43,9 +43,9 @@ type Story = StoryObj<typeof DeviceTypeIcon>;
export const Unknown: Story = {};
export const Desktop: Story = {
export const Pc: Story = {
args: {
deviceType: DeviceType.Desktop,
deviceType: DeviceType.Pc,
},
};
export const Mobile: Story = {
@ -53,8 +53,8 @@ export const Mobile: Story = {
deviceType: DeviceType.Mobile,
},
};
export const Web: Story = {
export const Tablet: Story = {
args: {
deviceType: DeviceType.Web,
deviceType: DeviceType.Tablet,
},
};

View File

@ -18,7 +18,7 @@ import { composeStory } from "@storybook/react";
import { render, cleanup } from "@testing-library/react";
import { describe, it, expect, afterEach } from "vitest";
import Meta, { Unknown, Desktop, Mobile, Web } from "./DeviceTypeIcon.stories";
import Meta, { Unknown, Pc, Mobile, Tablet } from "./DeviceTypeIcon.stories";
describe("<DeviceTypeIcon />", () => {
afterEach(cleanup);
@ -33,13 +33,13 @@ describe("<DeviceTypeIcon />", () => {
const { container } = render(<Component />);
expect(container).toMatchSnapshot();
});
it("renders desktop device type", () => {
const Component = composeStory(Desktop, Meta);
it("renders pc device type", () => {
const Component = composeStory(Pc, Meta);
const { container } = render(<Component />);
expect(container).toMatchSnapshot();
});
it("renders Web device type", () => {
const Component = composeStory(Web, Meta);
it("renders tablet device type", () => {
const Component = composeStory(Tablet, Meta);
const { container } = render(<Component />);
expect(container).toMatchSnapshot();
});

View File

@ -19,7 +19,7 @@ import IconBrowser from "@vector-im/compound-design-tokens/icons/web-browser.svg
import { FunctionComponent, SVGProps } from "react";
import { useTranslation } from "react-i18next";
import { DeviceType } from "../../utils/parseUserAgent";
import { DeviceType } from "../../gql/graphql";
import styles from "./DeviceTypeIcon.module.css";
@ -28,9 +28,9 @@ const deviceTypeToIcon: Record<
FunctionComponent<SVGProps<SVGSVGElement> & { title?: string | undefined }>
> = {
[DeviceType.Unknown]: IconUnknown,
[DeviceType.Desktop]: IconComputer,
[DeviceType.Pc]: IconComputer,
[DeviceType.Mobile]: IconMobile,
[DeviceType.Web]: IconBrowser,
[DeviceType.Tablet]: IconBrowser,
};
const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({
@ -42,9 +42,9 @@ const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({
const deviceTypeToLabel: Record<DeviceType, string> = {
[DeviceType.Unknown]: t("frontend.device_type_icon_label.unknown"),
[DeviceType.Desktop]: t("frontend.device_type_icon_label.desktop"),
[DeviceType.Pc]: t("frontend.device_type_icon_label.pc"),
[DeviceType.Mobile]: t("frontend.device_type_icon_label.mobile"),
[DeviceType.Web]: t("frontend.device_type_icon_label.web"),
[DeviceType.Tablet]: t("frontend.device_type_icon_label.tablet"),
};
const label = deviceTypeToLabel[deviceType];

View File

@ -16,7 +16,7 @@ import { Link } from "@tanstack/react-router";
import { H6, Text, Badge } from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next";
import { DeviceType } from "../../utils/parseUserAgent";
import { DeviceType } from "../../gql/graphql";
import Block from "../Block";
import DateTime from "../DateTime";

View File

@ -1,41 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`<DeviceTypeIcon /> > renders Web device type 1`] = `
<div>
<svg
aria-label="Web"
class="_icon_e677aa"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 20c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 18V6c0-.55.196-1.02.587-1.412A1.926 1.926 0 0 1 4 4h16c.55 0 1.02.196 1.413.588.391.391.587.862.587 1.412v12c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 20H4Zm0-2h16V8H4v10Z"
/>
</svg>
</div>
`;
exports[`<DeviceTypeIcon /> > renders desktop device type 1`] = `
<div>
<svg
aria-label="Desktop"
class="_icon_e677aa"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 18c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 16V5c0-.55.196-1.02.587-1.413A1.926 1.926 0 0 1 4 3h16c.55 0 1.02.196 1.413.587.39.393.587.863.587 1.413v11c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 18H4Zm0-2h16V5H4v11Zm-2 5a.967.967 0 0 1-.712-.288A.968.968 0 0 1 1 20c0-.283.096-.52.288-.712A.967.967 0 0 1 2 19h20c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.712A.968.968 0 0 1 22 21H2Z"
/>
</svg>
</div>
`;
exports[`<DeviceTypeIcon /> > renders mobile device type 1`] = `
<div>
<svg
@ -54,6 +18,42 @@ exports[`<DeviceTypeIcon /> > renders mobile device type 1`] = `
</div>
`;
exports[`<DeviceTypeIcon /> > renders pc device type 1`] = `
<div>
<svg
aria-label="Computer"
class="_icon_e677aa"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 18c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 16V5c0-.55.196-1.02.587-1.413A1.926 1.926 0 0 1 4 3h16c.55 0 1.02.196 1.413.587.39.393.587.863.587 1.413v11c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 18H4Zm0-2h16V5H4v11Zm-2 5a.967.967 0 0 1-.712-.288A.968.968 0 0 1 1 20c0-.283.096-.52.288-.712A.967.967 0 0 1 2 19h20c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.712A.968.968 0 0 1 22 21H2Z"
/>
</svg>
</div>
`;
exports[`<DeviceTypeIcon /> > renders tablet device type 1`] = `
<div>
<svg
aria-label="Tablet"
class="_icon_e677aa"
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 20c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 18V6c0-.55.196-1.02.587-1.412A1.926 1.926 0 0 1 4 4h16c.55 0 1.02.196 1.413.588.391.391.587.862.587 1.412v12c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 20H4Zm0-2h16V8H4v10Z"
/>
</svg>
</div>
`;
exports[`<DeviceTypeIcon /> > renders unknown device type 1`] = `
<div>
<svg

View File

@ -17,10 +17,6 @@ import { parseISO } from "date-fns";
import { useTranslation } from "react-i18next";
import { FragmentType, graphql, useFragment } from "../../gql";
import {
parseUserAgent,
sessionNameFromDeviceInformation,
} from "../../utils/parseUserAgent";
import BlockList from "../BlockList/BlockList";
import { useEndBrowserSession } from "../BrowserSession";
import DateTime from "../DateTime";
@ -36,7 +32,11 @@ const FRAGMENT = graphql(/* GraphQL */ `
id
createdAt
finishedAt
userAgent
userAgent {
name
model
os
}
lastActiveIp
lastActiveAt
lastAuthentication {
@ -61,9 +61,16 @@ const BrowserSessionDetail: React.FC<Props> = ({ session, isCurrent }) => {
const onSessionEnd = useEndBrowserSession(data.id, isCurrent);
const deviceInformation = parseUserAgent(data.userAgent || undefined);
const sessionName =
sessionNameFromDeviceInformation(deviceInformation) || "Browser session";
let sessionName = "Browser session";
if (data.userAgent) {
if (data.userAgent.model && data.userAgent.name) {
sessionName = `${data.userAgent.name} on ${data.userAgent.model}`;
} else if (data.userAgent.name && data.userAgent.os) {
sessionName = `${data.userAgent.name} on ${data.userAgent.os}`;
} else if (data.userAgent.name) {
sessionName = data.userAgent.name;
}
}
const finishedAt = data.finishedAt
? [

View File

@ -35,6 +35,11 @@ export const FRAGMENT = graphql(/* GraphQL */ `
finishedAt
lastActiveIp
lastActiveAt
userAgent {
name
os
model
}
ssoLogin {
id
redirectUri
@ -102,7 +107,7 @@ const CompatSessionDetail: React.FC<Props> = ({ session }) => {
if (data.ssoLogin?.redirectUri) {
clientDetails.push({
label: t("frontend.compat_session_detail.name"),
value: simplifyUrl(data.ssoLogin.redirectUri),
value: data.userAgent?.name ?? simplifyUrl(data.ssoLogin.redirectUri),
});
clientDetails.push({
label: t("frontend.session.uri_label"),

View File

@ -5,7 +5,7 @@ exports[`<OAuth2Session /> > renders a finished session 1`] = `
className="_block_17898c _session_634806"
>
<svg
aria-label="Web"
aria-label="Computer"
className="_icon_e677aa"
fill="currentColor"
height="1em"
@ -14,7 +14,7 @@ exports[`<OAuth2Session /> > renders a finished session 1`] = `
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 20c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 18V6c0-.55.196-1.02.587-1.412A1.926 1.926 0 0 1 4 4h16c.55 0 1.02.196 1.413.588.391.391.587.862.587 1.412v12c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 20H4Zm0-2h16V8H4v10Z"
d="M4 18c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 16V5c0-.55.196-1.02.587-1.413A1.926 1.926 0 0 1 4 3h16c.55 0 1.02.196 1.413.587.39.393.587.863.587 1.413v11c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 18H4Zm0-2h16V5H4v11Zm-2 5a.967.967 0 0 1-.712-.288A.968.968 0 0 1 1 20c0-.283.096-.52.288-.712A.967.967 0 0 1 2 19h20c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.712A.968.968 0 0 1 22 21H2Z"
/>
</svg>
<div

View File

@ -13,23 +13,23 @@ import { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/
* Therefore it is highly recommended to use the babel or swc plugin for production.
*/
const documents = {
"\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n":
"\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n":
types.BrowserSession_SessionFragmentDoc,
"\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n ...BrowserSession_session\n }\n }\n }\n":
types.EndBrowserSessionDocument,
"\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 ssoLogin {\n id\n redirectUri\n }\n }\n":
"\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n":
types.CompatSession_SessionFragmentDoc,
"\n mutation EndCompatSession($id: ID!) {\n endCompatSession(input: { compatSessionId: $id }) {\n status\n compatSession {\n id\n finishedAt\n }\n }\n }\n":
types.EndCompatSessionDocument,
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n":
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n userAgent {\n model\n os\n osVersion\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n":
types.OAuth2Session_SessionFragmentDoc,
"\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n ...OAuth2Session_session\n }\n }\n }\n":
types.EndOAuth2SessionDocument,
"\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n":
"\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\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 ssoLogin {\n id\n redirectUri\n }\n }\n":
"\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n name\n os\n model\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 client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n":
types.OAuth2Session_DetailFragmentDoc,
@ -83,8 +83,6 @@ const documents = {
types.VerifyEmailQueryDocument,
"\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n":
types.AllowCrossSigningResetDocument,
"\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n":
types.CurrentViewerSessionQueryDocument,
};
/**
@ -105,8 +103,8 @@ export function graphql(source: string): unknown;
* 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 BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n",
): (typeof documents)["\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n"];
source: "\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n",
): (typeof documents)["\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -123,8 +121,8 @@ export function graphql(
* 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 ssoLogin {\n id\n redirectUri\n }\n }\n",
): (typeof documents)["\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ssoLogin {\n id\n redirectUri\n }\n }\n"];
source: "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n",
): (typeof documents)["\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -135,8 +133,8 @@ export function graphql(
* 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 client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n",
): (typeof documents)["\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n"];
source: "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n userAgent {\n model\n os\n osVersion\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n",
): (typeof documents)["\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n userAgent {\n model\n os\n osVersion\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -147,14 +145,14 @@ export function graphql(
* 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 BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n",
): (typeof documents)["\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n"];
source: "\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\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 documents)["\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\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"];
/**
* 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 ssoLogin {\n id\n redirectUri\n }\n }\n",
): (typeof documents)["\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ssoLogin {\n id\n redirectUri\n }\n }\n"];
source: "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n name\n os\n model\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n",
): (typeof documents)["\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n name\n os\n model\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -311,12 +309,6 @@ export function graphql(
export function graphql(
source: "\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n",
): (typeof documents)["\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: "\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n",
): (typeof documents)["\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};

View File

@ -179,8 +179,8 @@ export type BrowserSession = CreationEvent &
state: SessionState;
/** The user logged in this session. */
user: User;
/** The user-agent string with which the session was created. */
userAgent?: Maybe<Scalars["String"]["output"]>;
/** The user-agent with which the session was created. */
userAgent?: Maybe<UserAgent>;
};
/** A browser session represents a logged in user in a browser. */
@ -241,6 +241,8 @@ export type CompatSession = CreationEvent &
state: SessionState;
/** The user authorized for this session. */
user: User;
/** The user-agent with which the session was created. */
userAgent?: Maybe<UserAgent>;
};
export type CompatSessionConnection = {
@ -343,6 +345,18 @@ export type CreationEvent = {
createdAt: Scalars["DateTime"]["output"];
};
/** The type of a user agent */
export enum DeviceType {
/** A mobile phone. Can also sometimes be a tablet. */
Mobile = "MOBILE",
/** A personal computer, laptop or desktop */
Pc = "PC",
/** A tablet */
Tablet = "TABLET",
/** Unknown device type */
Unknown = "UNKNOWN",
}
/** The input of the `endBrowserSession` mutation. */
export type EndBrowserSessionInput = {
/** The ID of the session to end. */
@ -617,6 +631,8 @@ export type Oauth2Session = CreationEvent &
state: SessionState;
/** User authorized for this session. */
user?: Maybe<User>;
/** The user-agent with which the session was created. */
userAgent?: Maybe<UserAgent>;
};
export type Oauth2SessionConnection = {
@ -1058,6 +1074,25 @@ export type UserUpstreamOauth2LinksArgs = {
last?: InputMaybe<Scalars["Int"]["input"]>;
};
/** A parsed user agent string */
export type UserAgent = {
__typename?: "UserAgent";
/** The device type */
deviceType: DeviceType;
/** The device model */
model?: Maybe<Scalars["String"]["output"]>;
/** The name of the browser */
name?: Maybe<Scalars["String"]["output"]>;
/** The operating system name */
os?: Maybe<Scalars["String"]["output"]>;
/** The operating system version */
osVersion?: Maybe<Scalars["String"]["output"]>;
/** The user agent string */
raw: Scalars["String"]["output"];
/** The version of the browser */
version?: Maybe<Scalars["String"]["output"]>;
};
/** A user email address */
export type UserEmail = CreationEvent &
Node & {
@ -1144,9 +1179,16 @@ export type BrowserSession_SessionFragment = {
id: string;
createdAt: string;
finishedAt?: string | null;
userAgent?: string | null;
lastActiveIp?: string | null;
lastActiveAt?: string | null;
userAgent?: {
__typename?: "UserAgent";
raw: string;
name?: string | null;
os?: string | null;
model?: string | null;
deviceType: DeviceType;
} | null;
lastAuthentication?: {
__typename?: "Authentication";
id: string;
@ -1193,6 +1235,14 @@ export type CompatSession_SessionFragment = {
finishedAt?: string | null;
lastActiveIp?: string | null;
lastActiveAt?: string | null;
userAgent?: {
__typename?: "UserAgent";
raw: string;
name?: string | null;
os?: string | null;
model?: string | null;
deviceType: DeviceType;
} | null;
ssoLogin?: {
__typename?: "CompatSsoLogin";
id: string;
@ -1225,6 +1275,13 @@ export type OAuth2Session_SessionFragment = {
finishedAt?: string | null;
lastActiveIp?: string | null;
lastActiveAt?: string | null;
userAgent?: {
__typename?: "UserAgent";
model?: string | null;
os?: string | null;
osVersion?: string | null;
deviceType: DeviceType;
} | null;
client: {
__typename?: "Oauth2Client";
id: string;
@ -1259,9 +1316,14 @@ export type BrowserSession_DetailFragment = {
id: string;
createdAt: string;
finishedAt?: string | null;
userAgent?: string | null;
lastActiveIp?: string | null;
lastActiveAt?: string | null;
userAgent?: {
__typename?: "UserAgent";
name?: string | null;
model?: string | null;
os?: string | null;
} | null;
lastAuthentication?: {
__typename?: "Authentication";
id: string;
@ -1278,6 +1340,12 @@ export type CompatSession_DetailFragment = {
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;
@ -1736,18 +1804,6 @@ export type AllowCrossSigningResetMutation = {
};
};
export type CurrentViewerSessionQueryQueryVariables = Exact<{
[key: string]: never;
}>;
export type CurrentViewerSessionQueryQuery = {
__typename?: "Query";
viewerSession:
| { __typename: "Anonymous" }
| { __typename: "BrowserSession"; id: string }
| { __typename: "Oauth2Session" };
};
export const BrowserSession_SessionFragmentDoc = {
kind: "Document",
definitions: [
@ -1764,7 +1820,20 @@ export const BrowserSession_SessionFragmentDoc = {
{ kind: "Field", name: { kind: "Name", value: "id" } },
{ kind: "Field", name: { kind: "Name", value: "createdAt" } },
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "userAgent" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "raw" } },
{ kind: "Field", name: { kind: "Name", value: "name" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
{ kind: "Field", name: { kind: "Name", value: "model" } },
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
],
},
},
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
@ -1828,6 +1897,20 @@ export const CompatSession_SessionFragmentDoc = {
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "raw" } },
{ kind: "Field", name: { kind: "Name", value: "name" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
{ kind: "Field", name: { kind: "Name", value: "model" } },
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
],
},
},
{
kind: "Field",
name: { kind: "Name", value: "ssoLogin" },
@ -1863,6 +1946,19 @@ export const OAuth2Session_SessionFragmentDoc = {
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "model" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
{ kind: "Field", name: { kind: "Name", value: "osVersion" } },
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
],
},
},
{
kind: "Field",
name: { kind: "Name", value: "client" },
@ -1901,7 +1997,18 @@ export const BrowserSession_DetailFragmentDoc = {
{ kind: "Field", name: { kind: "Name", value: "id" } },
{ kind: "Field", name: { kind: "Name", value: "createdAt" } },
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "userAgent" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "name" } },
{ kind: "Field", name: { kind: "Name", value: "model" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
],
},
},
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
@ -1950,6 +2057,18 @@ export const CompatSession_DetailFragmentDoc = {
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "name" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
{ kind: "Field", name: { kind: "Name", value: "model" } },
],
},
},
{
kind: "Field",
name: { kind: "Name", value: "ssoLogin" },
@ -2337,7 +2456,20 @@ export const EndBrowserSessionDocument = {
{ kind: "Field", name: { kind: "Name", value: "id" } },
{ kind: "Field", name: { kind: "Name", value: "createdAt" } },
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "userAgent" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "raw" } },
{ kind: "Field", name: { kind: "Name", value: "name" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
{ kind: "Field", name: { kind: "Name", value: "model" } },
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
],
},
},
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
@ -2512,6 +2644,19 @@ export const EndOAuth2SessionDocument = {
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "model" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
{ kind: "Field", name: { kind: "Name", value: "osVersion" } },
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
],
},
},
{
kind: "Field",
name: { kind: "Name", value: "client" },
@ -3518,6 +3663,18 @@ export const SessionDetailQueryDocument = {
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "name" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
{ kind: "Field", name: { kind: "Name", value: "model" } },
],
},
},
{
kind: "Field",
name: { kind: "Name", value: "ssoLogin" },
@ -3578,7 +3735,18 @@ export const SessionDetailQueryDocument = {
{ kind: "Field", name: { kind: "Name", value: "id" } },
{ kind: "Field", name: { kind: "Name", value: "createdAt" } },
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "userAgent" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "name" } },
{ kind: "Field", name: { kind: "Name", value: "model" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
],
},
},
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
@ -3833,7 +4001,20 @@ export const BrowserSessionListDocument = {
{ kind: "Field", name: { kind: "Name", value: "id" } },
{ kind: "Field", name: { kind: "Name", value: "createdAt" } },
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "userAgent" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "raw" } },
{ kind: "Field", name: { kind: "Name", value: "name" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
{ kind: "Field", name: { kind: "Name", value: "model" } },
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
],
},
},
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
@ -4151,6 +4332,20 @@ export const AppSessionsListQueryDocument = {
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "raw" } },
{ kind: "Field", name: { kind: "Name", value: "name" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
{ kind: "Field", name: { kind: "Name", value: "model" } },
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
],
},
},
{
kind: "Field",
name: { kind: "Name", value: "ssoLogin" },
@ -4181,6 +4376,19 @@ export const AppSessionsListQueryDocument = {
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
{
kind: "Field",
name: { kind: "Name", value: "userAgent" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "model" } },
{ kind: "Field", name: { kind: "Name", value: "os" } },
{ kind: "Field", name: { kind: "Name", value: "osVersion" } },
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
],
},
},
{
kind: "Field",
name: { kind: "Name", value: "client" },
@ -4639,44 +4847,3 @@ export const AllowCrossSigningResetDocument = {
AllowCrossSigningResetMutation,
AllowCrossSigningResetMutationVariables
>;
export const CurrentViewerSessionQueryDocument = {
kind: "Document",
definitions: [
{
kind: "OperationDefinition",
operation: "query",
name: { kind: "Name", value: "CurrentViewerSessionQuery" },
selectionSet: {
kind: "SelectionSet",
selections: [
{
kind: "Field",
name: { kind: "Name", value: "viewerSession" },
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "__typename" } },
{
kind: "InlineFragment",
typeCondition: {
kind: "NamedType",
name: { kind: "Name", value: "BrowserSession" },
},
selectionSet: {
kind: "SelectionSet",
selections: [
{ kind: "Field", name: { kind: "Name", value: "id" } },
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
CurrentViewerSessionQueryQuery,
CurrentViewerSessionQueryQueryVariables
>;

View File

@ -413,8 +413,9 @@ export default {
{
name: "userAgent",
type: {
kind: "SCALAR",
name: "Any",
kind: "OBJECT",
name: "UserAgent",
ofType: null,
},
args: [],
},
@ -628,6 +629,15 @@ export default {
},
args: [],
},
{
name: "userAgent",
type: {
kind: "OBJECT",
name: "UserAgent",
ofType: null,
},
args: [],
},
],
interfaces: [
{
@ -1741,6 +1751,15 @@ export default {
},
args: [],
},
{
name: "userAgent",
type: {
kind: "OBJECT",
name: "UserAgent",
ofType: null,
},
args: [],
},
],
interfaces: [
{
@ -3133,6 +3152,75 @@ export default {
},
],
},
{
kind: "OBJECT",
name: "UserAgent",
fields: [
{
name: "deviceType",
type: {
kind: "NON_NULL",
ofType: {
kind: "SCALAR",
name: "Any",
},
},
args: [],
},
{
name: "model",
type: {
kind: "SCALAR",
name: "Any",
},
args: [],
},
{
name: "name",
type: {
kind: "SCALAR",
name: "Any",
},
args: [],
},
{
name: "os",
type: {
kind: "SCALAR",
name: "Any",
},
args: [],
},
{
name: "osVersion",
type: {
kind: "SCALAR",
name: "Any",
},
args: [],
},
{
name: "raw",
type: {
kind: "NON_NULL",
ofType: {
kind: "SCALAR",
name: "Any",
},
},
args: [],
},
{
name: "version",
type: {
kind: "SCALAR",
name: "Any",
},
args: [],
},
],
interfaces: [],
},
{
kind: "OBJECT",
name: "UserEmail",

View File

@ -90,10 +90,21 @@
width: var(--cpd-space-10x);
}
& .name {
font: var(--cpd-font-body-md-semibold);
letter-spacing: var(--cpd-font-letter-spacing-body-md);
color: var(--cpd-color-text-primary);
& .lines {
display: flex;
flex-direction: column;
& div:first-child {
font: var(--cpd-font-body-md-semibold);
letter-spacing: var(--cpd-font-letter-spacing-body-md);
color: var(--cpd-color-text-primary);
}
& div {
font: var(--cpd-font-body-sm-regular);
letter-spacing: var(--cpd-font-letter-spacing-body-sm);
color: var(--cpd-color-text-secondary);
}
}
}

View File

@ -1,390 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { describe, it, expect } from "vitest";
import {
DeviceType,
DeviceInformation,
parseUserAgent,
sessionNameFromDeviceInformation,
} from "./parseUserAgent";
const makeDeviceExtendedInfo = (
deviceType: DeviceType,
deviceModel?: string,
deviceModelVersion?: string,
deviceOperatingSystem?: string,
deviceOperatingSystemVersion?: string,
clientName?: string,
clientVersion?: string,
): DeviceInformation => ({
deviceType,
deviceModel,
deviceModelVersion,
deviceOperatingSystem,
deviceOperatingSystemVersion,
client: clientName,
clientVersion,
});
/* eslint-disable max-len */
const ANDROID_UA = [
// New User Agent Implementation
"Element dbg/1.5.0-dev (Xiaomi Mi 9T; Android 11; RKQ1.200826.002 test-keys; Flavour GooglePlay; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Samsung SM-G960F; Android 6.0.1; RKQ1.200826.002; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google Nexus 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google (Nexus) 5; Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
"Element/1.5.0 (Google (Nexus) (5); Android 7.0; RKQ1.200826.002 test test; Flavour FDroid; MatrixAndroidSdk2 1.5.2)",
// Legacy User Agent Implementation
"Element/1.0.0 (Linux; U; Android 6.0.1; SM-A510F Build/MMB29; Flavour GPlay; MatrixAndroidSdk2 1.0)",
"Element/1.0.0 (Linux; Android 7.0; SM-G610M Build/NRD90M; Flavour GPlay; MatrixAndroidSdk2 1.0)",
];
const ANDROID_EXPECTED_RESULT = [
makeDeviceExtendedInfo(
DeviceType.Mobile,
"Xiaomi Mi 9T",
undefined,
"Android",
"11",
),
makeDeviceExtendedInfo(
DeviceType.Mobile,
"Samsung",
"SM-G960F",
"Android",
"6.0.1",
),
makeDeviceExtendedInfo(DeviceType.Mobile, "LG", "Nexus 5", "Android", "7.0"),
makeDeviceExtendedInfo(
DeviceType.Mobile,
"Google (Nexus) 5",
undefined,
"Android",
"7.0",
),
makeDeviceExtendedInfo(
DeviceType.Mobile,
"Google (Nexus) (5)",
undefined,
"Android",
"7.0",
),
makeDeviceExtendedInfo(
DeviceType.Mobile,
"Samsung",
"SM-A510F",
"Android",
"6.0.1",
),
makeDeviceExtendedInfo(
DeviceType.Mobile,
"Samsung",
"SM-G610M",
"Android",
"7.0",
),
];
const IOS_UA = [
"Element/1.8.21 (iPhone; iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPad Pro (11-inch); iOS 15.2; Scale/3.00)",
"Element/1.8.21 (iPad Pro (12.9-inch) (3rd generation); iOS 15.2; Scale/3.00)",
];
const IOS_EXPECTED_RESULT = [
makeDeviceExtendedInfo(DeviceType.Mobile, "Apple", "iPhone", "iOS", "15.2"),
makeDeviceExtendedInfo(
DeviceType.Mobile,
"Apple",
"iPhone XS Max",
"iOS",
"15.2",
),
makeDeviceExtendedInfo(
DeviceType.Mobile,
"iPad Pro (11-inch)",
undefined,
"iOS 15.2",
),
makeDeviceExtendedInfo(
DeviceType.Mobile,
"iPad Pro (12.9-inch) (3rd generation)",
undefined,
"iOS 15.2",
),
];
const DESKTOP_UA = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102" +
" Electron/20.1.1 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) ElementNightly/2022091301 Chrome/104.0.5112.102 Electron/20.1.1 Safari/537.36",
];
const DESKTOP_EXPECTED_RESULT = [
makeDeviceExtendedInfo(
DeviceType.Desktop,
"Apple",
"Macintosh",
"Mac OS",
undefined,
"Electron",
"20.1.1",
),
makeDeviceExtendedInfo(
DeviceType.Desktop,
undefined,
undefined,
"Windows",
undefined,
"Electron",
"20.1.1",
),
];
const WEB_UA = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.5112.102 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:39.0) Gecko/20100101 Firefox/39.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/600.3.18 (KHTML, like Gecko) Version/8.0.3 Safari/600.3.18",
"Mozilla/5.0 (Windows NT 6.0; rv:40.0) Gecko/20100101 Firefox/40.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.246",
// using mobile browser
"Mozilla/5.0 (iPad; CPU OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4",
"Mozilla/5.0 (iPhone; CPU iPhone OS 8_4_1 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) Version/8.0 Mobile/12H321 Safari/600.1.4",
"Mozilla/5.0 (Linux; Android 9; SM-G973U Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Mobile Safari/537.36",
];
const WEB_EXPECTED_RESULT = [
makeDeviceExtendedInfo(
DeviceType.Web,
"Apple",
"Macintosh",
"Mac OS",
undefined,
"Chrome",
"104.0.5112.102",
),
makeDeviceExtendedInfo(
DeviceType.Web,
undefined,
undefined,
"Windows",
undefined,
"Chrome",
"104.0.5112.102",
),
makeDeviceExtendedInfo(
DeviceType.Web,
"Apple",
"Macintosh",
"Mac OS",
undefined,
"Firefox",
"39.0",
),
makeDeviceExtendedInfo(
DeviceType.Web,
"Apple",
"Macintosh",
"Mac OS",
undefined,
"Safari",
"8.0.3",
),
makeDeviceExtendedInfo(
DeviceType.Web,
undefined,
undefined,
"Windows",
undefined,
"Firefox",
"40.0",
),
makeDeviceExtendedInfo(
DeviceType.Web,
undefined,
undefined,
"Windows",
undefined,
"Edge",
"12.246",
),
// using mobile browser
makeDeviceExtendedInfo(
DeviceType.Web,
"Apple",
"iPad",
"iOS",
undefined,
"Mobile Safari",
"8.0",
),
makeDeviceExtendedInfo(
DeviceType.Mobile,
"Apple",
"iPhone",
"iOS",
"8.4.1",
"Mobile Safari",
"8.0",
),
makeDeviceExtendedInfo(
DeviceType.Mobile,
"Samsung",
"SM-G973U",
"Android",
"9",
"Chrome",
"69.0.3497.100",
),
];
const MISC_UA = [
"AppleTV11,1/11.1",
"Curl Client/1.0",
"banana",
"",
// fluffy chat ios
"Dart/2.18 (dart:io)",
];
const MISC_EXPECTED_RESULT = [
makeDeviceExtendedInfo(
DeviceType.Unknown,
"Apple",
"Apple TV",
undefined,
undefined,
undefined,
),
makeDeviceExtendedInfo(
DeviceType.Unknown,
undefined,
undefined,
undefined,
undefined,
),
makeDeviceExtendedInfo(
DeviceType.Unknown,
undefined,
undefined,
undefined,
undefined,
),
makeDeviceExtendedInfo(
DeviceType.Unknown,
undefined,
undefined,
undefined,
undefined,
),
makeDeviceExtendedInfo(
DeviceType.Unknown,
undefined,
undefined,
undefined,
undefined,
),
];
/* eslint-disable max-len */
describe("parseUserAgent()", () => {
it("returns deviceType unknown when user agent is falsy", () => {
expect(parseUserAgent(undefined)).toEqual({
deviceType: DeviceType.Unknown,
});
});
type TestCase = [string, DeviceInformation];
const testPlatform = (
platform: string,
userAgents: string[],
results: DeviceInformation[],
): void => {
const testCases: TestCase[] = userAgents.map((userAgent, index) => [
userAgent,
results[index],
]);
describe(`on platform ${platform}`, () => {
it.each(testCases)(
"should parse the user agent correctly - %s",
(userAgent, expectedResult) => {
expect(parseUserAgent(userAgent)).toEqual(expectedResult);
},
);
});
};
testPlatform("Android", ANDROID_UA, ANDROID_EXPECTED_RESULT);
testPlatform("iOS", IOS_UA, IOS_EXPECTED_RESULT);
testPlatform("Desktop", DESKTOP_UA, DESKTOP_EXPECTED_RESULT);
testPlatform("Web", WEB_UA, WEB_EXPECTED_RESULT);
testPlatform("Misc", MISC_UA, MISC_EXPECTED_RESULT);
});
describe("sessionNameFromDeviceInformation", () => {
const deviceInfo = {
client: "Chrome",
clientVersion: "123",
deviceModel: "Apple Macintosh",
deviceOperatingSystem: "Mac OS",
deviceType: DeviceType.Web,
};
it("should concatenate device info", () => {
expect(sessionNameFromDeviceInformation(deviceInfo)).toEqual(
"Chrome on Mac OS",
);
});
it("should use device model when deviceOS is falsy", () => {
expect(
sessionNameFromDeviceInformation({
...deviceInfo,
deviceOperatingSystem: undefined,
}),
).toEqual("Chrome on Apple Macintosh");
});
it("should exclude device model and OS when both are falsy", () => {
expect(
sessionNameFromDeviceInformation({
...deviceInfo,
deviceOperatingSystem: undefined,
deviceModel: undefined,
}),
).toEqual("Chrome");
});
it("should exclude client when falsy", () => {
expect(
sessionNameFromDeviceInformation({
...deviceInfo,
client: undefined,
}),
).toEqual("Mac OS");
});
it("should return an empty string when no info", () => {
expect(
sessionNameFromDeviceInformation({
deviceType: DeviceType.Unknown,
}),
).toEqual("");
});
});

View File

@ -1,145 +0,0 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import UAParser from "ua-parser-js";
export enum DeviceType {
Desktop = "Desktop",
Mobile = "Mobile",
Web = "Web",
Unknown = "Unknown",
}
export type DeviceInformation = {
deviceType: DeviceType;
// eg Google Pixel 6
deviceModel?: string;
deviceModelVersion?: string;
// eg Android 11
deviceOperatingSystem?: string;
deviceOperatingSystemVersion?: string;
// eg Firefox 1.1.0
client?: string;
clientVersion?: string;
};
// Element/1.8.21 (iPhone XS Max; iOS 15.2; Scale/3.00)
const IOS_KEYWORD = "; iOS ";
const BROWSER_KEYWORD = "Mozilla/";
const getDeviceType = (
userAgent: string,
device: UAParser.IDevice,
browser: UAParser.IBrowser,
operatingSystem: UAParser.IOS,
): DeviceType => {
if (browser.name === "Electron") {
return DeviceType.Desktop;
}
if (
device.type === "mobile" ||
operatingSystem.name?.includes("Android") ||
userAgent.indexOf(IOS_KEYWORD) > -1
) {
return DeviceType.Mobile;
}
if (browser.name) {
return DeviceType.Web;
}
return DeviceType.Unknown;
};
interface CustomValues {
customDeviceModel?: string;
customDeviceOS?: string;
}
/**
* Some mobile model and OS strings are not recognised
* by the UA parsing library
* check they exist by hand
*/
const checkForCustomValues = (userAgent: string): CustomValues => {
if (userAgent.includes(BROWSER_KEYWORD)) {
return {};
}
const mightHaveDevice = userAgent.includes("(");
if (!mightHaveDevice) {
return {};
}
const deviceInfoSegments = userAgent
.substring(userAgent.indexOf("(") + 1)
.split("; ");
const customDeviceModel = deviceInfoSegments[0] || undefined;
const customDeviceOS = deviceInfoSegments[1] || undefined;
return { customDeviceModel, customDeviceOS };
};
export const parseUserAgent = (userAgent?: string): DeviceInformation => {
if (!userAgent) {
return {
deviceType: DeviceType.Unknown,
};
}
const parser = new UAParser(userAgent);
const browser = parser.getBrowser();
const device = parser.getDevice();
const operatingSystem = parser.getOS();
const deviceType = getDeviceType(userAgent, device, browser, operatingSystem);
// OSX versions are frozen at 10.15.17 in UA strings https://chromestatus.com/feature/5452592194781184
// ignore OS version in browser based sessions
const shouldIgnoreOSVersion =
deviceType === DeviceType.Web || deviceType === DeviceType.Desktop;
const deviceOperatingSystem = operatingSystem.name;
const deviceOperatingSystemVersion = shouldIgnoreOSVersion
? undefined
: operatingSystem.version;
const deviceModel = device.vendor;
const deviceModelVersion = device.model;
const client = browser.name;
const clientVersion = browser.version;
// only try to parse custom model and OS when device type is known
const { customDeviceModel, customDeviceOS } =
deviceType !== DeviceType.Unknown
? checkForCustomValues(userAgent)
: ({} as CustomValues);
return {
deviceType,
deviceModel: deviceModel || customDeviceModel,
deviceModelVersion,
deviceOperatingSystem: deviceOperatingSystem || customDeviceOS,
deviceOperatingSystemVersion,
client,
clientVersion,
};
};
export const sessionNameFromDeviceInformation = ({
deviceModel,
deviceOperatingSystem,
client,
}: DeviceInformation): string | undefined => {
const description = [client, deviceOperatingSystem || deviceModel]
.filter(Boolean)
.join(" on ");
return description;
};

View File

@ -1,42 +0,0 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import { useQuery } from "urql";
import { graphql } from "../../gql";
const CURRENT_VIEWER_SESSION_QUERY = graphql(/* GraphQL */ `
query CurrentViewerSessionQuery {
viewerSession {
__typename
... on BrowserSession {
id
}
}
}
`);
/**
* Query the current browser session id
* and unwrap the result
* throws error when error result
*/
export const useCurrentBrowserSessionId = (): string | null => {
const [result] = useQuery({ query: CURRENT_VIEWER_SESSION_QUERY });
if (result.error) throw result.error;
if (!result.data) throw new Error(); // Suspense mode is enabled
return result.data.viewerSession.__typename === "BrowserSession"
? result.data.viewerSession.id
: null;
};

View File

@ -33,10 +33,46 @@ limitations under the License.
<h1 class="title">Allow access to your account?</h1>
<div class="consent-device-card">
<div class="device" {%- if grant.user_agent %} title="{{ grant.user_agent }}"{% endif %}>
{{ icon.web_browser() }}
{# TODO: Infer from the user agent #}
<div class="name">Device</div>
<div class="device" {%- if grant.user_agent %} title="{{ grant.user_agent.raw }}"{% endif %}>
{% if grant.user_agent.device_type == "mobile" %}
{{ icon.mobile() }}
{% elif grant.user_agent.device_type == "tablet" %}
{{ icon.web_browser() }}
{% elif grant.user_agent.device_type == "pc" %}
{{ icon.computer() }}
{% else %}
{{ icon.unknown_solid() }}
{% endif %}
<div class="lines">
{% if grant.user_agent.model %}
<div>{{ grant.user_agent.model }}</div>
{% endif %}
{% if grant.user_agent.os %}
<div>
{{ grant.user_agent.os }}
{% if grant.user_agent.os_version %}
{{ grant.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 %}
<div>
{{ grant.user_agent.name }}
{% if grant.user_agent.version %}
{{ grant.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 %}
<div>Device</div>
{% endif %}
</div>
</div>
<div class="meta">
{% if grant.ip_address %}

View File

@ -2,11 +2,11 @@
"action": {
"cancel": "Cancel",
"@cancel": {
"context": "pages/consent.html:72:11-29, pages/device_consent.html:94:13-31, pages/login.html:100:13-31, pages/policy_violation.html:52:13-31, pages/register.html:77:13-31"
"context": "pages/consent.html:72:11-29, pages/device_consent.html:130:13-31, pages/login.html:100:13-31, pages/policy_violation.html:52:13-31, pages/register.html:77:13-31"
},
"continue": "Continue",
"@continue": {
"context": "pages/account/emails/add.html:45:26-46, pages/account/emails/verify.html:60:26-46, pages/consent.html:60:28-48, pages/device_consent.html:91:13-33, pages/device_link.html:50:26-46, pages/login.html:62:30-50, pages/reauth.html:40:28-48, pages/register.html:72:28-48, pages/sso.html:45:28-48"
"context": "pages/account/emails/add.html:45:26-46, pages/account/emails/verify.html:60:26-46, pages/consent.html:60:28-48, pages/device_consent.html:127:13-33, pages/device_link.html:50:26-46, pages/login.html:62:30-50, pages/reauth.html:40:28-48, pages/register.html:72:28-48, pages/sso.html:45:28-48"
},
"create_account": "Create Account",
"@create_account": {
@ -18,7 +18,7 @@
},
"sign_out": "Sign out",
"@sign_out": {
"context": "pages/consent.html:68:28-48, pages/device_consent.html:103:30-50, pages/index.html:36:28-48, pages/policy_violation.html:46:28-48, pages/sso.html:53:28-48, pages/upstream_oauth2/link_mismatch.html:32:24-44, pages/upstream_oauth2/suggest_link.html:40:26-46"
"context": "pages/consent.html:68:28-48, pages/device_consent.html:139:30-50, pages/index.html:36:28-48, pages/policy_violation.html:46:28-48, pages/sso.html:53:28-48, pages/upstream_oauth2/link_mismatch.html:32:24-44, pages/upstream_oauth2/suggest_link.html:40:26-46"
}
},
"app": {
@ -246,7 +246,7 @@
},
"not_you": "Not %(username)s?",
"@not_you": {
"context": "pages/consent.html:65:11-67, pages/device_consent.html:100:13-69, pages/sso.html:50:11-67",
"context": "pages/consent.html:65:11-67, pages/device_consent.html:136:13-69, pages/sso.html:50:11-67",
"description": "Suggestions for the user to log in as a different user"
},
"or_separator": "Or",