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", "oauth2-types",
"rand 0.8.5", "rand 0.8.5",
"rand_chacha 0.3.1", "rand_chacha 0.3.1",
"regex",
"serde", "serde",
"thiserror", "thiserror",
"ulid", "ulid",
"url", "url",
"woothee",
] ]
[[package]] [[package]]
@ -7198,6 +7200,16 @@ dependencies = [
"unicode-xid", "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]] [[package]]
name = "writeable" name = "writeable"
version = "0.5.4" version = "0.5.4"

View File

@ -20,6 +20,8 @@ crc = "3.0.1"
ulid.workspace = true ulid.workspace = true
rand.workspace = true rand.workspace = true
rand_chacha = "0.3.1" rand_chacha = "0.3.1"
regex = "1.10.3"
woothee = "0.13.0"
mas-iana.workspace = true mas-iana.workspace = true
mas-jose.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"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with 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 // See the License for the specific language governing permissions and
// limitations under the License. // 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 ulid::Ulid;
use super::Device; use super::Device;
use crate::InvalidTransitionError; use crate::{InvalidTransitionError, UserAgent};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub enum CompatSessionState { pub enum CompatSessionState {
@ -83,7 +83,7 @@ pub struct CompatSession {
pub user_session_id: Option<Ulid>, pub user_session_id: Option<Ulid>,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub is_synapse_admin: bool, 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_at: Option<DateTime<Utc>>,
pub last_active_ip: Option<IpAddr>, pub last_active_ip: Option<IpAddr>,
} }

View File

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

View File

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

View File

@ -19,7 +19,7 @@ use oauth2_types::scope::Scope;
use serde::Serialize; use serde::Serialize;
use ulid::Ulid; use ulid::Ulid;
use crate::InvalidTransitionError; use crate::{InvalidTransitionError, UserAgent};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
pub enum SessionState { pub enum SessionState {
@ -75,7 +75,7 @@ pub struct Session {
pub user_session_id: Option<Ulid>, pub user_session_id: Option<Ulid>,
pub client_id: Ulid, pub client_id: Ulid,
pub scope: Scope, pub scope: Scope,
pub user_agent: Option<String>, pub user_agent: Option<UserAgent>,
pub last_active_at: Option<DateTime<Utc>>, pub last_active_at: Option<DateTime<Utc>>,
pub last_active_ip: Option<IpAddr>, 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 serde::Serialize;
use ulid::Ulid; use ulid::Ulid;
use crate::UserAgent;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct User { pub struct User {
pub id: Ulid, pub id: Ulid,
@ -83,7 +85,7 @@ pub struct BrowserSession {
pub user: User, pub user: User,
pub created_at: DateTime<Utc>, pub created_at: DateTime<Utc>,
pub finished_at: Option<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_at: Option<DateTime<Utc>>,
pub last_active_ip: Option<IpAddr>, pub last_active_ip: Option<IpAddr>,
} }
@ -105,7 +107,9 @@ impl BrowserSession {
user, user,
created_at: now, created_at: now,
finished_at: None, 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_at: Some(now),
last_active_ip: None, last_active_ip: None,
}) })

View File

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

View File

@ -24,7 +24,7 @@ use mas_storage::{
use super::{ use super::{
AppSession, CompatSession, Cursor, NodeCursor, NodeType, OAuth2Session, PreloadedTotalCount, AppSession, CompatSession, Cursor, NodeCursor, NodeType, OAuth2Session, PreloadedTotalCount,
SessionState, User, SessionState, User, UserAgent,
}; };
use crate::state::ContextExt; use crate::state::ContextExt;
@ -87,9 +87,9 @@ impl BrowserSession {
} }
} }
/// The user-agent string with which the session was created. /// The user-agent with which the session was created.
pub async fn user_agent(&self) -> Option<&str> { pub async fn user_agent(&self) -> Option<UserAgent> {
self.0.user_agent.as_deref() self.0.user_agent.clone().map(UserAgent::from)
} }
/// The last IP address used by the session. /// 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 mas_storage::{compat::CompatSessionRepository, user::UserRepository};
use url::Url; use url::Url;
use super::{BrowserSession, NodeType, SessionState, User}; use super::{BrowserSession, NodeType, SessionState, User, UserAgent};
use crate::state::ContextExt; use crate::state::ContextExt;
/// Lazy-loaded reverse reference. /// Lazy-loaded reverse reference.
@ -103,6 +103,11 @@ impl CompatSession {
self.session.finished_at() 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. /// The associated SSO login, if any.
pub async fn sso_login( pub async fn sso_login(
&self, &self,

View File

@ -12,7 +12,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use async_graphql::{Enum, Interface, Object}; use async_graphql::{Enum, Interface, Object, SimpleObject};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
mod browser_sessions; mod browser_sessions;
@ -73,3 +73,69 @@ pub enum SessionState {
/// The session is no longer active. /// The session is no longer active.
Finished, 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 ulid::Ulid;
use url::Url; use url::Url;
use super::{BrowserSession, NodeType, SessionState, User}; use super::{BrowserSession, NodeType, SessionState, User, UserAgent};
use crate::{state::ContextExt, UserId}; use crate::{state::ContextExt, UserId};
/// An OAuth 2.0 session represents a client session which used the OAuth APIs /// 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. /// The state of the session.
pub async fn state(&self) -> SessionState { pub async fn state(&self) -> SessionState {
match &self.0.state { match &self.0.state {

View File

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

View File

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

View File

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

View File

@ -237,7 +237,7 @@ async fn get_requester(
return Err(RouteError::MissingScope); return Err(RouteError::MissingScope);
} }
Requester::OAuth2Session(session, user) Requester::OAuth2Session(Box::new((session, user)))
} else { } else {
let maybe_session = session_info.load_session(&mut repo).await?; 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 axum::{extract::State, response::IntoResponse, Json, TypedHeader};
use chrono::Duration; use chrono::Duration;
use headers::{CacheControl, Pragma, UserAgent}; use headers::{CacheControl, Pragma};
use hyper::StatusCode; use hyper::StatusCode;
use mas_axum_utils::{ use mas_axum_utils::{
client_authorization::{ClientAuthorization, CredentialsVerificationError}, client_authorization::{ClientAuthorization, CredentialsVerificationError},
http_client_factory::HttpClientFactory, http_client_factory::HttpClientFactory,
sentry::SentryEventID, sentry::SentryEventID,
}; };
use mas_data_model::UserAgent;
use mas_keystore::Encrypter; use mas_keystore::Encrypter;
use mas_router::UrlBuilder; use mas_router::UrlBuilder;
use mas_storage::{oauth2::OAuth2DeviceCodeGrantParams, BoxClock, BoxRepository, BoxRng}; use mas_storage::{oauth2::OAuth2DeviceCodeGrantParams, BoxClock, BoxRepository, BoxRng};
@ -84,7 +85,7 @@ pub(crate) async fn post(
mut rng: BoxRng, mut rng: BoxRng,
clock: BoxClock, clock: BoxClock,
mut repo: BoxRepository, mut repo: BoxRepository,
user_agent: Option<TypedHeader<UserAgent>>, user_agent: Option<TypedHeader<headers::UserAgent>>,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
State(url_builder): State<UrlBuilder>, State(url_builder): State<UrlBuilder>,
State(http_client_factory): State<HttpClientFactory>, State(http_client_factory): State<HttpClientFactory>,
@ -125,7 +126,7 @@ pub(crate) async fn post(
let expires_in = Duration::minutes(20); 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 ip_address = activity_tracker.ip();
let device_code = Alphanumeric.sample_string(&mut rng, 32); let device_code = Alphanumeric.sample_string(&mut rng, 32);

View File

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

View File

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

View File

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

View File

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

View File

@ -15,7 +15,7 @@
//! A module containing PostgreSQL implementation of repositories for sessions //! A module containing PostgreSQL implementation of repositories for sessions
use async_trait::async_trait; 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::{ use mas_storage::{
app_session::{AppSession, AppSessionFilter, AppSessionRepository}, app_session::{AppSession, AppSessionFilter, AppSessionRepository},
Page, Pagination, Page, Pagination,
@ -84,6 +84,7 @@ use priv_::{AppSessionLookup, AppSessionLookupIden};
impl TryFrom<AppSessionLookup> for AppSession { impl TryFrom<AppSessionLookup> for AppSession {
type Error = DatabaseError; type Error = DatabaseError;
#[allow(clippy::too_many_lines)]
fn try_from(value: AppSessionLookup) -> Result<Self, Self::Error> { 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 // 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 // whether it's a compat session or an oauth2 session
@ -104,6 +105,7 @@ impl TryFrom<AppSessionLookup> for AppSession {
last_active_ip, last_active_ip,
} = value; } = value;
let user_agent = user_agent.map(UserAgent::parse);
let user_session_id = user_session_id.map(Ulid::from); let user_session_id = user_session_id.map(Ulid::from);
match ( match (

View File

@ -28,7 +28,7 @@ pub use self::{
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use chrono::Duration; use chrono::Duration;
use mas_data_model::Device; use mas_data_model::{Device, UserAgent};
use mas_storage::{ use mas_storage::{
clock::MockClock, clock::MockClock,
compat::{ compat::{
@ -133,7 +133,7 @@ mod tests {
assert!(session_lookup.user_agent.is_none()); assert!(session_lookup.user_agent.is_none());
let session = repo let session = repo
.compat_session() .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 .await
.unwrap(); .unwrap();
assert_eq!(session.user_agent.as_deref(), Some("Mozilla/5.0")); 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 chrono::{DateTime, Utc};
use mas_data_model::{ use mas_data_model::{
BrowserSession, CompatSession, CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device, BrowserSession, CompatSession, CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device,
User, User, UserAgent,
}; };
use mas_storage::{ use mas_storage::{
compat::{CompatSessionFilter, CompatSessionRepository}, compat::{CompatSessionFilter, CompatSessionRepository},
@ -90,7 +90,7 @@ impl TryFrom<CompatSessionLookup> for CompatSession {
device, device,
created_at: value.created_at, created_at: value.created_at,
is_synapse_admin: value.is_synapse_admin, 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_at: value.last_active_at,
last_active_ip: value.last_active_ip, 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), user_session_id: value.user_session_id.map(Ulid::from),
created_at: value.created_at, created_at: value.created_at,
is_synapse_admin: value.is_synapse_admin, 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_at: value.last_active_at,
last_active_ip: value.last_active_ip, last_active_ip: value.last_active_ip,
}; };
@ -575,7 +575,7 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> {
async fn record_user_agent( async fn record_user_agent(
&mut self, &mut self,
mut compat_session: CompatSession, mut compat_session: CompatSession,
user_agent: String, user_agent: UserAgent,
) -> Result<CompatSession, Self::Error> { ) -> Result<CompatSession, Self::Error> {
let res = sqlx::query!( let res = sqlx::query!(
r#" r#"
@ -584,7 +584,7 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> {
WHERE compat_session_id = $1 WHERE compat_session_id = $1
"#, "#,
Uuid::from(compat_session.id), Uuid::from(compat_session.id),
user_agent, &*user_agent,
) )
.traced() .traced()
.execute(&mut *self.conn) .execute(&mut *self.conn)

View File

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

View File

@ -32,7 +32,7 @@ pub use self::{
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use chrono::Duration; use chrono::Duration;
use mas_data_model::AuthorizationCode; use mas_data_model::{AuthorizationCode, UserAgent};
use mas_storage::{ use mas_storage::{
clock::MockClock, clock::MockClock,
oauth2::{OAuth2DeviceCodeGrantParams, OAuth2SessionFilter, OAuth2SessionRepository}, oauth2::{OAuth2DeviceCodeGrantParams, OAuth2SessionFilter, OAuth2SessionRepository},
@ -371,7 +371,7 @@ mod tests {
assert!(session.user_agent.is_none()); assert!(session.user_agent.is_none());
let session = repo let session = repo
.oauth2_session() .oauth2_session()
.record_user_agent(session, "Mozilla/5.0".to_owned()) .record_user_agent(session, UserAgent::parse("Mozilla/5.0".to_owned()))
.await .await
.unwrap(); .unwrap();
assert_eq!(session.user_agent.as_deref(), Some("Mozilla/5.0")); 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 async_trait::async_trait;
use chrono::{DateTime, Utc}; 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::{ use mas_storage::{
oauth2::{OAuth2SessionFilter, OAuth2SessionRepository}, oauth2::{OAuth2SessionFilter, OAuth2SessionRepository},
Clock, Page, Pagination, Clock, Page, Pagination,
@ -94,7 +94,7 @@ impl TryFrom<OAuthSessionLookup> for Session {
user_id: value.user_id.map(Ulid::from), user_id: value.user_id.map(Ulid::from),
user_session_id: value.user_session_id.map(Ulid::from), user_session_id: value.user_session_id.map(Ulid::from),
scope, scope,
user_agent: value.user_agent, user_agent: value.user_agent.map(UserAgent::parse),
last_active_at: value.last_active_at, last_active_at: value.last_active_at,
last_active_ip: value.last_active_ip, last_active_ip: value.last_active_ip,
}) })
@ -444,14 +444,14 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> {
%session.id, %session.id,
%session.scope, %session.scope,
client.id = %session.client_id, client.id = %session.client_id,
session.user_agent = %user_agent, session.user_agent = %user_agent.raw,
), ),
err, err,
)] )]
async fn record_user_agent( async fn record_user_agent(
&mut self, &mut self,
mut session: Session, mut session: Session,
user_agent: String, user_agent: UserAgent,
) -> Result<Session, Self::Error> { ) -> Result<Session, Self::Error> {
let res = sqlx::query!( let res = sqlx::query!(
r#" r#"
@ -460,7 +460,7 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> {
WHERE oauth2_session_id = $1 WHERE oauth2_session_id = $1
"#, "#,
Uuid::from(session.id), Uuid::from(session.id),
user_agent, &*user_agent,
) )
.traced() .traced()
.execute(&mut *self.conn) .execute(&mut *self.conn)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -55,10 +55,10 @@
"session_details_title": "Session" "session_details_title": "Session"
}, },
"device_type_icon_label": { "device_type_icon_label": {
"desktop": "Desktop",
"mobile": "Mobile", "mobile": "Mobile",
"unknown": "Unknown device type", "pc": "Computer",
"web": "Web" "tablet": "Tablet",
"unknown": "Unknown device type"
}, },
"end_session_button": { "end_session_button": {
"confirmation_modal_title": "Are you sure you want to end this session?", "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! 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. The last IP address used by the session.
""" """
@ -314,6 +314,10 @@ type CompatSession implements Node & CreationEvent {
""" """
finishedAt: DateTime finishedAt: DateTime
""" """
The user-agent with which the session was created.
"""
userAgent: UserAgent
"""
The associated SSO login, if any. The associated SSO login, if any.
""" """
ssoLogin: CompatSsoLogin ssoLogin: CompatSsoLogin
@ -500,6 +504,28 @@ The input/output is a string in RFC3339 format.
""" """
scalar DateTime 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. The input of the `endBrowserSession` mutation.
""" """
@ -822,6 +848,10 @@ type Oauth2Session implements Node & CreationEvent {
""" """
finishedAt: DateTime finishedAt: DateTime
""" """
The user-agent with which the session was created.
"""
userAgent: UserAgent
"""
The state of the session. The state of the session.
""" """
state: SessionState! state: SessionState!
@ -1529,6 +1559,40 @@ type User implements Node {
): AppSessionConnection! ): 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 A user email address
""" """

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { DeviceType } from "../../utils/parseUserAgent"; import { DeviceType } from "../../gql/graphql";
import DeviceTypeIcon from "./DeviceTypeIcon"; import DeviceTypeIcon from "./DeviceTypeIcon";
@ -30,9 +30,9 @@ const meta = {
control: "select", control: "select",
options: [ options: [
DeviceType.Unknown, DeviceType.Unknown,
DeviceType.Desktop, DeviceType.Pc,
DeviceType.Mobile, DeviceType.Mobile,
DeviceType.Web, DeviceType.Tablet,
], ],
}, },
}, },
@ -43,9 +43,9 @@ type Story = StoryObj<typeof DeviceTypeIcon>;
export const Unknown: Story = {}; export const Unknown: Story = {};
export const Desktop: Story = { export const Pc: Story = {
args: { args: {
deviceType: DeviceType.Desktop, deviceType: DeviceType.Pc,
}, },
}; };
export const Mobile: Story = { export const Mobile: Story = {
@ -53,8 +53,8 @@ export const Mobile: Story = {
deviceType: DeviceType.Mobile, deviceType: DeviceType.Mobile,
}, },
}; };
export const Web: Story = { export const Tablet: Story = {
args: { 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 { render, cleanup } from "@testing-library/react";
import { describe, it, expect, afterEach } from "vitest"; 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 />", () => { describe("<DeviceTypeIcon />", () => {
afterEach(cleanup); afterEach(cleanup);
@ -33,13 +33,13 @@ describe("<DeviceTypeIcon />", () => {
const { container } = render(<Component />); const { container } = render(<Component />);
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it("renders desktop device type", () => { it("renders pc device type", () => {
const Component = composeStory(Desktop, Meta); const Component = composeStory(Pc, Meta);
const { container } = render(<Component />); const { container } = render(<Component />);
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();
}); });
it("renders Web device type", () => { it("renders tablet device type", () => {
const Component = composeStory(Web, Meta); const Component = composeStory(Tablet, Meta);
const { container } = render(<Component />); const { container } = render(<Component />);
expect(container).toMatchSnapshot(); 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 { FunctionComponent, SVGProps } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { DeviceType } from "../../utils/parseUserAgent"; import { DeviceType } from "../../gql/graphql";
import styles from "./DeviceTypeIcon.module.css"; import styles from "./DeviceTypeIcon.module.css";
@ -28,9 +28,9 @@ const deviceTypeToIcon: Record<
FunctionComponent<SVGProps<SVGSVGElement> & { title?: string | undefined }> FunctionComponent<SVGProps<SVGSVGElement> & { title?: string | undefined }>
> = { > = {
[DeviceType.Unknown]: IconUnknown, [DeviceType.Unknown]: IconUnknown,
[DeviceType.Desktop]: IconComputer, [DeviceType.Pc]: IconComputer,
[DeviceType.Mobile]: IconMobile, [DeviceType.Mobile]: IconMobile,
[DeviceType.Web]: IconBrowser, [DeviceType.Tablet]: IconBrowser,
}; };
const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({ const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({
@ -42,9 +42,9 @@ const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({
const deviceTypeToLabel: Record<DeviceType, string> = { const deviceTypeToLabel: Record<DeviceType, string> = {
[DeviceType.Unknown]: t("frontend.device_type_icon_label.unknown"), [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.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]; 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 { H6, Text, Badge } from "@vector-im/compound-web";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { DeviceType } from "../../utils/parseUserAgent"; import { DeviceType } from "../../gql/graphql";
import Block from "../Block"; import Block from "../Block";
import DateTime from "../DateTime"; import DateTime from "../DateTime";

View File

@ -1,41 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html // 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`] = ` exports[`<DeviceTypeIcon /> > renders mobile device type 1`] = `
<div> <div>
<svg <svg
@ -54,6 +18,42 @@ exports[`<DeviceTypeIcon /> > renders mobile device type 1`] = `
</div> </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`] = ` exports[`<DeviceTypeIcon /> > renders unknown device type 1`] = `
<div> <div>
<svg <svg

View File

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

View File

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

View File

@ -5,7 +5,7 @@ exports[`<OAuth2Session /> > renders a finished session 1`] = `
className="_block_17898c _session_634806" className="_block_17898c _session_634806"
> >
<svg <svg
aria-label="Web" aria-label="Computer"
className="_icon_e677aa" className="_icon_e677aa"
fill="currentColor" fill="currentColor"
height="1em" height="1em"
@ -14,7 +14,7 @@ exports[`<OAuth2Session /> > renders a finished session 1`] = `
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<path <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> </svg>
<div <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. * Therefore it is highly recommended to use the babel or swc plugin for production.
*/ */
const documents = { 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, 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": "\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, 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": "\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, 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, 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": "\n mutation EndCompatSession($id: ID!) {\n endCompatSession(input: { compatSessionId: $id }) {\n status\n compatSession {\n id\n finishedAt\n }\n }\n }\n":
types.EndCompatSessionDocument, 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, 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": "\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, 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, 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, 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": "\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, types.OAuth2Session_DetailFragmentDoc,
@ -83,8 +83,6 @@ const documents = {
types.VerifyEmailQueryDocument, types.VerifyEmailQueryDocument,
"\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n": "\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n":
types.AllowCrossSigningResetDocument, 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. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql( 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", 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 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. * 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. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql( 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", 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 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. * 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. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql( 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", 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 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. * 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. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql( 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", 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 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. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql( 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", 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 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. * 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( export function graphql(
source: "\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n", 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"]; ): (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) { export function graphql(source: string) {
return (documents as any)[source] ?? {}; return (documents as any)[source] ?? {};

View File

@ -179,8 +179,8 @@ export type BrowserSession = CreationEvent &
state: SessionState; state: SessionState;
/** The user logged in this session. */ /** The user logged in this session. */
user: User; user: User;
/** The user-agent string with which the session was created. */ /** The user-agent with which the session was created. */
userAgent?: Maybe<Scalars["String"]["output"]>; userAgent?: Maybe<UserAgent>;
}; };
/** A browser session represents a logged in user in a browser. */ /** A browser session represents a logged in user in a browser. */
@ -241,6 +241,8 @@ export type CompatSession = CreationEvent &
state: SessionState; state: SessionState;
/** The user authorized for this session. */ /** The user authorized for this session. */
user: User; user: User;
/** The user-agent with which the session was created. */
userAgent?: Maybe<UserAgent>;
}; };
export type CompatSessionConnection = { export type CompatSessionConnection = {
@ -343,6 +345,18 @@ export type CreationEvent = {
createdAt: Scalars["DateTime"]["output"]; 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. */ /** The input of the `endBrowserSession` mutation. */
export type EndBrowserSessionInput = { export type EndBrowserSessionInput = {
/** The ID of the session to end. */ /** The ID of the session to end. */
@ -617,6 +631,8 @@ export type Oauth2Session = CreationEvent &
state: SessionState; state: SessionState;
/** User authorized for this session. */ /** User authorized for this session. */
user?: Maybe<User>; user?: Maybe<User>;
/** The user-agent with which the session was created. */
userAgent?: Maybe<UserAgent>;
}; };
export type Oauth2SessionConnection = { export type Oauth2SessionConnection = {
@ -1058,6 +1074,25 @@ export type UserUpstreamOauth2LinksArgs = {
last?: InputMaybe<Scalars["Int"]["input"]>; 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 */ /** A user email address */
export type UserEmail = CreationEvent & export type UserEmail = CreationEvent &
Node & { Node & {
@ -1144,9 +1179,16 @@ export type BrowserSession_SessionFragment = {
id: string; id: string;
createdAt: string; createdAt: string;
finishedAt?: string | null; finishedAt?: string | null;
userAgent?: string | null;
lastActiveIp?: string | null; lastActiveIp?: string | null;
lastActiveAt?: string | null; lastActiveAt?: string | null;
userAgent?: {
__typename?: "UserAgent";
raw: string;
name?: string | null;
os?: string | null;
model?: string | null;
deviceType: DeviceType;
} | null;
lastAuthentication?: { lastAuthentication?: {
__typename?: "Authentication"; __typename?: "Authentication";
id: string; id: string;
@ -1193,6 +1235,14 @@ export type CompatSession_SessionFragment = {
finishedAt?: string | null; finishedAt?: string | null;
lastActiveIp?: string | null; lastActiveIp?: string | null;
lastActiveAt?: string | null; lastActiveAt?: string | null;
userAgent?: {
__typename?: "UserAgent";
raw: string;
name?: string | null;
os?: string | null;
model?: string | null;
deviceType: DeviceType;
} | null;
ssoLogin?: { ssoLogin?: {
__typename?: "CompatSsoLogin"; __typename?: "CompatSsoLogin";
id: string; id: string;
@ -1225,6 +1275,13 @@ export type OAuth2Session_SessionFragment = {
finishedAt?: string | null; finishedAt?: string | null;
lastActiveIp?: string | null; lastActiveIp?: string | null;
lastActiveAt?: string | null; lastActiveAt?: string | null;
userAgent?: {
__typename?: "UserAgent";
model?: string | null;
os?: string | null;
osVersion?: string | null;
deviceType: DeviceType;
} | null;
client: { client: {
__typename?: "Oauth2Client"; __typename?: "Oauth2Client";
id: string; id: string;
@ -1259,9 +1316,14 @@ export type BrowserSession_DetailFragment = {
id: string; id: string;
createdAt: string; createdAt: string;
finishedAt?: string | null; finishedAt?: string | null;
userAgent?: string | null;
lastActiveIp?: string | null; lastActiveIp?: string | null;
lastActiveAt?: string | null; lastActiveAt?: string | null;
userAgent?: {
__typename?: "UserAgent";
name?: string | null;
model?: string | null;
os?: string | null;
} | null;
lastAuthentication?: { lastAuthentication?: {
__typename?: "Authentication"; __typename?: "Authentication";
id: string; id: string;
@ -1278,6 +1340,12 @@ export type CompatSession_DetailFragment = {
finishedAt?: string | null; finishedAt?: string | null;
lastActiveIp?: string | null; lastActiveIp?: string | null;
lastActiveAt?: string | null; lastActiveAt?: string | null;
userAgent?: {
__typename?: "UserAgent";
name?: string | null;
os?: string | null;
model?: string | null;
} | null;
ssoLogin?: { ssoLogin?: {
__typename?: "CompatSsoLogin"; __typename?: "CompatSsoLogin";
id: string; 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 = { export const BrowserSession_SessionFragmentDoc = {
kind: "Document", kind: "Document",
definitions: [ definitions: [
@ -1764,7 +1820,20 @@ export const BrowserSession_SessionFragmentDoc = {
{ kind: "Field", name: { kind: "Name", value: "id" } }, { kind: "Field", name: { kind: "Name", value: "id" } },
{ kind: "Field", name: { kind: "Name", value: "createdAt" } }, { kind: "Field", name: { kind: "Name", value: "createdAt" } },
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { 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: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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", kind: "Field",
name: { kind: "Name", value: "ssoLogin" }, 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: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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", kind: "Field",
name: { kind: "Name", value: "client" }, 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: "id" } },
{ kind: "Field", name: { kind: "Name", value: "createdAt" } }, { kind: "Field", name: { kind: "Name", value: "createdAt" } },
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { 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: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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", kind: "Field",
name: { kind: "Name", value: "ssoLogin" }, 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: "id" } },
{ kind: "Field", name: { kind: "Name", value: "createdAt" } }, { kind: "Field", name: { kind: "Name", value: "createdAt" } },
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { 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: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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", kind: "Field",
name: { kind: "Name", value: "client" }, 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: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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", kind: "Field",
name: { kind: "Name", value: "ssoLogin" }, 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: "id" } },
{ kind: "Field", name: { kind: "Name", value: "createdAt" } }, { kind: "Field", name: { kind: "Name", value: "createdAt" } },
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { 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: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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: "id" } },
{ kind: "Field", name: { kind: "Name", value: "createdAt" } }, { kind: "Field", name: { kind: "Name", value: "createdAt" } },
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } }, { 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: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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", kind: "Field",
name: { kind: "Name", value: "ssoLogin" }, 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: "finishedAt" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } }, { kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } }, { 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", kind: "Field",
name: { kind: "Name", value: "client" }, name: { kind: "Name", value: "client" },
@ -4639,44 +4847,3 @@ export const AllowCrossSigningResetDocument = {
AllowCrossSigningResetMutation, AllowCrossSigningResetMutation,
AllowCrossSigningResetMutationVariables 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", name: "userAgent",
type: { type: {
kind: "SCALAR", kind: "OBJECT",
name: "Any", name: "UserAgent",
ofType: null,
}, },
args: [], args: [],
}, },
@ -628,6 +629,15 @@ export default {
}, },
args: [], args: [],
}, },
{
name: "userAgent",
type: {
kind: "OBJECT",
name: "UserAgent",
ofType: null,
},
args: [],
},
], ],
interfaces: [ interfaces: [
{ {
@ -1741,6 +1751,15 @@ export default {
}, },
args: [], args: [],
}, },
{
name: "userAgent",
type: {
kind: "OBJECT",
name: "UserAgent",
ofType: null,
},
args: [],
},
], ],
interfaces: [ 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", kind: "OBJECT",
name: "UserEmail", name: "UserEmail",

View File

@ -90,10 +90,21 @@
width: var(--cpd-space-10x); width: var(--cpd-space-10x);
} }
& .name { & .lines {
font: var(--cpd-font-body-md-semibold); display: flex;
letter-spacing: var(--cpd-font-letter-spacing-body-md); flex-direction: column;
color: var(--cpd-color-text-primary);
& 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> <h1 class="title">Allow access to your account?</h1>
<div class="consent-device-card"> <div class="consent-device-card">
<div class="device" {%- if grant.user_agent %} title="{{ grant.user_agent }}"{% endif %}> <div class="device" {%- if grant.user_agent %} title="{{ grant.user_agent.raw }}"{% endif %}>
{{ icon.web_browser() }} {% if grant.user_agent.device_type == "mobile" %}
{# TODO: Infer from the user agent #} {{ icon.mobile() }}
<div class="name">Device</div> {% 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>
<div class="meta"> <div class="meta">
{% if grant.ip_address %} {% if grant.ip_address %}

View File

@ -2,11 +2,11 @@
"action": { "action": {
"cancel": "Cancel", "cancel": "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": "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": "Create Account",
"@create_account": { "@create_account": {
@ -18,7 +18,7 @@
}, },
"sign_out": "Sign out", "sign_out": "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": { "app": {
@ -246,7 +246,7 @@
}, },
"not_you": "Not %(username)s?", "not_you": "Not %(username)s?",
"@not_you": { "@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" "description": "Suggestions for the user to log in as a different user"
}, },
"or_separator": "Or", "or_separator": "Or",