You've already forked authentication-service
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:
12
Cargo.lock
generated
12
Cargo.lock
generated
@ -3070,10 +3070,12 @@ dependencies = [
|
||||
"oauth2-types",
|
||||
"rand 0.8.5",
|
||||
"rand_chacha 0.3.1",
|
||||
"regex",
|
||||
"serde",
|
||||
"thiserror",
|
||||
"ulid",
|
||||
"url",
|
||||
"woothee",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -7198,6 +7200,16 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "woothee"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "896174c6a4779d4d7d4523dd27aef7d46609eda2497e370f6c998325c6bf6971"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.5.4"
|
||||
|
@ -20,6 +20,8 @@ crc = "3.0.1"
|
||||
ulid.workspace = true
|
||||
rand.workspace = true
|
||||
rand_chacha = "0.3.1"
|
||||
regex = "1.10.3"
|
||||
woothee = "0.13.0"
|
||||
|
||||
mas-iana.workspace = true
|
||||
mas-jose.workspace = true
|
||||
|
@ -1,4 +1,4 @@
|
||||
// Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
// Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
@ -12,4 +12,15 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
export * from "./useCurrentBrowserSessionId";
|
||||
use mas_data_model::UserAgent;
|
||||
|
||||
/// Simple command-line tool to try out user-agent parsing
|
||||
///
|
||||
/// It parses user-agents from stdin and prints the parsed user-agent to stdout.
|
||||
fn main() {
|
||||
for line in std::io::stdin().lines() {
|
||||
let user_agent = line.unwrap();
|
||||
let user_agent = UserAgent::parse(user_agent);
|
||||
println!("{user_agent:?}");
|
||||
}
|
||||
}
|
@ -19,7 +19,7 @@ use serde::Serialize;
|
||||
use ulid::Ulid;
|
||||
|
||||
use super::Device;
|
||||
use crate::InvalidTransitionError;
|
||||
use crate::{InvalidTransitionError, UserAgent};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub enum CompatSessionState {
|
||||
@ -83,7 +83,7 @@ pub struct CompatSession {
|
||||
pub user_session_id: Option<Ulid>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub is_synapse_admin: bool,
|
||||
pub user_agent: Option<String>,
|
||||
pub user_agent: Option<UserAgent>,
|
||||
pub last_active_at: Option<DateTime<Utc>>,
|
||||
pub last_active_ip: Option<IpAddr>,
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ pub(crate) mod compat;
|
||||
pub(crate) mod oauth2;
|
||||
pub(crate) mod tokens;
|
||||
pub(crate) mod upstream_oauth2;
|
||||
pub(crate) mod user_agent;
|
||||
pub(crate) mod users;
|
||||
|
||||
/// Error when an invalid state transition is attempted.
|
||||
@ -46,6 +47,7 @@ pub use self::{
|
||||
UpstreamOAuthProviderImportAction, UpstreamOAuthProviderImportPreference,
|
||||
UpstreamOAuthProviderPkceMode, UpstreamOAuthProviderSubjectPreference,
|
||||
},
|
||||
user_agent::{DeviceType, UserAgent},
|
||||
users::{
|
||||
Authentication, AuthenticationMethod, BrowserSession, Password, User, UserEmail,
|
||||
UserEmailVerification, UserEmailVerificationState,
|
||||
|
@ -19,7 +19,7 @@ use oauth2_types::scope::Scope;
|
||||
use serde::Serialize;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::{BrowserSession, InvalidTransitionError, Session};
|
||||
use crate::{BrowserSession, InvalidTransitionError, Session, UserAgent};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case", tag = "state")]
|
||||
@ -200,7 +200,7 @@ pub struct DeviceCodeGrant {
|
||||
pub ip_address: Option<IpAddr>,
|
||||
|
||||
/// The user agent used to request this device code grant.
|
||||
pub user_agent: Option<String>,
|
||||
pub user_agent: Option<UserAgent>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for DeviceCodeGrant {
|
||||
|
@ -19,7 +19,7 @@ use oauth2_types::scope::Scope;
|
||||
use serde::Serialize;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::InvalidTransitionError;
|
||||
use crate::{InvalidTransitionError, UserAgent};
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
|
||||
pub enum SessionState {
|
||||
@ -75,7 +75,7 @@ pub struct Session {
|
||||
pub user_session_id: Option<Ulid>,
|
||||
pub client_id: Ulid,
|
||||
pub scope: Scope,
|
||||
pub user_agent: Option<String>,
|
||||
pub user_agent: Option<UserAgent>,
|
||||
pub last_active_at: Option<DateTime<Utc>>,
|
||||
pub last_active_ip: Option<IpAddr>,
|
||||
}
|
||||
|
217
crates/data-model/src/user_agent.rs
Normal file
217
crates/data-model/src/user_agent.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
@ -19,6 +19,8 @@ use rand::{Rng, SeedableRng};
|
||||
use serde::Serialize;
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::UserAgent;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct User {
|
||||
pub id: Ulid,
|
||||
@ -83,7 +85,7 @@ pub struct BrowserSession {
|
||||
pub user: User,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub finished_at: Option<DateTime<Utc>>,
|
||||
pub user_agent: Option<String>,
|
||||
pub user_agent: Option<UserAgent>,
|
||||
pub last_active_at: Option<DateTime<Utc>>,
|
||||
pub last_active_ip: Option<IpAddr>,
|
||||
}
|
||||
@ -105,7 +107,9 @@ impl BrowserSession {
|
||||
user,
|
||||
created_at: now,
|
||||
finished_at: None,
|
||||
user_agent: Some("Mozilla/5.0".to_owned()),
|
||||
user_agent: Some(UserAgent::parse(
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned()
|
||||
)),
|
||||
last_active_at: Some(now),
|
||||
last_active_ip: None,
|
||||
})
|
||||
|
@ -49,10 +49,10 @@ pub enum Requester {
|
||||
Anonymous,
|
||||
|
||||
/// The requester is a browser session, stored in a cookie.
|
||||
BrowserSession(BrowserSession),
|
||||
BrowserSession(Box<BrowserSession>),
|
||||
|
||||
/// The requester is a OAuth2 session, with an access token.
|
||||
OAuth2Session(Session, Option<User>),
|
||||
OAuth2Session(Box<(Session, Option<User>)>),
|
||||
}
|
||||
|
||||
trait OwnerId {
|
||||
@ -108,21 +108,21 @@ impl Requester {
|
||||
fn browser_session(&self) -> Option<&BrowserSession> {
|
||||
match self {
|
||||
Self::BrowserSession(session) => Some(session),
|
||||
Self::OAuth2Session(_, _) | Self::Anonymous => None,
|
||||
Self::OAuth2Session(_) | Self::Anonymous => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn user(&self) -> Option<&User> {
|
||||
match self {
|
||||
Self::BrowserSession(session) => Some(&session.user),
|
||||
Self::OAuth2Session(_session, user) => user.as_ref(),
|
||||
Self::OAuth2Session(tuple) => tuple.1.as_ref(),
|
||||
Self::Anonymous => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn oauth2_session(&self) -> Option<&Session> {
|
||||
match self {
|
||||
Self::OAuth2Session(session, _) => Some(session),
|
||||
Self::OAuth2Session(tuple) => Some(&tuple.0),
|
||||
Self::BrowserSession(_) | Self::Anonymous => None,
|
||||
}
|
||||
}
|
||||
@ -148,10 +148,10 @@ impl Requester {
|
||||
|
||||
fn is_admin(&self) -> bool {
|
||||
match self {
|
||||
Self::OAuth2Session(session, _user) => {
|
||||
Self::OAuth2Session(tuple) => {
|
||||
// TODO: is this the right scope?
|
||||
// This has to be in sync with the policy
|
||||
session.scope.contains("urn:mas:admin")
|
||||
tuple.0.scope.contains("urn:mas:admin")
|
||||
}
|
||||
Self::BrowserSession(_) | Self::Anonymous => false,
|
||||
}
|
||||
@ -160,7 +160,7 @@ impl Requester {
|
||||
|
||||
impl From<BrowserSession> for Requester {
|
||||
fn from(session: BrowserSession) -> Self {
|
||||
Self::BrowserSession(session)
|
||||
Self::BrowserSession(Box::new(session))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -24,7 +24,7 @@ use mas_storage::{
|
||||
|
||||
use super::{
|
||||
AppSession, CompatSession, Cursor, NodeCursor, NodeType, OAuth2Session, PreloadedTotalCount,
|
||||
SessionState, User,
|
||||
SessionState, User, UserAgent,
|
||||
};
|
||||
use crate::state::ContextExt;
|
||||
|
||||
@ -87,9 +87,9 @@ impl BrowserSession {
|
||||
}
|
||||
}
|
||||
|
||||
/// The user-agent string with which the session was created.
|
||||
pub async fn user_agent(&self) -> Option<&str> {
|
||||
self.0.user_agent.as_deref()
|
||||
/// The user-agent with which the session was created.
|
||||
pub async fn user_agent(&self) -> Option<UserAgent> {
|
||||
self.0.user_agent.clone().map(UserAgent::from)
|
||||
}
|
||||
|
||||
/// The last IP address used by the session.
|
||||
|
@ -18,7 +18,7 @@ use chrono::{DateTime, Utc};
|
||||
use mas_storage::{compat::CompatSessionRepository, user::UserRepository};
|
||||
use url::Url;
|
||||
|
||||
use super::{BrowserSession, NodeType, SessionState, User};
|
||||
use super::{BrowserSession, NodeType, SessionState, User, UserAgent};
|
||||
use crate::state::ContextExt;
|
||||
|
||||
/// Lazy-loaded reverse reference.
|
||||
@ -103,6 +103,11 @@ impl CompatSession {
|
||||
self.session.finished_at()
|
||||
}
|
||||
|
||||
/// The user-agent with which the session was created.
|
||||
pub async fn user_agent(&self) -> Option<UserAgent> {
|
||||
self.session.user_agent.clone().map(UserAgent::from)
|
||||
}
|
||||
|
||||
/// The associated SSO login, if any.
|
||||
pub async fn sso_login(
|
||||
&self,
|
||||
|
@ -12,7 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
use async_graphql::{Enum, Interface, Object};
|
||||
use async_graphql::{Enum, Interface, Object, SimpleObject};
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
mod browser_sessions;
|
||||
@ -73,3 +73,69 @@ pub enum SessionState {
|
||||
/// The session is no longer active.
|
||||
Finished,
|
||||
}
|
||||
|
||||
/// The type of a user agent
|
||||
#[derive(Enum, Copy, Clone, Eq, PartialEq)]
|
||||
pub enum DeviceType {
|
||||
/// A personal computer, laptop or desktop
|
||||
Pc,
|
||||
|
||||
/// A mobile phone. Can also sometimes be a tablet.
|
||||
Mobile,
|
||||
|
||||
/// A tablet
|
||||
Tablet,
|
||||
|
||||
/// Unknown device type
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl From<mas_data_model::DeviceType> for DeviceType {
|
||||
fn from(device_type: mas_data_model::DeviceType) -> Self {
|
||||
match device_type {
|
||||
mas_data_model::DeviceType::Pc => Self::Pc,
|
||||
mas_data_model::DeviceType::Mobile => Self::Mobile,
|
||||
mas_data_model::DeviceType::Tablet => Self::Tablet,
|
||||
mas_data_model::DeviceType::Unknown => Self::Unknown,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parsed user agent string
|
||||
#[derive(SimpleObject)]
|
||||
pub struct UserAgent {
|
||||
/// The user agent string
|
||||
pub raw: String,
|
||||
|
||||
/// The name of the browser
|
||||
pub name: Option<String>,
|
||||
|
||||
/// The version of the browser
|
||||
pub version: Option<String>,
|
||||
|
||||
/// The operating system name
|
||||
pub os: Option<String>,
|
||||
|
||||
/// The operating system version
|
||||
pub os_version: Option<String>,
|
||||
|
||||
/// The device model
|
||||
pub model: Option<String>,
|
||||
|
||||
/// The device type
|
||||
pub device_type: DeviceType,
|
||||
}
|
||||
|
||||
impl From<mas_data_model::UserAgent> for UserAgent {
|
||||
fn from(ua: mas_data_model::UserAgent) -> Self {
|
||||
Self {
|
||||
raw: ua.raw,
|
||||
name: ua.name,
|
||||
version: ua.version,
|
||||
os: ua.os,
|
||||
os_version: ua.os_version,
|
||||
model: ua.model,
|
||||
device_type: ua.device_type.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,7 @@ use oauth2_types::{oidc::ApplicationType, scope::Scope};
|
||||
use ulid::Ulid;
|
||||
use url::Url;
|
||||
|
||||
use super::{BrowserSession, NodeType, SessionState, User};
|
||||
use super::{BrowserSession, NodeType, SessionState, User, UserAgent};
|
||||
use crate::{state::ContextExt, UserId};
|
||||
|
||||
/// An OAuth 2.0 session represents a client session which used the OAuth APIs
|
||||
@ -67,6 +67,11 @@ impl OAuth2Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The user-agent with which the session was created.
|
||||
pub async fn user_agent(&self) -> Option<UserAgent> {
|
||||
self.0.user_agent.clone().map(UserAgent::from)
|
||||
}
|
||||
|
||||
/// The state of the session.
|
||||
pub async fn state(&self) -> SessionState {
|
||||
match &self.0.state {
|
||||
|
@ -40,7 +40,7 @@ pub struct EndCompatSessionInput {
|
||||
/// The payload of the `endCompatSession` mutation.
|
||||
pub enum EndCompatSessionPayload {
|
||||
NotFound,
|
||||
Ended(mas_data_model::CompatSession),
|
||||
Ended(Box<mas_data_model::CompatSession>),
|
||||
}
|
||||
|
||||
/// The status of the `endCompatSession` mutation.
|
||||
@ -66,7 +66,7 @@ impl EndCompatSessionPayload {
|
||||
/// Returns the ended session.
|
||||
async fn compat_session(&self) -> Option<CompatSession> {
|
||||
match self {
|
||||
Self::Ended(session) => Some(CompatSession::new(session.clone())),
|
||||
Self::Ended(session) => Some(CompatSession::new(*session.clone())),
|
||||
Self::NotFound => None,
|
||||
}
|
||||
}
|
||||
@ -110,6 +110,6 @@ impl CompatSessionMutations {
|
||||
|
||||
repo.save().await?;
|
||||
|
||||
Ok(EndCompatSessionPayload::Ended(session))
|
||||
Ok(EndCompatSessionPayload::Ended(Box::new(session)))
|
||||
}
|
||||
}
|
||||
|
@ -31,8 +31,11 @@ impl ViewerQuery {
|
||||
|
||||
match requester {
|
||||
Requester::BrowserSession(session) => Viewer::user(session.user.clone()),
|
||||
Requester::OAuth2Session(_session, Some(user)) => Viewer::user(user.clone()),
|
||||
Requester::OAuth2Session(_, None) | Requester::Anonymous => Viewer::anonymous(),
|
||||
Requester::OAuth2Session(tuple) => match &tuple.1 {
|
||||
Some(user) => Viewer::user(user.clone()),
|
||||
None => Viewer::anonymous(),
|
||||
},
|
||||
Requester::Anonymous => Viewer::anonymous(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,10 +44,8 @@ impl ViewerQuery {
|
||||
let requester = ctx.requester();
|
||||
|
||||
match requester {
|
||||
Requester::BrowserSession(session) => ViewerSession::browser_session(session.clone()),
|
||||
Requester::OAuth2Session(session, _user) => {
|
||||
ViewerSession::oauth2_session(session.clone())
|
||||
}
|
||||
Requester::BrowserSession(session) => ViewerSession::browser_session(*session.clone()),
|
||||
Requester::OAuth2Session(tuple) => ViewerSession::oauth2_session(tuple.0.clone()),
|
||||
Requester::Anonymous => ViewerSession::anonymous(),
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ use axum::{extract::State, response::IntoResponse, Json, TypedHeader};
|
||||
use chrono::Duration;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::sentry::SentryEventID;
|
||||
use mas_data_model::{CompatSession, CompatSsoLoginState, Device, TokenType, User};
|
||||
use mas_data_model::{CompatSession, CompatSsoLoginState, Device, TokenType, User, UserAgent};
|
||||
use mas_storage::{
|
||||
compat::{
|
||||
CompatAccessTokenRepository, CompatRefreshTokenRepository, CompatSessionRepository,
|
||||
@ -220,7 +220,7 @@ pub(crate) async fn post(
|
||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
||||
Json(input): Json<RequestBody>,
|
||||
) -> Result<impl IntoResponse, RouteError> {
|
||||
let user_agent = user_agent.map(|ua| ua.to_string());
|
||||
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
|
||||
let (mut session, user) = match (password_manager.is_enabled(), input.credentials) {
|
||||
(
|
||||
true,
|
||||
|
@ -237,7 +237,7 @@ async fn get_requester(
|
||||
return Err(RouteError::MissingScope);
|
||||
}
|
||||
|
||||
Requester::OAuth2Session(session, user)
|
||||
Requester::OAuth2Session(Box::new((session, user)))
|
||||
} else {
|
||||
let maybe_session = session_info.load_session(&mut repo).await?;
|
||||
|
||||
|
@ -14,13 +14,14 @@
|
||||
|
||||
use axum::{extract::State, response::IntoResponse, Json, TypedHeader};
|
||||
use chrono::Duration;
|
||||
use headers::{CacheControl, Pragma, UserAgent};
|
||||
use headers::{CacheControl, Pragma};
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::{
|
||||
client_authorization::{ClientAuthorization, CredentialsVerificationError},
|
||||
http_client_factory::HttpClientFactory,
|
||||
sentry::SentryEventID,
|
||||
};
|
||||
use mas_data_model::UserAgent;
|
||||
use mas_keystore::Encrypter;
|
||||
use mas_router::UrlBuilder;
|
||||
use mas_storage::{oauth2::OAuth2DeviceCodeGrantParams, BoxClock, BoxRepository, BoxRng};
|
||||
@ -84,7 +85,7 @@ pub(crate) async fn post(
|
||||
mut rng: BoxRng,
|
||||
clock: BoxClock,
|
||||
mut repo: BoxRepository,
|
||||
user_agent: Option<TypedHeader<UserAgent>>,
|
||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
||||
activity_tracker: BoundActivityTracker,
|
||||
State(url_builder): State<UrlBuilder>,
|
||||
State(http_client_factory): State<HttpClientFactory>,
|
||||
@ -125,7 +126,7 @@ pub(crate) async fn post(
|
||||
|
||||
let expires_in = Duration::minutes(20);
|
||||
|
||||
let user_agent = user_agent.map(|ua| ua.0.to_string());
|
||||
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
|
||||
let ip_address = activity_tracker.ip();
|
||||
|
||||
let device_code = Alphanumeric.sample_string(&mut rng, 32);
|
||||
|
@ -21,7 +21,9 @@ use mas_axum_utils::{
|
||||
http_client_factory::HttpClientFactory,
|
||||
sentry::SentryEventID,
|
||||
};
|
||||
use mas_data_model::{AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, TokenType};
|
||||
use mas_data_model::{
|
||||
AuthorizationGrantStage, Client, Device, DeviceCodeGrantState, TokenType, UserAgent,
|
||||
};
|
||||
use mas_keystore::{Encrypter, Keystore};
|
||||
use mas_oidc_client::types::scope::ScopeToken;
|
||||
use mas_policy::Policy;
|
||||
@ -233,7 +235,7 @@ pub(crate) async fn post(
|
||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
||||
client_authorization: ClientAuthorization<AccessTokenRequest>,
|
||||
) -> Result<impl IntoResponse, RouteError> {
|
||||
let user_agent = user_agent.map(|ua| ua.to_string());
|
||||
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
|
||||
let client = client_authorization
|
||||
.credentials
|
||||
.fetch(&mut repo)
|
||||
@ -335,7 +337,7 @@ async fn authorization_code_grant(
|
||||
url_builder: &UrlBuilder,
|
||||
site_config: &SiteConfig,
|
||||
mut repo: BoxRepository,
|
||||
user_agent: Option<String>,
|
||||
user_agent: Option<UserAgent>,
|
||||
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
|
||||
// Check that the client is allowed to use this grant type
|
||||
if !client.grant_types.contains(&GrantType::AuthorizationCode) {
|
||||
@ -504,7 +506,7 @@ async fn refresh_token_grant(
|
||||
client: &Client,
|
||||
site_config: &SiteConfig,
|
||||
mut repo: BoxRepository,
|
||||
user_agent: Option<String>,
|
||||
user_agent: Option<UserAgent>,
|
||||
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
|
||||
// Check that the client is allowed to use this grant type
|
||||
if !client.grant_types.contains(&GrantType::RefreshToken) {
|
||||
@ -587,7 +589,7 @@ async fn client_credentials_grant(
|
||||
site_config: &SiteConfig,
|
||||
mut repo: BoxRepository,
|
||||
mut policy: Policy,
|
||||
user_agent: Option<String>,
|
||||
user_agent: Option<UserAgent>,
|
||||
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
|
||||
// Check that the client is allowed to use this grant type
|
||||
if !client.grant_types.contains(&GrantType::ClientCredentials) {
|
||||
@ -656,7 +658,7 @@ async fn device_code_grant(
|
||||
url_builder: &UrlBuilder,
|
||||
site_config: &SiteConfig,
|
||||
mut repo: BoxRepository,
|
||||
user_agent: Option<String>,
|
||||
user_agent: Option<UserAgent>,
|
||||
) -> Result<(AccessTokenResponse, BoxRepository), RouteError> {
|
||||
// Check that the client is allowed to use this grant type
|
||||
if !client.grant_types.contains(&GrantType::DeviceCode) {
|
||||
|
@ -24,7 +24,7 @@ use mas_axum_utils::{
|
||||
sentry::SentryEventID,
|
||||
FancyError, SessionInfoExt,
|
||||
};
|
||||
use mas_data_model::User;
|
||||
use mas_data_model::{User, UserAgent};
|
||||
use mas_jose::jwt::Jwt;
|
||||
use mas_policy::Policy;
|
||||
use mas_router::UrlBuilder;
|
||||
@ -200,7 +200,7 @@ pub(crate) async fn get(
|
||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
||||
Path(link_id): Path<Ulid>,
|
||||
) -> Result<impl IntoResponse, RouteError> {
|
||||
let user_agent = user_agent.map(|ua| ua.as_str().to_owned());
|
||||
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
|
||||
let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar);
|
||||
let (session_id, post_auth_action) = sessions_cookie
|
||||
.lookup_link(link_id)
|
||||
@ -481,7 +481,7 @@ pub(crate) async fn post(
|
||||
Path(link_id): Path<Ulid>,
|
||||
Form(form): Form<ProtectedForm<FormData>>,
|
||||
) -> Result<Response, RouteError> {
|
||||
let user_agent = user_agent.map(|ua| ua.as_str().to_owned());
|
||||
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
|
||||
let form = cookie_jar.verify_form(&clock, form)?;
|
||||
|
||||
let sessions_cookie = UpstreamSessionsCookie::load(&cookie_jar);
|
||||
|
@ -17,14 +17,13 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
TypedHeader,
|
||||
};
|
||||
use headers::UserAgent;
|
||||
use hyper::StatusCode;
|
||||
use mas_axum_utils::{
|
||||
cookies::CookieJar,
|
||||
csrf::{CsrfExt, CsrfToken, ProtectedForm},
|
||||
FancyError, SessionInfoExt,
|
||||
};
|
||||
use mas_data_model::BrowserSession;
|
||||
use mas_data_model::{BrowserSession, UserAgent};
|
||||
use mas_i18n::DataLocale;
|
||||
use mas_router::{UpstreamOAuth2Authorize, UrlBuilder};
|
||||
use mas_storage::{
|
||||
@ -123,10 +122,10 @@ pub(crate) async fn post(
|
||||
activity_tracker: BoundActivityTracker,
|
||||
Query(query): Query<OptionalPostAuthAction>,
|
||||
cookie_jar: CookieJar,
|
||||
user_agent: Option<TypedHeader<UserAgent>>,
|
||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
||||
Form(form): Form<ProtectedForm<LoginForm>>,
|
||||
) -> Result<Response, FancyError> {
|
||||
let user_agent = user_agent.map(|ua| ua.as_str().to_owned());
|
||||
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
|
||||
if !password_manager.is_enabled() {
|
||||
// XXX: is it necessary to have better errors here?
|
||||
return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
|
||||
@ -216,7 +215,7 @@ async fn login(
|
||||
clock: &impl Clock,
|
||||
username: &str,
|
||||
password: &str,
|
||||
user_agent: Option<String>,
|
||||
user_agent: Option<UserAgent>,
|
||||
) -> Result<BrowserSession, FormError> {
|
||||
// XXX: we're loosing the error context here
|
||||
// First, lookup the user
|
||||
|
@ -19,7 +19,6 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
TypedHeader,
|
||||
};
|
||||
use headers::UserAgent;
|
||||
use hyper::StatusCode;
|
||||
use lettre::Address;
|
||||
use mas_axum_utils::{
|
||||
@ -27,6 +26,7 @@ use mas_axum_utils::{
|
||||
csrf::{CsrfExt, CsrfToken, ProtectedForm},
|
||||
FancyError, SessionInfoExt,
|
||||
};
|
||||
use mas_data_model::UserAgent;
|
||||
use mas_i18n::DataLocale;
|
||||
use mas_policy::Policy;
|
||||
use mas_router::UrlBuilder;
|
||||
@ -116,10 +116,10 @@ pub(crate) async fn post(
|
||||
activity_tracker: BoundActivityTracker,
|
||||
Query(query): Query<OptionalPostAuthAction>,
|
||||
cookie_jar: CookieJar,
|
||||
user_agent: Option<TypedHeader<UserAgent>>,
|
||||
user_agent: Option<TypedHeader<headers::UserAgent>>,
|
||||
Form(form): Form<ProtectedForm<RegisterForm>>,
|
||||
) -> Result<Response, FancyError> {
|
||||
let user_agent = user_agent.map(|ua| ua.as_str().to_owned());
|
||||
let user_agent = user_agent.map(|ua| UserAgent::parse(ua.as_str().to_owned()));
|
||||
if !password_manager.is_enabled() {
|
||||
return Ok(StatusCode::METHOD_NOT_ALLOWED.into_response());
|
||||
}
|
||||
|
@ -15,7 +15,7 @@
|
||||
//! A module containing PostgreSQL implementation of repositories for sessions
|
||||
|
||||
use async_trait::async_trait;
|
||||
use mas_data_model::{CompatSession, CompatSessionState, Device, Session, SessionState};
|
||||
use mas_data_model::{CompatSession, CompatSessionState, Device, Session, SessionState, UserAgent};
|
||||
use mas_storage::{
|
||||
app_session::{AppSession, AppSessionFilter, AppSessionRepository},
|
||||
Page, Pagination,
|
||||
@ -84,6 +84,7 @@ use priv_::{AppSessionLookup, AppSessionLookupIden};
|
||||
impl TryFrom<AppSessionLookup> for AppSession {
|
||||
type Error = DatabaseError;
|
||||
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn try_from(value: AppSessionLookup) -> Result<Self, Self::Error> {
|
||||
// This is annoying to do, but we have to match on all the fields to determine
|
||||
// whether it's a compat session or an oauth2 session
|
||||
@ -104,6 +105,7 @@ impl TryFrom<AppSessionLookup> for AppSession {
|
||||
last_active_ip,
|
||||
} = value;
|
||||
|
||||
let user_agent = user_agent.map(UserAgent::parse);
|
||||
let user_session_id = user_session_id.map(Ulid::from);
|
||||
|
||||
match (
|
||||
|
@ -28,7 +28,7 @@ pub use self::{
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Duration;
|
||||
use mas_data_model::Device;
|
||||
use mas_data_model::{Device, UserAgent};
|
||||
use mas_storage::{
|
||||
clock::MockClock,
|
||||
compat::{
|
||||
@ -133,7 +133,7 @@ mod tests {
|
||||
assert!(session_lookup.user_agent.is_none());
|
||||
let session = repo
|
||||
.compat_session()
|
||||
.record_user_agent(session_lookup, "Mozilla/5.0".to_owned())
|
||||
.record_user_agent(session_lookup, UserAgent::parse("Mozilla/5.0".to_owned()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(session.user_agent.as_deref(), Some("Mozilla/5.0"));
|
||||
|
@ -18,7 +18,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use mas_data_model::{
|
||||
BrowserSession, CompatSession, CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device,
|
||||
User,
|
||||
User, UserAgent,
|
||||
};
|
||||
use mas_storage::{
|
||||
compat::{CompatSessionFilter, CompatSessionRepository},
|
||||
@ -90,7 +90,7 @@ impl TryFrom<CompatSessionLookup> for CompatSession {
|
||||
device,
|
||||
created_at: value.created_at,
|
||||
is_synapse_admin: value.is_synapse_admin,
|
||||
user_agent: value.user_agent,
|
||||
user_agent: value.user_agent.map(UserAgent::parse),
|
||||
last_active_at: value.last_active_at,
|
||||
last_active_ip: value.last_active_ip,
|
||||
};
|
||||
@ -145,7 +145,7 @@ impl TryFrom<CompatSessionAndSsoLoginLookup> for (CompatSession, Option<CompatSs
|
||||
user_session_id: value.user_session_id.map(Ulid::from),
|
||||
created_at: value.created_at,
|
||||
is_synapse_admin: value.is_synapse_admin,
|
||||
user_agent: value.user_agent,
|
||||
user_agent: value.user_agent.map(UserAgent::parse),
|
||||
last_active_at: value.last_active_at,
|
||||
last_active_ip: value.last_active_ip,
|
||||
};
|
||||
@ -575,7 +575,7 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> {
|
||||
async fn record_user_agent(
|
||||
&mut self,
|
||||
mut compat_session: CompatSession,
|
||||
user_agent: String,
|
||||
user_agent: UserAgent,
|
||||
) -> Result<CompatSession, Self::Error> {
|
||||
let res = sqlx::query!(
|
||||
r#"
|
||||
@ -584,7 +584,7 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> {
|
||||
WHERE compat_session_id = $1
|
||||
"#,
|
||||
Uuid::from(compat_session.id),
|
||||
user_agent,
|
||||
&*user_agent,
|
||||
)
|
||||
.traced()
|
||||
.execute(&mut *self.conn)
|
||||
|
@ -16,7 +16,7 @@ use std::net::IpAddr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use mas_data_model::{BrowserSession, DeviceCodeGrant, DeviceCodeGrantState, Session};
|
||||
use mas_data_model::{BrowserSession, DeviceCodeGrant, DeviceCodeGrantState, Session, UserAgent};
|
||||
use mas_storage::{
|
||||
oauth2::{OAuth2DeviceCodeGrantParams, OAuth2DeviceCodeGrantRepository},
|
||||
Clock,
|
||||
@ -140,7 +140,7 @@ impl TryFrom<OAuth2DeviceGrantLookup> for DeviceCodeGrant {
|
||||
created_at,
|
||||
expires_at,
|
||||
ip_address,
|
||||
user_agent,
|
||||
user_agent: user_agent.map(UserAgent::parse),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ pub use self::{
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::Duration;
|
||||
use mas_data_model::AuthorizationCode;
|
||||
use mas_data_model::{AuthorizationCode, UserAgent};
|
||||
use mas_storage::{
|
||||
clock::MockClock,
|
||||
oauth2::{OAuth2DeviceCodeGrantParams, OAuth2SessionFilter, OAuth2SessionRepository},
|
||||
@ -371,7 +371,7 @@ mod tests {
|
||||
assert!(session.user_agent.is_none());
|
||||
let session = repo
|
||||
.oauth2_session()
|
||||
.record_user_agent(session, "Mozilla/5.0".to_owned())
|
||||
.record_user_agent(session, UserAgent::parse("Mozilla/5.0".to_owned()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(session.user_agent.as_deref(), Some("Mozilla/5.0"));
|
||||
|
@ -16,7 +16,7 @@ use std::net::IpAddr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use mas_data_model::{BrowserSession, Client, Session, SessionState, User};
|
||||
use mas_data_model::{BrowserSession, Client, Session, SessionState, User, UserAgent};
|
||||
use mas_storage::{
|
||||
oauth2::{OAuth2SessionFilter, OAuth2SessionRepository},
|
||||
Clock, Page, Pagination,
|
||||
@ -94,7 +94,7 @@ impl TryFrom<OAuthSessionLookup> for Session {
|
||||
user_id: value.user_id.map(Ulid::from),
|
||||
user_session_id: value.user_session_id.map(Ulid::from),
|
||||
scope,
|
||||
user_agent: value.user_agent,
|
||||
user_agent: value.user_agent.map(UserAgent::parse),
|
||||
last_active_at: value.last_active_at,
|
||||
last_active_ip: value.last_active_ip,
|
||||
})
|
||||
@ -444,14 +444,14 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> {
|
||||
%session.id,
|
||||
%session.scope,
|
||||
client.id = %session.client_id,
|
||||
session.user_agent = %user_agent,
|
||||
session.user_agent = %user_agent.raw,
|
||||
),
|
||||
err,
|
||||
)]
|
||||
async fn record_user_agent(
|
||||
&mut self,
|
||||
mut session: Session,
|
||||
user_agent: String,
|
||||
user_agent: UserAgent,
|
||||
) -> Result<Session, Self::Error> {
|
||||
let res = sqlx::query!(
|
||||
r#"
|
||||
@ -460,7 +460,7 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> {
|
||||
WHERE oauth2_session_id = $1
|
||||
"#,
|
||||
Uuid::from(session.id),
|
||||
user_agent,
|
||||
&*user_agent,
|
||||
)
|
||||
.traced()
|
||||
.execute(&mut *self.conn)
|
||||
|
@ -18,7 +18,7 @@ use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use mas_data_model::{
|
||||
Authentication, AuthenticationMethod, BrowserSession, Password,
|
||||
UpstreamOAuthAuthorizationSession, User,
|
||||
UpstreamOAuthAuthorizationSession, User, UserAgent,
|
||||
};
|
||||
use mas_storage::{user::BrowserSessionRepository, Clock, Page, Pagination};
|
||||
use rand::RngCore;
|
||||
@ -86,7 +86,7 @@ impl TryFrom<SessionLookup> for BrowserSession {
|
||||
user,
|
||||
created_at: value.user_session_created_at,
|
||||
finished_at: value.user_session_finished_at,
|
||||
user_agent: value.user_session_user_agent,
|
||||
user_agent: value.user_session_user_agent.map(UserAgent::parse),
|
||||
last_active_at: value.user_session_last_active_at,
|
||||
last_active_ip: value.user_session_last_active_ip,
|
||||
})
|
||||
@ -189,7 +189,7 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> {
|
||||
rng: &mut (dyn RngCore + Send),
|
||||
clock: &dyn Clock,
|
||||
user: &User,
|
||||
user_agent: Option<String>,
|
||||
user_agent: Option<UserAgent>,
|
||||
) -> Result<BrowserSession, Self::Error> {
|
||||
let created_at = clock.now();
|
||||
let id = Ulid::from_datetime_with_source(created_at.into(), rng);
|
||||
@ -203,7 +203,7 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> {
|
||||
Uuid::from(id),
|
||||
Uuid::from(user.id),
|
||||
created_at,
|
||||
user_agent,
|
||||
user_agent.as_deref(),
|
||||
)
|
||||
.traced()
|
||||
.execute(&mut *self.conn)
|
||||
|
@ -16,7 +16,7 @@ use std::net::IpAddr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, Device, User};
|
||||
use mas_data_model::{BrowserSession, CompatSession, CompatSsoLogin, Device, User, UserAgent};
|
||||
use rand_core::RngCore;
|
||||
use ulid::Ulid;
|
||||
|
||||
@ -266,7 +266,7 @@ pub trait CompatSessionRepository: Send + Sync {
|
||||
async fn record_user_agent(
|
||||
&mut self,
|
||||
compat_session: CompatSession,
|
||||
user_agent: String,
|
||||
user_agent: UserAgent,
|
||||
) -> Result<CompatSession, Self::Error>;
|
||||
}
|
||||
|
||||
@ -305,6 +305,6 @@ repository_impl!(CompatSessionRepository:
|
||||
async fn record_user_agent(
|
||||
&mut self,
|
||||
compat_session: CompatSession,
|
||||
user_agent: String,
|
||||
user_agent: UserAgent,
|
||||
) -> Result<CompatSession, Self::Error>;
|
||||
);
|
||||
|
@ -16,7 +16,7 @@ use std::net::IpAddr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::Duration;
|
||||
use mas_data_model::{BrowserSession, Client, DeviceCodeGrant, Session};
|
||||
use mas_data_model::{BrowserSession, Client, DeviceCodeGrant, Session, UserAgent};
|
||||
use oauth2_types::scope::Scope;
|
||||
use rand_core::RngCore;
|
||||
use ulid::Ulid;
|
||||
@ -44,7 +44,7 @@ pub struct OAuth2DeviceCodeGrantParams<'a> {
|
||||
pub ip_address: Option<IpAddr>,
|
||||
|
||||
/// The user agent from which the request was made
|
||||
pub user_agent: Option<String>,
|
||||
pub user_agent: Option<UserAgent>,
|
||||
}
|
||||
|
||||
/// An [`OAuth2DeviceCodeGrantRepository`] helps interacting with
|
||||
|
@ -16,7 +16,7 @@ use std::net::IpAddr;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use mas_data_model::{BrowserSession, Client, Session, User};
|
||||
use mas_data_model::{BrowserSession, Client, Session, User, UserAgent};
|
||||
use oauth2_types::scope::Scope;
|
||||
use rand_core::RngCore;
|
||||
use ulid::Ulid;
|
||||
@ -296,7 +296,7 @@ pub trait OAuth2SessionRepository: Send + Sync {
|
||||
async fn record_user_agent(
|
||||
&mut self,
|
||||
session: Session,
|
||||
user_agent: String,
|
||||
user_agent: UserAgent,
|
||||
) -> Result<Session, Self::Error>;
|
||||
}
|
||||
|
||||
@ -349,6 +349,6 @@ repository_impl!(OAuth2SessionRepository:
|
||||
async fn record_user_agent(
|
||||
&mut self,
|
||||
session: Session,
|
||||
user_agent: String,
|
||||
user_agent: UserAgent,
|
||||
) -> Result<Session, Self::Error>;
|
||||
);
|
||||
|
@ -17,7 +17,7 @@ use std::net::IpAddr;
|
||||
use async_trait::async_trait;
|
||||
use chrono::{DateTime, Utc};
|
||||
use mas_data_model::{
|
||||
Authentication, BrowserSession, Password, UpstreamOAuthAuthorizationSession, User,
|
||||
Authentication, BrowserSession, Password, UpstreamOAuthAuthorizationSession, User, UserAgent,
|
||||
};
|
||||
use rand_core::RngCore;
|
||||
use ulid::Ulid;
|
||||
@ -127,7 +127,7 @@ pub trait BrowserSessionRepository: Send + Sync {
|
||||
rng: &mut (dyn RngCore + Send),
|
||||
clock: &dyn Clock,
|
||||
user: &User,
|
||||
user_agent: Option<String>,
|
||||
user_agent: Option<UserAgent>,
|
||||
) -> Result<BrowserSession, Self::Error>;
|
||||
|
||||
/// Finish a [`BrowserSession`]
|
||||
@ -254,7 +254,7 @@ repository_impl!(BrowserSessionRepository:
|
||||
rng: &mut (dyn RngCore + Send),
|
||||
clock: &dyn Clock,
|
||||
user: &User,
|
||||
user_agent: Option<String>,
|
||||
user_agent: Option<UserAgent>,
|
||||
) -> Result<BrowserSession, Self::Error>;
|
||||
async fn finish(
|
||||
&mut self,
|
||||
|
@ -25,7 +25,7 @@ use chrono::{DateTime, Duration, Utc};
|
||||
use http::{Method, Uri, Version};
|
||||
use mas_data_model::{
|
||||
AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState,
|
||||
DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserEmail,
|
||||
DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserAgent, UserEmail,
|
||||
UserEmailVerification,
|
||||
};
|
||||
use mas_i18n::DataLocale;
|
||||
@ -1164,7 +1164,7 @@ impl TemplateContext for DeviceConsentContext {
|
||||
created_at: now - Duration::minutes(5),
|
||||
expires_at: now + Duration::minutes(25),
|
||||
ip_address: Some(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1))),
|
||||
user_agent: Some("Mozilla/5.0".to_owned()),
|
||||
user_agent: Some(UserAgent::parse("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.0.0 Safari/537.36".to_owned())),
|
||||
};
|
||||
Self { grant, client }
|
||||
})
|
||||
|
@ -102,7 +102,7 @@ module.exports = {
|
||||
exceptions: {
|
||||
// The '*Connection', '*Edge', '*Payload' and 'PageInfo' types don't have IDs
|
||||
// XXX: Maybe the MatrixUser type should have an ID?
|
||||
types: ["PageInfo", "MatrixUser"],
|
||||
types: ["PageInfo", "MatrixUser", "UserAgent"],
|
||||
suffixes: ["Connection", "Edge", "Payload"],
|
||||
},
|
||||
},
|
||||
|
@ -55,10 +55,10 @@
|
||||
"session_details_title": "Session"
|
||||
},
|
||||
"device_type_icon_label": {
|
||||
"desktop": "Desktop",
|
||||
"mobile": "Mobile",
|
||||
"unknown": "Unknown device type",
|
||||
"web": "Web"
|
||||
"pc": "Computer",
|
||||
"tablet": "Tablet",
|
||||
"unknown": "Unknown device type"
|
||||
},
|
||||
"end_session_button": {
|
||||
"confirmation_modal_title": "Are you sure you want to end this session?",
|
||||
|
@ -212,9 +212,9 @@ type BrowserSession implements Node & CreationEvent {
|
||||
"""
|
||||
state: SessionState!
|
||||
"""
|
||||
The user-agent string with which the session was created.
|
||||
The user-agent with which the session was created.
|
||||
"""
|
||||
userAgent: String
|
||||
userAgent: UserAgent
|
||||
"""
|
||||
The last IP address used by the session.
|
||||
"""
|
||||
@ -314,6 +314,10 @@ type CompatSession implements Node & CreationEvent {
|
||||
"""
|
||||
finishedAt: DateTime
|
||||
"""
|
||||
The user-agent with which the session was created.
|
||||
"""
|
||||
userAgent: UserAgent
|
||||
"""
|
||||
The associated SSO login, if any.
|
||||
"""
|
||||
ssoLogin: CompatSsoLogin
|
||||
@ -500,6 +504,28 @@ The input/output is a string in RFC3339 format.
|
||||
"""
|
||||
scalar DateTime
|
||||
|
||||
"""
|
||||
The type of a user agent
|
||||
"""
|
||||
enum DeviceType {
|
||||
"""
|
||||
A personal computer, laptop or desktop
|
||||
"""
|
||||
PC
|
||||
"""
|
||||
A mobile phone. Can also sometimes be a tablet.
|
||||
"""
|
||||
MOBILE
|
||||
"""
|
||||
A tablet
|
||||
"""
|
||||
TABLET
|
||||
"""
|
||||
Unknown device type
|
||||
"""
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
"""
|
||||
The input of the `endBrowserSession` mutation.
|
||||
"""
|
||||
@ -822,6 +848,10 @@ type Oauth2Session implements Node & CreationEvent {
|
||||
"""
|
||||
finishedAt: DateTime
|
||||
"""
|
||||
The user-agent with which the session was created.
|
||||
"""
|
||||
userAgent: UserAgent
|
||||
"""
|
||||
The state of the session.
|
||||
"""
|
||||
state: SessionState!
|
||||
@ -1529,6 +1559,40 @@ type User implements Node {
|
||||
): AppSessionConnection!
|
||||
}
|
||||
|
||||
"""
|
||||
A parsed user agent string
|
||||
"""
|
||||
type UserAgent {
|
||||
"""
|
||||
The user agent string
|
||||
"""
|
||||
raw: String!
|
||||
"""
|
||||
The name of the browser
|
||||
"""
|
||||
name: String
|
||||
"""
|
||||
The version of the browser
|
||||
"""
|
||||
version: String
|
||||
"""
|
||||
The operating system name
|
||||
"""
|
||||
os: String
|
||||
"""
|
||||
The operating system version
|
||||
"""
|
||||
osVersion: String
|
||||
"""
|
||||
The device model
|
||||
"""
|
||||
model: String
|
||||
"""
|
||||
The device type
|
||||
"""
|
||||
deviceType: DeviceType!
|
||||
}
|
||||
|
||||
"""
|
||||
A user email address
|
||||
"""
|
||||
|
@ -17,10 +17,6 @@ import { useCallback } from "react";
|
||||
import { useMutation } from "urql";
|
||||
|
||||
import { FragmentType, graphql, useFragment } from "../gql";
|
||||
import {
|
||||
parseUserAgent,
|
||||
sessionNameFromDeviceInformation,
|
||||
} from "../utils/parseUserAgent";
|
||||
|
||||
import EndSessionButton from "./Session/EndSessionButton";
|
||||
import Session from "./Session/Session";
|
||||
@ -30,7 +26,13 @@ const FRAGMENT = graphql(/* GraphQL */ `
|
||||
id
|
||||
createdAt
|
||||
finishedAt
|
||||
userAgent
|
||||
userAgent {
|
||||
raw
|
||||
name
|
||||
os
|
||||
model
|
||||
deviceType
|
||||
}
|
||||
lastActiveIp
|
||||
lastActiveAt
|
||||
lastAuthentication {
|
||||
@ -83,9 +85,16 @@ const BrowserSession: React.FC<Props> = ({ session, isCurrent }) => {
|
||||
const lastActiveAt = data.lastActiveAt
|
||||
? parseISO(data.lastActiveAt)
|
||||
: undefined;
|
||||
const deviceInformation = parseUserAgent(data.userAgent || undefined);
|
||||
const sessionName =
|
||||
sessionNameFromDeviceInformation(deviceInformation) || "Browser session";
|
||||
let sessionName = "Browser session";
|
||||
if (data.userAgent) {
|
||||
if (data.userAgent.model && data.userAgent.name) {
|
||||
sessionName = `${data.userAgent.name} on ${data.userAgent.model}`;
|
||||
} else if (data.userAgent.name && data.userAgent.os) {
|
||||
sessionName = `${data.userAgent.name} on ${data.userAgent.os}`;
|
||||
} else if (data.userAgent.name) {
|
||||
sessionName = data.userAgent.name;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Session
|
||||
@ -94,7 +103,7 @@ const BrowserSession: React.FC<Props> = ({ session, isCurrent }) => {
|
||||
createdAt={createdAt}
|
||||
finishedAt={finishedAt}
|
||||
isCurrent={isCurrent}
|
||||
deviceType={deviceInformation?.deviceType}
|
||||
deviceType={data.userAgent?.deviceType}
|
||||
lastActiveIp={data.lastActiveIp || undefined}
|
||||
lastActiveAt={lastActiveAt}
|
||||
>
|
||||
|
@ -28,6 +28,13 @@ export const FRAGMENT = graphql(/* GraphQL */ `
|
||||
finishedAt
|
||||
lastActiveIp
|
||||
lastActiveAt
|
||||
userAgent {
|
||||
raw
|
||||
name
|
||||
os
|
||||
model
|
||||
deviceType
|
||||
}
|
||||
ssoLogin {
|
||||
id
|
||||
redirectUri
|
||||
@ -78,10 +85,20 @@ const CompatSession: React.FC<{
|
||||
await endCompatSession({ id: data.id });
|
||||
};
|
||||
|
||||
const clientName = data.ssoLogin?.redirectUri
|
||||
let clientName = data.ssoLogin?.redirectUri
|
||||
? simplifyUrl(data.ssoLogin.redirectUri)
|
||||
: undefined;
|
||||
|
||||
if (data.userAgent) {
|
||||
if (data.userAgent.model && data.userAgent.name) {
|
||||
clientName = `${data.userAgent.name} on ${data.userAgent.model}`;
|
||||
} else if (data.userAgent.name && data.userAgent.os) {
|
||||
clientName = `${data.userAgent.name} on ${data.userAgent.os}`;
|
||||
} else if (data.userAgent.name) {
|
||||
clientName = data.userAgent.name;
|
||||
}
|
||||
}
|
||||
|
||||
const createdAt = parseISO(data.createdAt);
|
||||
const finishedAt = data.finishedAt ? parseISO(data.finishedAt) : undefined;
|
||||
const lastActiveAt = data.lastActiveAt
|
||||
@ -95,6 +112,7 @@ const CompatSession: React.FC<{
|
||||
createdAt={createdAt}
|
||||
finishedAt={finishedAt}
|
||||
clientName={clientName}
|
||||
deviceType={data.userAgent?.deviceType}
|
||||
lastActiveIp={data.lastActiveIp || undefined}
|
||||
lastActiveAt={lastActiveAt}
|
||||
>
|
||||
|
@ -16,9 +16,8 @@ import { parseISO } from "date-fns";
|
||||
import { useMutation } from "urql";
|
||||
|
||||
import { FragmentType, graphql, useFragment } from "../gql";
|
||||
import { Oauth2ApplicationType } from "../gql/graphql";
|
||||
import { DeviceType, Oauth2ApplicationType } from "../gql/graphql";
|
||||
import { getDeviceIdFromScope } from "../utils/deviceIdFromScope";
|
||||
import { DeviceType } from "../utils/parseUserAgent";
|
||||
|
||||
import { Session } from "./Session";
|
||||
import EndSessionButton from "./Session/EndSessionButton";
|
||||
@ -31,6 +30,14 @@ export const FRAGMENT = graphql(/* GraphQL */ `
|
||||
finishedAt
|
||||
lastActiveIp
|
||||
lastActiveAt
|
||||
|
||||
userAgent {
|
||||
model
|
||||
os
|
||||
osVersion
|
||||
deviceType
|
||||
}
|
||||
|
||||
client {
|
||||
id
|
||||
clientId
|
||||
@ -57,7 +64,7 @@ const getDeviceTypeFromClientAppType = (
|
||||
appType?: Oauth2ApplicationType | null,
|
||||
): DeviceType => {
|
||||
if (appType === Oauth2ApplicationType.Web) {
|
||||
return DeviceType.Web;
|
||||
return DeviceType.Pc;
|
||||
}
|
||||
if (appType === Oauth2ApplicationType.Native) {
|
||||
return DeviceType.Mobile;
|
||||
@ -85,9 +92,17 @@ const OAuth2Session: React.FC<Props> = ({ session }) => {
|
||||
? parseISO(data.lastActiveAt)
|
||||
: undefined;
|
||||
|
||||
const deviceType = getDeviceTypeFromClientAppType(
|
||||
data.client.applicationType,
|
||||
);
|
||||
const deviceType =
|
||||
(data.userAgent?.deviceType === DeviceType.Unknown
|
||||
? null
|
||||
: data.userAgent?.deviceType) ??
|
||||
getDeviceTypeFromClientAppType(data.client.applicationType);
|
||||
|
||||
let clientName = data.client.clientName || data.client.clientId || undefined;
|
||||
|
||||
if (data.userAgent?.model) {
|
||||
clientName = `${clientName} on ${data.userAgent.model}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Session
|
||||
@ -95,7 +110,7 @@ const OAuth2Session: React.FC<Props> = ({ session }) => {
|
||||
name={deviceId}
|
||||
createdAt={createdAt}
|
||||
finishedAt={finishedAt}
|
||||
clientName={data.client.clientName || data.client.clientId || undefined}
|
||||
clientName={clientName}
|
||||
clientLogoUri={data.client.logoUri || undefined}
|
||||
deviceType={deviceType}
|
||||
lastActiveIp={data.lastActiveIp || undefined}
|
||||
|
@ -14,7 +14,7 @@
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
import { DeviceType } from "../../utils/parseUserAgent";
|
||||
import { DeviceType } from "../../gql/graphql";
|
||||
|
||||
import DeviceTypeIcon from "./DeviceTypeIcon";
|
||||
|
||||
@ -30,9 +30,9 @@ const meta = {
|
||||
control: "select",
|
||||
options: [
|
||||
DeviceType.Unknown,
|
||||
DeviceType.Desktop,
|
||||
DeviceType.Pc,
|
||||
DeviceType.Mobile,
|
||||
DeviceType.Web,
|
||||
DeviceType.Tablet,
|
||||
],
|
||||
},
|
||||
},
|
||||
@ -43,9 +43,9 @@ type Story = StoryObj<typeof DeviceTypeIcon>;
|
||||
|
||||
export const Unknown: Story = {};
|
||||
|
||||
export const Desktop: Story = {
|
||||
export const Pc: Story = {
|
||||
args: {
|
||||
deviceType: DeviceType.Desktop,
|
||||
deviceType: DeviceType.Pc,
|
||||
},
|
||||
};
|
||||
export const Mobile: Story = {
|
||||
@ -53,8 +53,8 @@ export const Mobile: Story = {
|
||||
deviceType: DeviceType.Mobile,
|
||||
},
|
||||
};
|
||||
export const Web: Story = {
|
||||
export const Tablet: Story = {
|
||||
args: {
|
||||
deviceType: DeviceType.Web,
|
||||
deviceType: DeviceType.Tablet,
|
||||
},
|
||||
};
|
||||
|
@ -18,7 +18,7 @@ import { composeStory } from "@storybook/react";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
|
||||
import Meta, { Unknown, Desktop, Mobile, Web } from "./DeviceTypeIcon.stories";
|
||||
import Meta, { Unknown, Pc, Mobile, Tablet } from "./DeviceTypeIcon.stories";
|
||||
|
||||
describe("<DeviceTypeIcon />", () => {
|
||||
afterEach(cleanup);
|
||||
@ -33,13 +33,13 @@ describe("<DeviceTypeIcon />", () => {
|
||||
const { container } = render(<Component />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders desktop device type", () => {
|
||||
const Component = composeStory(Desktop, Meta);
|
||||
it("renders pc device type", () => {
|
||||
const Component = composeStory(Pc, Meta);
|
||||
const { container } = render(<Component />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("renders Web device type", () => {
|
||||
const Component = composeStory(Web, Meta);
|
||||
it("renders tablet device type", () => {
|
||||
const Component = composeStory(Tablet, Meta);
|
||||
const { container } = render(<Component />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
@ -19,7 +19,7 @@ import IconBrowser from "@vector-im/compound-design-tokens/icons/web-browser.svg
|
||||
import { FunctionComponent, SVGProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { DeviceType } from "../../utils/parseUserAgent";
|
||||
import { DeviceType } from "../../gql/graphql";
|
||||
|
||||
import styles from "./DeviceTypeIcon.module.css";
|
||||
|
||||
@ -28,9 +28,9 @@ const deviceTypeToIcon: Record<
|
||||
FunctionComponent<SVGProps<SVGSVGElement> & { title?: string | undefined }>
|
||||
> = {
|
||||
[DeviceType.Unknown]: IconUnknown,
|
||||
[DeviceType.Desktop]: IconComputer,
|
||||
[DeviceType.Pc]: IconComputer,
|
||||
[DeviceType.Mobile]: IconMobile,
|
||||
[DeviceType.Web]: IconBrowser,
|
||||
[DeviceType.Tablet]: IconBrowser,
|
||||
};
|
||||
|
||||
const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({
|
||||
@ -42,9 +42,9 @@ const DeviceTypeIcon: React.FC<{ deviceType: DeviceType }> = ({
|
||||
|
||||
const deviceTypeToLabel: Record<DeviceType, string> = {
|
||||
[DeviceType.Unknown]: t("frontend.device_type_icon_label.unknown"),
|
||||
[DeviceType.Desktop]: t("frontend.device_type_icon_label.desktop"),
|
||||
[DeviceType.Pc]: t("frontend.device_type_icon_label.pc"),
|
||||
[DeviceType.Mobile]: t("frontend.device_type_icon_label.mobile"),
|
||||
[DeviceType.Web]: t("frontend.device_type_icon_label.web"),
|
||||
[DeviceType.Tablet]: t("frontend.device_type_icon_label.tablet"),
|
||||
};
|
||||
|
||||
const label = deviceTypeToLabel[deviceType];
|
||||
|
@ -16,7 +16,7 @@ import { Link } from "@tanstack/react-router";
|
||||
import { H6, Text, Badge } from "@vector-im/compound-web";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
import { DeviceType } from "../../utils/parseUserAgent";
|
||||
import { DeviceType } from "../../gql/graphql";
|
||||
import Block from "../Block";
|
||||
import DateTime from "../DateTime";
|
||||
|
||||
|
@ -1,41 +1,5 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`<DeviceTypeIcon /> > renders Web device type 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
aria-label="Web"
|
||||
class="_icon_e677aa"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 20c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 18V6c0-.55.196-1.02.587-1.412A1.926 1.926 0 0 1 4 4h16c.55 0 1.02.196 1.413.588.391.391.587.862.587 1.412v12c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 20H4Zm0-2h16V8H4v10Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<DeviceTypeIcon /> > renders desktop device type 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
aria-label="Desktop"
|
||||
class="_icon_e677aa"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 18c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 16V5c0-.55.196-1.02.587-1.413A1.926 1.926 0 0 1 4 3h16c.55 0 1.02.196 1.413.587.39.393.587.863.587 1.413v11c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 18H4Zm0-2h16V5H4v11Zm-2 5a.967.967 0 0 1-.712-.288A.968.968 0 0 1 1 20c0-.283.096-.52.288-.712A.967.967 0 0 1 2 19h20c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.712A.968.968 0 0 1 22 21H2Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<DeviceTypeIcon /> > renders mobile device type 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
@ -54,6 +18,42 @@ exports[`<DeviceTypeIcon /> > renders mobile device type 1`] = `
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<DeviceTypeIcon /> > renders pc device type 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
aria-label="Computer"
|
||||
class="_icon_e677aa"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 18c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 16V5c0-.55.196-1.02.587-1.413A1.926 1.926 0 0 1 4 3h16c.55 0 1.02.196 1.413.587.39.393.587.863.587 1.413v11c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 18H4Zm0-2h16V5H4v11Zm-2 5a.967.967 0 0 1-.712-.288A.968.968 0 0 1 1 20c0-.283.096-.52.288-.712A.967.967 0 0 1 2 19h20c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.712A.968.968 0 0 1 22 21H2Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<DeviceTypeIcon /> > renders tablet device type 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
aria-label="Tablet"
|
||||
class="_icon_e677aa"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 20c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 18V6c0-.55.196-1.02.587-1.412A1.926 1.926 0 0 1 4 4h16c.55 0 1.02.196 1.413.588.391.391.587.862.587 1.412v12c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 20H4Zm0-2h16V8H4v10Z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<DeviceTypeIcon /> > renders unknown device type 1`] = `
|
||||
<div>
|
||||
<svg
|
||||
|
@ -17,10 +17,6 @@ import { parseISO } from "date-fns";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { FragmentType, graphql, useFragment } from "../../gql";
|
||||
import {
|
||||
parseUserAgent,
|
||||
sessionNameFromDeviceInformation,
|
||||
} from "../../utils/parseUserAgent";
|
||||
import BlockList from "../BlockList/BlockList";
|
||||
import { useEndBrowserSession } from "../BrowserSession";
|
||||
import DateTime from "../DateTime";
|
||||
@ -36,7 +32,11 @@ const FRAGMENT = graphql(/* GraphQL */ `
|
||||
id
|
||||
createdAt
|
||||
finishedAt
|
||||
userAgent
|
||||
userAgent {
|
||||
name
|
||||
model
|
||||
os
|
||||
}
|
||||
lastActiveIp
|
||||
lastActiveAt
|
||||
lastAuthentication {
|
||||
@ -61,9 +61,16 @@ const BrowserSessionDetail: React.FC<Props> = ({ session, isCurrent }) => {
|
||||
|
||||
const onSessionEnd = useEndBrowserSession(data.id, isCurrent);
|
||||
|
||||
const deviceInformation = parseUserAgent(data.userAgent || undefined);
|
||||
const sessionName =
|
||||
sessionNameFromDeviceInformation(deviceInformation) || "Browser session";
|
||||
let sessionName = "Browser session";
|
||||
if (data.userAgent) {
|
||||
if (data.userAgent.model && data.userAgent.name) {
|
||||
sessionName = `${data.userAgent.name} on ${data.userAgent.model}`;
|
||||
} else if (data.userAgent.name && data.userAgent.os) {
|
||||
sessionName = `${data.userAgent.name} on ${data.userAgent.os}`;
|
||||
} else if (data.userAgent.name) {
|
||||
sessionName = data.userAgent.name;
|
||||
}
|
||||
}
|
||||
|
||||
const finishedAt = data.finishedAt
|
||||
? [
|
||||
|
@ -35,6 +35,11 @@ export const FRAGMENT = graphql(/* GraphQL */ `
|
||||
finishedAt
|
||||
lastActiveIp
|
||||
lastActiveAt
|
||||
userAgent {
|
||||
name
|
||||
os
|
||||
model
|
||||
}
|
||||
ssoLogin {
|
||||
id
|
||||
redirectUri
|
||||
@ -102,7 +107,7 @@ const CompatSessionDetail: React.FC<Props> = ({ session }) => {
|
||||
if (data.ssoLogin?.redirectUri) {
|
||||
clientDetails.push({
|
||||
label: t("frontend.compat_session_detail.name"),
|
||||
value: simplifyUrl(data.ssoLogin.redirectUri),
|
||||
value: data.userAgent?.name ?? simplifyUrl(data.ssoLogin.redirectUri),
|
||||
});
|
||||
clientDetails.push({
|
||||
label: t("frontend.session.uri_label"),
|
||||
|
@ -5,7 +5,7 @@ exports[`<OAuth2Session /> > renders a finished session 1`] = `
|
||||
className="_block_17898c _session_634806"
|
||||
>
|
||||
<svg
|
||||
aria-label="Web"
|
||||
aria-label="Computer"
|
||||
className="_icon_e677aa"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
@ -14,7 +14,7 @@ exports[`<OAuth2Session /> > renders a finished session 1`] = `
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M4 20c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 18V6c0-.55.196-1.02.587-1.412A1.926 1.926 0 0 1 4 4h16c.55 0 1.02.196 1.413.588.391.391.587.862.587 1.412v12c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 20H4Zm0-2h16V8H4v10Z"
|
||||
d="M4 18c-.55 0-1.02-.196-1.413-.587A1.926 1.926 0 0 1 2 16V5c0-.55.196-1.02.587-1.413A1.926 1.926 0 0 1 4 3h16c.55 0 1.02.196 1.413.587.39.393.587.863.587 1.413v11c0 .55-.196 1.02-.587 1.413A1.926 1.926 0 0 1 20 18H4Zm0-2h16V5H4v11Zm-2 5a.967.967 0 0 1-.712-.288A.968.968 0 0 1 1 20c0-.283.096-.52.288-.712A.967.967 0 0 1 2 19h20c.283 0 .52.096.712.288.192.191.288.429.288.712s-.096.52-.288.712A.968.968 0 0 1 22 21H2Z"
|
||||
/>
|
||||
</svg>
|
||||
<div
|
||||
|
@ -13,23 +13,23 @@ import { TypedDocumentNode as DocumentNode } from "@graphql-typed-document-node/
|
||||
* Therefore it is highly recommended to use the babel or swc plugin for production.
|
||||
*/
|
||||
const documents = {
|
||||
"\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n":
|
||||
"\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n":
|
||||
types.BrowserSession_SessionFragmentDoc,
|
||||
"\n mutation EndBrowserSession($id: ID!) {\n endBrowserSession(input: { browserSessionId: $id }) {\n status\n browserSession {\n id\n ...BrowserSession_session\n }\n }\n }\n":
|
||||
types.EndBrowserSessionDocument,
|
||||
"\n fragment OAuth2Client_detail on Oauth2Client {\n id\n clientId\n clientName\n clientUri\n logoUri\n tosUri\n policyUri\n redirectUris\n }\n":
|
||||
types.OAuth2Client_DetailFragmentDoc,
|
||||
"\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ssoLogin {\n id\n redirectUri\n }\n }\n":
|
||||
"\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n":
|
||||
types.CompatSession_SessionFragmentDoc,
|
||||
"\n mutation EndCompatSession($id: ID!) {\n endCompatSession(input: { compatSessionId: $id }) {\n status\n compatSession {\n id\n finishedAt\n }\n }\n }\n":
|
||||
types.EndCompatSessionDocument,
|
||||
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n":
|
||||
"\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n userAgent {\n model\n os\n osVersion\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n":
|
||||
types.OAuth2Session_SessionFragmentDoc,
|
||||
"\n mutation EndOAuth2Session($id: ID!) {\n endOauth2Session(input: { oauth2SessionId: $id }) {\n status\n oauth2Session {\n id\n ...OAuth2Session_session\n }\n }\n }\n":
|
||||
types.EndOAuth2SessionDocument,
|
||||
"\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n":
|
||||
"\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n":
|
||||
types.BrowserSession_DetailFragmentDoc,
|
||||
"\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ssoLogin {\n id\n redirectUri\n }\n }\n":
|
||||
"\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n name\n os\n model\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n":
|
||||
types.CompatSession_DetailFragmentDoc,
|
||||
"\n fragment OAuth2Session_detail on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n clientUri\n logoUri\n }\n }\n":
|
||||
types.OAuth2Session_DetailFragmentDoc,
|
||||
@ -83,8 +83,6 @@ const documents = {
|
||||
types.VerifyEmailQueryDocument,
|
||||
"\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n":
|
||||
types.AllowCrossSigningResetDocument,
|
||||
"\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n":
|
||||
types.CurrentViewerSessionQueryDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -105,8 +103,8 @@ export function graphql(source: string): unknown;
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(
|
||||
source: "\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n",
|
||||
): (typeof documents)["\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n"];
|
||||
source: "\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n",
|
||||
): (typeof documents)["\n fragment BrowserSession_session on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@ -123,8 +121,8 @@ export function graphql(
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(
|
||||
source: "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ssoLogin {\n id\n redirectUri\n }\n }\n",
|
||||
): (typeof documents)["\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ssoLogin {\n id\n redirectUri\n }\n }\n"];
|
||||
source: "\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n",
|
||||
): (typeof documents)["\n fragment CompatSession_session on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n raw\n name\n os\n model\n deviceType\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@ -135,8 +133,8 @@ export function graphql(
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(
|
||||
source: "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n",
|
||||
): (typeof documents)["\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n"];
|
||||
source: "\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n userAgent {\n model\n os\n osVersion\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n",
|
||||
): (typeof documents)["\n fragment OAuth2Session_session on Oauth2Session {\n id\n scope\n createdAt\n finishedAt\n lastActiveIp\n lastActiveAt\n\n userAgent {\n model\n os\n osVersion\n deviceType\n }\n\n client {\n id\n clientId\n clientName\n applicationType\n logoUri\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@ -147,14 +145,14 @@ export function graphql(
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(
|
||||
source: "\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n",
|
||||
): (typeof documents)["\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n"];
|
||||
source: "\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n",
|
||||
): (typeof documents)["\n fragment BrowserSession_detail on BrowserSession {\n id\n createdAt\n finishedAt\n userAgent {\n name\n model\n os\n }\n lastActiveIp\n lastActiveAt\n lastAuthentication {\n id\n createdAt\n }\n user {\n id\n username\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(
|
||||
source: "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ssoLogin {\n id\n redirectUri\n }\n }\n",
|
||||
): (typeof documents)["\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n ssoLogin {\n id\n redirectUri\n }\n }\n"];
|
||||
source: "\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n name\n os\n model\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n",
|
||||
): (typeof documents)["\n fragment CompatSession_detail on CompatSession {\n id\n createdAt\n deviceId\n finishedAt\n lastActiveIp\n lastActiveAt\n userAgent {\n name\n os\n model\n }\n ssoLogin {\n id\n redirectUri\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
@ -311,12 +309,6 @@ export function graphql(
|
||||
export function graphql(
|
||||
source: "\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n",
|
||||
): (typeof documents)["\n mutation AllowCrossSigningReset($userId: ID!) {\n allowUserCrossSigningReset(input: { userId: $userId }) {\n user {\n id\n }\n }\n }\n"];
|
||||
/**
|
||||
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
|
||||
*/
|
||||
export function graphql(
|
||||
source: "\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n",
|
||||
): (typeof documents)["\n query CurrentViewerSessionQuery {\n viewerSession {\n __typename\n ... on BrowserSession {\n id\n }\n }\n }\n"];
|
||||
|
||||
export function graphql(source: string) {
|
||||
return (documents as any)[source] ?? {};
|
||||
|
@ -179,8 +179,8 @@ export type BrowserSession = CreationEvent &
|
||||
state: SessionState;
|
||||
/** The user logged in this session. */
|
||||
user: User;
|
||||
/** The user-agent string with which the session was created. */
|
||||
userAgent?: Maybe<Scalars["String"]["output"]>;
|
||||
/** The user-agent with which the session was created. */
|
||||
userAgent?: Maybe<UserAgent>;
|
||||
};
|
||||
|
||||
/** A browser session represents a logged in user in a browser. */
|
||||
@ -241,6 +241,8 @@ export type CompatSession = CreationEvent &
|
||||
state: SessionState;
|
||||
/** The user authorized for this session. */
|
||||
user: User;
|
||||
/** The user-agent with which the session was created. */
|
||||
userAgent?: Maybe<UserAgent>;
|
||||
};
|
||||
|
||||
export type CompatSessionConnection = {
|
||||
@ -343,6 +345,18 @@ export type CreationEvent = {
|
||||
createdAt: Scalars["DateTime"]["output"];
|
||||
};
|
||||
|
||||
/** The type of a user agent */
|
||||
export enum DeviceType {
|
||||
/** A mobile phone. Can also sometimes be a tablet. */
|
||||
Mobile = "MOBILE",
|
||||
/** A personal computer, laptop or desktop */
|
||||
Pc = "PC",
|
||||
/** A tablet */
|
||||
Tablet = "TABLET",
|
||||
/** Unknown device type */
|
||||
Unknown = "UNKNOWN",
|
||||
}
|
||||
|
||||
/** The input of the `endBrowserSession` mutation. */
|
||||
export type EndBrowserSessionInput = {
|
||||
/** The ID of the session to end. */
|
||||
@ -617,6 +631,8 @@ export type Oauth2Session = CreationEvent &
|
||||
state: SessionState;
|
||||
/** User authorized for this session. */
|
||||
user?: Maybe<User>;
|
||||
/** The user-agent with which the session was created. */
|
||||
userAgent?: Maybe<UserAgent>;
|
||||
};
|
||||
|
||||
export type Oauth2SessionConnection = {
|
||||
@ -1058,6 +1074,25 @@ export type UserUpstreamOauth2LinksArgs = {
|
||||
last?: InputMaybe<Scalars["Int"]["input"]>;
|
||||
};
|
||||
|
||||
/** A parsed user agent string */
|
||||
export type UserAgent = {
|
||||
__typename?: "UserAgent";
|
||||
/** The device type */
|
||||
deviceType: DeviceType;
|
||||
/** The device model */
|
||||
model?: Maybe<Scalars["String"]["output"]>;
|
||||
/** The name of the browser */
|
||||
name?: Maybe<Scalars["String"]["output"]>;
|
||||
/** The operating system name */
|
||||
os?: Maybe<Scalars["String"]["output"]>;
|
||||
/** The operating system version */
|
||||
osVersion?: Maybe<Scalars["String"]["output"]>;
|
||||
/** The user agent string */
|
||||
raw: Scalars["String"]["output"];
|
||||
/** The version of the browser */
|
||||
version?: Maybe<Scalars["String"]["output"]>;
|
||||
};
|
||||
|
||||
/** A user email address */
|
||||
export type UserEmail = CreationEvent &
|
||||
Node & {
|
||||
@ -1144,9 +1179,16 @@ export type BrowserSession_SessionFragment = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
finishedAt?: string | null;
|
||||
userAgent?: string | null;
|
||||
lastActiveIp?: string | null;
|
||||
lastActiveAt?: string | null;
|
||||
userAgent?: {
|
||||
__typename?: "UserAgent";
|
||||
raw: string;
|
||||
name?: string | null;
|
||||
os?: string | null;
|
||||
model?: string | null;
|
||||
deviceType: DeviceType;
|
||||
} | null;
|
||||
lastAuthentication?: {
|
||||
__typename?: "Authentication";
|
||||
id: string;
|
||||
@ -1193,6 +1235,14 @@ export type CompatSession_SessionFragment = {
|
||||
finishedAt?: string | null;
|
||||
lastActiveIp?: string | null;
|
||||
lastActiveAt?: string | null;
|
||||
userAgent?: {
|
||||
__typename?: "UserAgent";
|
||||
raw: string;
|
||||
name?: string | null;
|
||||
os?: string | null;
|
||||
model?: string | null;
|
||||
deviceType: DeviceType;
|
||||
} | null;
|
||||
ssoLogin?: {
|
||||
__typename?: "CompatSsoLogin";
|
||||
id: string;
|
||||
@ -1225,6 +1275,13 @@ export type OAuth2Session_SessionFragment = {
|
||||
finishedAt?: string | null;
|
||||
lastActiveIp?: string | null;
|
||||
lastActiveAt?: string | null;
|
||||
userAgent?: {
|
||||
__typename?: "UserAgent";
|
||||
model?: string | null;
|
||||
os?: string | null;
|
||||
osVersion?: string | null;
|
||||
deviceType: DeviceType;
|
||||
} | null;
|
||||
client: {
|
||||
__typename?: "Oauth2Client";
|
||||
id: string;
|
||||
@ -1259,9 +1316,14 @@ export type BrowserSession_DetailFragment = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
finishedAt?: string | null;
|
||||
userAgent?: string | null;
|
||||
lastActiveIp?: string | null;
|
||||
lastActiveAt?: string | null;
|
||||
userAgent?: {
|
||||
__typename?: "UserAgent";
|
||||
name?: string | null;
|
||||
model?: string | null;
|
||||
os?: string | null;
|
||||
} | null;
|
||||
lastAuthentication?: {
|
||||
__typename?: "Authentication";
|
||||
id: string;
|
||||
@ -1278,6 +1340,12 @@ export type CompatSession_DetailFragment = {
|
||||
finishedAt?: string | null;
|
||||
lastActiveIp?: string | null;
|
||||
lastActiveAt?: string | null;
|
||||
userAgent?: {
|
||||
__typename?: "UserAgent";
|
||||
name?: string | null;
|
||||
os?: string | null;
|
||||
model?: string | null;
|
||||
} | null;
|
||||
ssoLogin?: {
|
||||
__typename?: "CompatSsoLogin";
|
||||
id: string;
|
||||
@ -1736,18 +1804,6 @@ export type AllowCrossSigningResetMutation = {
|
||||
};
|
||||
};
|
||||
|
||||
export type CurrentViewerSessionQueryQueryVariables = Exact<{
|
||||
[key: string]: never;
|
||||
}>;
|
||||
|
||||
export type CurrentViewerSessionQueryQuery = {
|
||||
__typename?: "Query";
|
||||
viewerSession:
|
||||
| { __typename: "Anonymous" }
|
||||
| { __typename: "BrowserSession"; id: string }
|
||||
| { __typename: "Oauth2Session" };
|
||||
};
|
||||
|
||||
export const BrowserSession_SessionFragmentDoc = {
|
||||
kind: "Document",
|
||||
definitions: [
|
||||
@ -1764,7 +1820,20 @@ export const BrowserSession_SessionFragmentDoc = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "id" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "createdAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "userAgent" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "raw" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "name" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
@ -1828,6 +1897,20 @@ export const CompatSession_SessionFragmentDoc = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "raw" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "name" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "ssoLogin" },
|
||||
@ -1863,6 +1946,19 @@ export const OAuth2Session_SessionFragmentDoc = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "osVersion" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "client" },
|
||||
@ -1901,7 +1997,18 @@ export const BrowserSession_DetailFragmentDoc = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "id" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "createdAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "userAgent" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "name" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
@ -1950,6 +2057,18 @@ export const CompatSession_DetailFragmentDoc = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "name" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "ssoLogin" },
|
||||
@ -2337,7 +2456,20 @@ export const EndBrowserSessionDocument = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "id" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "createdAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "userAgent" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "raw" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "name" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
@ -2512,6 +2644,19 @@ export const EndOAuth2SessionDocument = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "osVersion" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "client" },
|
||||
@ -3518,6 +3663,18 @@ export const SessionDetailQueryDocument = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "name" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "ssoLogin" },
|
||||
@ -3578,7 +3735,18 @@ export const SessionDetailQueryDocument = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "id" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "createdAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "userAgent" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "name" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
@ -3833,7 +4001,20 @@ export const BrowserSessionListDocument = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "id" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "createdAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "userAgent" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "raw" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "name" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
@ -4151,6 +4332,20 @@ export const AppSessionsListQueryDocument = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "raw" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "name" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "ssoLogin" },
|
||||
@ -4181,6 +4376,19 @@ export const AppSessionsListQueryDocument = {
|
||||
{ kind: "Field", name: { kind: "Name", value: "finishedAt" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveIp" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "lastActiveAt" } },
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "userAgent" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "model" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "os" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "osVersion" } },
|
||||
{ kind: "Field", name: { kind: "Name", value: "deviceType" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "client" },
|
||||
@ -4639,44 +4847,3 @@ export const AllowCrossSigningResetDocument = {
|
||||
AllowCrossSigningResetMutation,
|
||||
AllowCrossSigningResetMutationVariables
|
||||
>;
|
||||
export const CurrentViewerSessionQueryDocument = {
|
||||
kind: "Document",
|
||||
definitions: [
|
||||
{
|
||||
kind: "OperationDefinition",
|
||||
operation: "query",
|
||||
name: { kind: "Name", value: "CurrentViewerSessionQuery" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{
|
||||
kind: "Field",
|
||||
name: { kind: "Name", value: "viewerSession" },
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "__typename" } },
|
||||
{
|
||||
kind: "InlineFragment",
|
||||
typeCondition: {
|
||||
kind: "NamedType",
|
||||
name: { kind: "Name", value: "BrowserSession" },
|
||||
},
|
||||
selectionSet: {
|
||||
kind: "SelectionSet",
|
||||
selections: [
|
||||
{ kind: "Field", name: { kind: "Name", value: "id" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as unknown as DocumentNode<
|
||||
CurrentViewerSessionQueryQuery,
|
||||
CurrentViewerSessionQueryQueryVariables
|
||||
>;
|
||||
|
@ -413,8 +413,9 @@ export default {
|
||||
{
|
||||
name: "userAgent",
|
||||
type: {
|
||||
kind: "SCALAR",
|
||||
name: "Any",
|
||||
kind: "OBJECT",
|
||||
name: "UserAgent",
|
||||
ofType: null,
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
@ -628,6 +629,15 @@ export default {
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
name: "userAgent",
|
||||
type: {
|
||||
kind: "OBJECT",
|
||||
name: "UserAgent",
|
||||
ofType: null,
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
],
|
||||
interfaces: [
|
||||
{
|
||||
@ -1741,6 +1751,15 @@ export default {
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
name: "userAgent",
|
||||
type: {
|
||||
kind: "OBJECT",
|
||||
name: "UserAgent",
|
||||
ofType: null,
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
],
|
||||
interfaces: [
|
||||
{
|
||||
@ -3133,6 +3152,75 @@ export default {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
kind: "OBJECT",
|
||||
name: "UserAgent",
|
||||
fields: [
|
||||
{
|
||||
name: "deviceType",
|
||||
type: {
|
||||
kind: "NON_NULL",
|
||||
ofType: {
|
||||
kind: "SCALAR",
|
||||
name: "Any",
|
||||
},
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
name: "model",
|
||||
type: {
|
||||
kind: "SCALAR",
|
||||
name: "Any",
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
type: {
|
||||
kind: "SCALAR",
|
||||
name: "Any",
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
name: "os",
|
||||
type: {
|
||||
kind: "SCALAR",
|
||||
name: "Any",
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
name: "osVersion",
|
||||
type: {
|
||||
kind: "SCALAR",
|
||||
name: "Any",
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
name: "raw",
|
||||
type: {
|
||||
kind: "NON_NULL",
|
||||
ofType: {
|
||||
kind: "SCALAR",
|
||||
name: "Any",
|
||||
},
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
name: "version",
|
||||
type: {
|
||||
kind: "SCALAR",
|
||||
name: "Any",
|
||||
},
|
||||
args: [],
|
||||
},
|
||||
],
|
||||
interfaces: [],
|
||||
},
|
||||
{
|
||||
kind: "OBJECT",
|
||||
name: "UserEmail",
|
||||
|
@ -90,10 +90,21 @@
|
||||
width: var(--cpd-space-10x);
|
||||
}
|
||||
|
||||
& .name {
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
letter-spacing: var(--cpd-font-letter-spacing-body-md);
|
||||
color: var(--cpd-color-text-primary);
|
||||
& .lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& div:first-child {
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
letter-spacing: var(--cpd-font-letter-spacing-body-md);
|
||||
color: var(--cpd-color-text-primary);
|
||||
}
|
||||
|
||||
& div {
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
letter-spacing: var(--cpd-font-letter-spacing-body-sm);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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("");
|
||||
});
|
||||
});
|
@ -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;
|
||||
};
|
@ -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;
|
||||
};
|
@ -33,10 +33,46 @@ limitations under the License.
|
||||
<h1 class="title">Allow access to your account?</h1>
|
||||
|
||||
<div class="consent-device-card">
|
||||
<div class="device" {%- if grant.user_agent %} title="{{ grant.user_agent }}"{% endif %}>
|
||||
{{ icon.web_browser() }}
|
||||
{# TODO: Infer from the user agent #}
|
||||
<div class="name">Device</div>
|
||||
<div class="device" {%- if grant.user_agent %} title="{{ grant.user_agent.raw }}"{% endif %}>
|
||||
{% if grant.user_agent.device_type == "mobile" %}
|
||||
{{ icon.mobile() }}
|
||||
{% elif grant.user_agent.device_type == "tablet" %}
|
||||
{{ icon.web_browser() }}
|
||||
{% elif grant.user_agent.device_type == "pc" %}
|
||||
{{ icon.computer() }}
|
||||
{% else %}
|
||||
{{ icon.unknown_solid() }}
|
||||
{% endif %}
|
||||
|
||||
<div class="lines">
|
||||
{% if grant.user_agent.model %}
|
||||
<div>{{ grant.user_agent.model }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if grant.user_agent.os %}
|
||||
<div>
|
||||
{{ grant.user_agent.os }}
|
||||
{% if grant.user_agent.os_version %}
|
||||
{{ grant.user_agent.os_version }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# If we haven't detected a model, it's probably a browser, so show the name #}
|
||||
{% if not grant.user_agent.model and grant.user_agent.name %}
|
||||
<div>
|
||||
{{ grant.user_agent.name }}
|
||||
{% if grant.user_agent.version %}
|
||||
{{ grant.user_agent.version }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# If we couldn't detect anything, show a generic "Device" #}
|
||||
{% if not grant.user_agent.model and not grant.user_agent.name and not grant.user_agent.os %}
|
||||
<div>Device</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
{% if grant.ip_address %}
|
||||
|
@ -2,11 +2,11 @@
|
||||
"action": {
|
||||
"cancel": "Cancel",
|
||||
"@cancel": {
|
||||
"context": "pages/consent.html:72:11-29, pages/device_consent.html:94:13-31, pages/login.html:100:13-31, pages/policy_violation.html:52:13-31, pages/register.html:77:13-31"
|
||||
"context": "pages/consent.html:72:11-29, pages/device_consent.html:130:13-31, pages/login.html:100:13-31, pages/policy_violation.html:52:13-31, pages/register.html:77:13-31"
|
||||
},
|
||||
"continue": "Continue",
|
||||
"@continue": {
|
||||
"context": "pages/account/emails/add.html:45:26-46, pages/account/emails/verify.html:60:26-46, pages/consent.html:60:28-48, pages/device_consent.html:91:13-33, pages/device_link.html:50:26-46, pages/login.html:62:30-50, pages/reauth.html:40:28-48, pages/register.html:72:28-48, pages/sso.html:45:28-48"
|
||||
"context": "pages/account/emails/add.html:45:26-46, pages/account/emails/verify.html:60:26-46, pages/consent.html:60:28-48, pages/device_consent.html:127:13-33, pages/device_link.html:50:26-46, pages/login.html:62:30-50, pages/reauth.html:40:28-48, pages/register.html:72:28-48, pages/sso.html:45:28-48"
|
||||
},
|
||||
"create_account": "Create Account",
|
||||
"@create_account": {
|
||||
@ -18,7 +18,7 @@
|
||||
},
|
||||
"sign_out": "Sign out",
|
||||
"@sign_out": {
|
||||
"context": "pages/consent.html:68:28-48, pages/device_consent.html:103:30-50, pages/index.html:36:28-48, pages/policy_violation.html:46:28-48, pages/sso.html:53:28-48, pages/upstream_oauth2/link_mismatch.html:32:24-44, pages/upstream_oauth2/suggest_link.html:40:26-46"
|
||||
"context": "pages/consent.html:68:28-48, pages/device_consent.html:139:30-50, pages/index.html:36:28-48, pages/policy_violation.html:46:28-48, pages/sso.html:53:28-48, pages/upstream_oauth2/link_mismatch.html:32:24-44, pages/upstream_oauth2/suggest_link.html:40:26-46"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
@ -246,7 +246,7 @@
|
||||
},
|
||||
"not_you": "Not %(username)s?",
|
||||
"@not_you": {
|
||||
"context": "pages/consent.html:65:11-67, pages/device_consent.html:100:13-69, pages/sso.html:50:11-67",
|
||||
"context": "pages/consent.html:65:11-67, pages/device_consent.html:136:13-69, pages/sso.html:50:11-67",
|
||||
"description": "Suggestions for the user to log in as a different user"
|
||||
},
|
||||
"or_separator": "Or",
|
||||
|
Reference in New Issue
Block a user