From c42314edb54fa423672ca7cb60e83d62803e49c7 Mon Sep 17 00:00:00 2001 From: Scott Lamb Date: Wed, 1 Sep 2021 15:01:42 -0700 Subject: [PATCH] UI preferences: #153 #155 --- design/api.md | 63 ++++++++---- server/Cargo.lock | 2 + server/db/Cargo.toml | 2 + server/db/auth.rs | 88 +++++++++++++++- server/db/schema.sql | 6 +- server/db/upgrade/v6_to_v7.rs | 3 +- server/src/json.rs | 26 ++++- server/src/web.rs | 182 ++++++++++++++++++++-------------- ui/src/App.tsx | 6 +- ui/src/api.ts | 6 ++ ui/src/types.ts | 1 - 11 files changed, 280 insertions(+), 105 deletions(-) diff --git a/design/api.md b/design/api.md index e427c29..fde448a 100644 --- a/design/api.md +++ b/design/api.md @@ -21,6 +21,7 @@ Status: **current**. * [Request 1](#request-1) * [Request 2](#request-2) * [Request 3](#request-3) +* [`POST /api/users/`](#post-apiusersid) ## Objective @@ -42,7 +43,7 @@ All requests for JSON data should be sent with the header ### `POST /api/login` -The request should have an `application/json` body containing a dict with +The request should have an `application/json` body containing a JSON object with `username` and `password` keys. On successful authentication, the server will return an HTTP 204 (no content) @@ -81,11 +82,11 @@ Example request URI (with added whitespace between parameters): &cameraConfigs=true ``` -The `application/json` response will have a dict as follows: +The `application/json` response will have a JSON object as follows: * `timeZoneName`: the name of the IANA time zone the server is using to divide recordings into days as described further below. -* `cameras`: a list of cameras. Each is a dict as follows: +* `cameras`: a list of cameras. Each is a JSON object as follows: * `uuid`: in text format * `id`: an integer. The client doesn't ever need to send the id back in API requests, but camera ids are helpful to know when debugging @@ -93,12 +94,12 @@ The `application/json` response will have a dict as follows: * `shortName`: a short name (typically one or two words) * `description`: a longer description (typically a phrase or paragraph) * `config`: (only included if request parameter `cameraConfigs` is true) - a dictionary describing the configuration of the camera: + a JSON object describing the configuration of the camera: * `username` * `password` * `onvif_host` - * `streams`: a dict of stream type ("main" or "sub") to a dictionary - describing the stream: + * `streams`: a JSON object of stream type ("main" or "sub") to a JSON + object describing the stream: * `id`: an integer. The client doesn't ever need to send the id back in API requests, but stream ids are helpful to know when debugging by reading logs or directly examining the @@ -119,7 +120,7 @@ The `application/json` response will have a dict as follows: because it also includes the wasted portion of the final filesystem block allocated to each file. * `days`: (only included if request parameter `days` is true) - dictionary representing calendar days (in the server's time zone) + JSON object representing calendar days (in the server's time zone) with non-zero total duration of recordings for that day. Currently this includes uncommitted and growing recordings. This is likely to change in a future release for @@ -136,10 +137,10 @@ The `application/json` response will have a dict as follows: might be 23 hours or 25 hours during spring forward or fall back, respectively. * `config`: (only included if request parameter `cameraConfigs` is - true) a dictionary describing the configuration of the stream: + true) a JSON object describing the configuration of the stream: * `rtsp_url` -* `signals`: a list of all *signals* known to the server. Each is a dictionary - with the following properties: +* `signals`: a list of all *signals* known to the server. Each is a JSON + object with the following properties: * `id`: an integer identifier. * `shortName`: a unique, human-readable description of the signal * `cameras`: a map of associated cameras' UUIDs to the type of association: @@ -158,9 +159,14 @@ The `application/json` response will have a dict as follows: as in the [HTML specification](https://html.spec.whatwg.org/#colours). * `motion`: if present and true, directly associated cameras will be considered to have motion when this signal is in this state. -* `session`: if logged in, a dict with the following properties: - * `username` - * `csrf`: a cross-site request forgery token for use in `POST` requests. +* `user`: if authenticated, a JSON object: + * `name`: a human-readable name + * `id`: an integer + * `preferences`: a JSON object + * `session`: an object, present only if authenticated via session cookie. + (In the future, it will be possible to instead authenticate via uid over + a Unix domain socket.) + * `csrf`: a cross-site request forgery token for use in `POST` requests. Example response: @@ -239,9 +245,12 @@ Example response: } } ], - "session": { - "username": "slamb", - "csrf": "2DivvlnKUQ9JD4ao6YACBJm8XK4bFmOc" + "user": { + "id": 1, + "name": "slamb", + "session": { + "csrf": "2DivvlnKUQ9JD4ao6YACBJm8XK4bFmOc" + } } } ``` @@ -671,18 +680,18 @@ analytics client starts up and analyzes all video segments recorded since it last ran. These will specify beginning and end times. The request should have an `application/json` body describing the change to -make. It should be a dict with these attributes: +make. It should be a JSON object with these attributes: * `signalIds`: a list of signal ids to change. Must be sorted. * `states`: a list (one per `signalIds` entry) of states to set. -* `start`: the starting time of the change, as a dict of the form +* `start`: the starting time of the change, as a JSON object of the form `{'base': 'epoch', 'rel90k': t}` or `{'base': 'now', 'rel90k': t}`. In the `epoch` form, `rel90k` is 90 kHz units since 1970-01-01 00:00:00 UTC. In the `now` form, `rel90k` is relative to current time and may be negative. * `end`: the ending time of the change, in the same form as `start`. -The response will be an `application/json` body dict with the following +The response will be an `application/json` body JSON object with the following attributes: * `time90k`: the current time. When the request's `startTime90k` is absent @@ -765,6 +774,22 @@ Response: } ``` +## `POST /api/users/` + +Currently this request only allows updating the preferences for the +currently-authenticated user. This is likely to change. + +Expects a JSON object: + +* `update`: sets the provided fields +* `precondition`: forces the request to fail with HTTP status 412 + (Precondition failed) if the provided fields don't have the given value. + +Currently both objects support a single field, `preferences`, which should be +a JSON dictionary. + +Returns HTTP status 204 (No Content) on success. + [media-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-media-segments [init-segment]: https://w3c.github.io/media-source/isobmff-byte-stream-format.html#iso-init-segments [rfc-6381]: https://tools.ietf.org/html/rfc6381 diff --git a/server/Cargo.lock b/server/Cargo.lock index 3b87fa3..71bbfaa 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -1236,6 +1236,8 @@ dependencies = [ "protobuf-codegen-pure", "ring", "rusqlite", + "serde", + "serde_json", "smallvec", "tempfile", "time", diff --git a/server/db/Cargo.toml b/server/db/Cargo.toml index 0969261..10aaf2a 100644 --- a/server/db/Cargo.toml +++ b/server/db/Cargo.toml @@ -37,6 +37,8 @@ prettydiff = { git = "https://github.com/scottlamb/prettydiff", branch = "pr-upd protobuf = { git = "https://github.com/stepancheg/rust-protobuf" } ring = "0.16.2" rusqlite = "0.25.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" smallvec = "1.0" tempfile = "3.2.0" time = "0.1" diff --git a/server/db/auth.rs b/server/db/auth.rs index d92b0f6..99477d8 100644 --- a/server/db/auth.rs +++ b/server/db/auth.rs @@ -13,6 +13,7 @@ use log::info; use parking_lot::Mutex; use protobuf::Message; use ring::rand::{SecureRandom, SystemRandom}; +use rusqlite::types::FromSqlError; use rusqlite::{named_params, params, Connection, Transaction}; use std::collections::BTreeMap; use std::fmt; @@ -34,6 +35,32 @@ pub(crate) fn set_test_config() { )); } +#[derive(serde::Deserialize, serde::Serialize, Clone, Debug, Default, Eq, PartialEq)] +pub struct UserPreferences(serde_json::Map); + +impl rusqlite::types::FromSql for UserPreferences { + fn column_result(value: rusqlite::types::ValueRef) -> rusqlite::types::FromSqlResult { + Ok(Self(match value { + rusqlite::types::ValueRef::Null => serde_json::Map::default(), + rusqlite::types::ValueRef::Text(t) => { + serde_json::from_slice(t).map_err(|e| FromSqlError::Other(Box::new(e)))? + } + _ => return Err(FromSqlError::InvalidType), + })) + } +} + +impl rusqlite::types::ToSql for UserPreferences { + fn to_sql(&self) -> rusqlite::Result> { + if self.0.is_empty() { + return Ok(rusqlite::types::Null.into()); + } + Ok(serde_json::to_string(&self.0) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))? + .into()) + } +} + enum UserFlag { Disabled = 1, } @@ -48,6 +75,7 @@ pub struct User { pub password_failure_count: i64, pub unix_uid: Option, pub permissions: Permissions, + pub preferences: UserPreferences, /// True iff this `User` has changed since the last flush. /// Only a couple things are flushed lazily: `password_failure_count` and (on upgrade to a new @@ -62,6 +90,7 @@ impl User { username: self.username.clone(), flags: self.flags, set_password_hash: None, + set_preferences: None, unix_uid: self.unix_uid, permissions: self.permissions.clone(), } @@ -87,6 +116,7 @@ pub struct UserChange { pub username: String, pub flags: i32, set_password_hash: Option>, + set_preferences: Option, pub unix_uid: Option, pub permissions: Permissions, } @@ -98,6 +128,7 @@ impl UserChange { username, flags: 0, set_password_hash: None, + set_preferences: None, unix_uid: None, permissions: Permissions::default(), } @@ -112,6 +143,10 @@ impl UserChange { self.set_password_hash = Some(None); } + pub fn set_preferences(&mut self, preferences: UserPreferences) { + self.set_preferences = Some(preferences); + } + pub fn disable(&mut self) { self.flags |= UserFlag::Disabled as i32; } @@ -204,7 +239,7 @@ pub enum RevocationReason { #[derive(Debug, Default)] pub struct Session { - user_id: i32, + pub user_id: i32, flags: i32, // bitmask of SessionFlag enum values domain: Option>, description: Option, @@ -359,7 +394,8 @@ impl State { password_id, password_failure_count, unix_uid, - permissions + permissions, + preferences from user "#, @@ -382,6 +418,7 @@ impl State { unix_uid: row.get(6)?, dirty: false, permissions, + preferences: row.get(8)?, }, ); state.users_by_name.insert(name, id); @@ -417,7 +454,8 @@ impl State { password_failure_count = :password_failure_count, flags = :flags, unix_uid = :unix_uid, - permissions = :permissions + permissions = :permissions, + preferences = :preferences where id = :id "#, @@ -427,6 +465,10 @@ impl State { ::std::collections::btree_map::Entry::Vacant(_) => panic!("missing uid {}!", id), ::std::collections::btree_map::Entry::Occupied(e) => e, }; + let preferences = change.set_preferences.unwrap_or_else(|| { + let u = e.get(); + u.preferences.clone() + }); { let (phash, pid, pcount) = match change.set_password_hash.as_ref() { None => { @@ -448,6 +490,7 @@ impl State { ":unix_uid": &change.unix_uid, ":id": &id, ":permissions": &permissions, + ":preferences": &preferences, })?; } let u = e.into_mut(); @@ -460,14 +503,17 @@ impl State { u.flags = change.flags; u.unix_uid = change.unix_uid; u.permissions = change.permissions; + u.preferences = preferences; Ok(u) } fn add_user(&mut self, conn: &Connection, change: UserChange) -> Result<&User, Error> { let mut stmt = conn.prepare_cached( r#" - insert into user (username, password_hash, flags, unix_uid, permissions) - values (:username, :password_hash, :flags, :unix_uid, :permissions) + insert into user (username, password_hash, flags, unix_uid, permissions, + preferences) + values (:username, :password_hash, :flags, :unix_uid, :permissions, + :preferences) "#, )?; let password_hash = change.set_password_hash.unwrap_or(None); @@ -475,12 +521,14 @@ impl State { .permissions .write_to_bytes() .expect("proto3->vec is infallible"); + let preferences = change.set_preferences.unwrap_or_default(); stmt.execute(named_params! { ":username": &change.username[..], ":password_hash": &password_hash, ":flags": &change.flags, ":unix_uid": &change.unix_uid, ":permissions": &permissions, + ":preferences": &preferences, })?; let id = conn.last_insert_rowid() as i32; self.users_by_name.insert(change.username.clone(), id); @@ -499,6 +547,7 @@ impl State { unix_uid: change.unix_uid, dirty: false, permissions: change.permissions, + preferences, })) } @@ -1283,4 +1332,33 @@ mod tests { assert!(u.permissions.view_video); assert!(u.permissions.update_signals); } + + #[test] + fn preferences() { + testutil::init(); + let mut conn = Connection::open_in_memory().unwrap(); + db::init(&mut conn).unwrap(); + let mut state = State::init(&conn).unwrap(); + let mut change = UserChange::add_user("slamb".to_owned()); + let mut preferences = UserPreferences::default(); + preferences.0.insert("foo".to_string(), 42.into()); + change.set_preferences(preferences.clone()); + let u = state.apply(&conn, change).unwrap(); + assert_eq!(preferences, u.preferences); + let mut change = u.change(); + preferences.0.insert("bar".to_string(), 26.into()); + change.set_preferences(preferences.clone()); + let u = state.apply(&conn, change).unwrap(); + assert_eq!(preferences, u.preferences); + let uid = u.id; + + { + let tx = conn.transaction().unwrap(); + state.flush(&tx).unwrap(); + tx.commit().unwrap(); + } + let state = State::init(&conn).unwrap(); + let u = state.users_by_id().get(&uid).unwrap(); + assert_eq!(preferences, u.preferences); + } } diff --git a/server/db/schema.sql b/server/db/schema.sql index 828e12a..544f8a4 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -341,7 +341,11 @@ create table user ( -- Permissions available for newly created tokens or when authenticating via -- unix_uid above. A serialized "Permissions" protobuf. - permissions blob not null default X'' + permissions blob not null default X'', + + -- Preferences controlled by the user. A JSON object, or null to represent + -- the empty object. Can be returned and modified through the API. + preferences text ); -- A single session, whether for browser or robot use. diff --git a/server/db/upgrade/v6_to_v7.rs b/server/db/upgrade/v6_to_v7.rs index 077cf89..dd826b8 100644 --- a/server/db/upgrade/v6_to_v7.rs +++ b/server/db/upgrade/v6_to_v7.rs @@ -5,6 +5,7 @@ /// Upgrades a version 6 schema to a version 7 schema. use failure::Error; -pub fn run(_args: &super::Args, _tx: &rusqlite::Transaction) -> Result<(), Error> { +pub fn run(_args: &super::Args, tx: &rusqlite::Transaction) -> Result<(), Error> { + tx.execute_batch("alter table user add preferences text")?; Ok(()) } diff --git a/server/src/json.rs b/server/src/json.rs index d0f3267..867dad8 100644 --- a/server/src/json.rs +++ b/server/src/json.rs @@ -21,7 +21,7 @@ pub struct TopLevel<'a> { pub cameras: (&'a db::LockedDatabase, bool, bool), #[serde(skip_serializing_if = "Option::is_none")] - pub session: Option, + pub user: Option, #[serde(serialize_with = "TopLevel::serialize_signals")] pub signals: (&'a db::LockedDatabase, bool), @@ -33,8 +33,6 @@ pub struct TopLevel<'a> { #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct Session { - pub username: String, - #[serde(serialize_with = "Session::serialize_csrf")] pub csrf: SessionHash, } @@ -519,3 +517,25 @@ impl VideoSampleEntry { } } } + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ToplevelUser { + pub name: String, + pub id: i32, + pub preferences: db::auth::UserPreferences, + pub session: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PostUser { + pub update: Option, + pub precondition: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserSubset { + pub preferences: Option, +} diff --git a/server/src/web.rs b/server/src/web.rs index f071a90..05356e3 100644 --- a/server/src/web.rs +++ b/server/src/web.rs @@ -16,6 +16,7 @@ use fnv::FnvHashMap; use futures::stream::StreamExt; use futures::{future::Either, sink::SinkExt}; use http::header::{self, HeaderValue}; +use http::method::Method; use http::{status::StatusCode, Request, Response}; use http_serve::dir::FsDir; use hyper::body::Bytes; @@ -68,86 +69,79 @@ enum Path { Login, // "/api/login" Logout, // "/api/logout" Static, // (anything that doesn't start with "/api/") + User(i32), // "/api/users/" NotFound, } impl Path { fn decode(path: &str) -> Self { - if !path.starts_with("/api/") { - return Path::Static; - } - let path = &path["/api".len()..]; - if path == "/" { - return Path::TopLevel; - } + let path = match path.strip_prefix("/api/") { + Some(p) => p, + None => return Path::Static, + }; match path { - "/login" => return Path::Login, - "/logout" => return Path::Logout, - "/request" => return Path::Request, - "/signals" => return Path::Signals, + "" => return Path::TopLevel, + "login" => return Path::Login, + "logout" => return Path::Logout, + "request" => return Path::Request, + "signals" => return Path::Signals, _ => {} }; - if path.starts_with("/init/") { - let (debug, path) = if path.ends_with(".txt") { - (true, &path[0..path.len() - 4]) - } else { - (false, path) + if let Some(path) = path.strip_prefix("init/") { + let (debug, path) = match path.strip_suffix(".txt") { + Some(p) => (true, p), + None => (false, path), }; - if !path.ends_with(".mp4") { - return Path::NotFound; - } - let id_start = "/init/".len(); - let id_end = path.len() - ".mp4".len(); - if let Ok(id) = i32::from_str(&path[id_start..id_end]) { + let path = match path.strip_suffix(".mp4") { + Some(p) => p, + None => return Path::NotFound, + }; + if let Ok(id) = i32::from_str(&path) { return Path::InitSegment(id, debug); } return Path::NotFound; - } - if !path.starts_with("/cameras/") { - return Path::NotFound; - } - let path = &path["/cameras/".len()..]; - let slash = match path.find('/') { - None => { - return Path::NotFound; + } else if let Some(path) = path.strip_prefix("cameras/") { + let (uuid, path) = match path.split_once('/') { + Some(pair) => pair, + None => return Path::NotFound, + }; + + // TODO(slamb): require uuid to be in canonical format. + let uuid = match Uuid::parse_str(uuid) { + Ok(u) => u, + Err(_) => return Path::NotFound, + }; + + if path.is_empty() { + return Path::Camera(uuid); } - Some(s) => s, - }; - let uuid = &path[0..slash]; - let path = &path[slash + 1..]; - // TODO(slamb): require uuid to be in canonical format. - let uuid = match Uuid::parse_str(uuid) { - Ok(u) => u, - Err(_) => return Path::NotFound, - }; - - if path.is_empty() { - return Path::Camera(uuid); - } - - let slash = match path.find('/') { - None => { - return Path::NotFound; + let (type_, path) = match path.split_once('/') { + Some(pair) => pair, + None => return Path::NotFound, + }; + let type_ = match db::StreamType::parse(type_) { + None => { + return Path::NotFound; + } + Some(t) => t, + }; + match path { + "recordings" => Path::StreamRecordings(uuid, type_), + "view.mp4" => Path::StreamViewMp4(uuid, type_, false), + "view.mp4.txt" => Path::StreamViewMp4(uuid, type_, true), + "view.m4s" => Path::StreamViewMp4Segment(uuid, type_, false), + "view.m4s.txt" => Path::StreamViewMp4Segment(uuid, type_, true), + "live.m4s" => Path::StreamLiveMp4Segments(uuid, type_), + _ => Path::NotFound, } - Some(s) => s, - }; - let (type_, path) = path.split_at(slash); - - let type_ = match db::StreamType::parse(type_) { - None => { - return Path::NotFound; + } else if let Some(path) = path.strip_prefix("users/") { + if let Ok(id) = i32::from_str(path) { + return Path::User(id); } - Some(t) => t, - }; - match path { - "/recordings" => Path::StreamRecordings(uuid, type_), - "/view.mp4" => Path::StreamViewMp4(uuid, type_, false), - "/view.mp4.txt" => Path::StreamViewMp4(uuid, type_, true), - "/view.m4s" => Path::StreamViewMp4Segment(uuid, type_, false), - "/view.m4s.txt" => Path::StreamViewMp4Segment(uuid, type_, true), - "/live.m4s" => Path::StreamLiveMp4Segments(uuid, type_), - _ => Path::NotFound, + Path::NotFound + } else { + Path::NotFound } } } @@ -253,7 +247,7 @@ impl FromStr for Segments { struct Caller { permissions: db::Permissions, - session: Option, + user: Option, } type ResponseResult = Result, HttpError>; @@ -302,7 +296,7 @@ fn extract_sid(req: &Request) -> Option { /// deserialization. Keeping the bytes allows the caller to use a `Deserialize` /// that borrows from the bytes. async fn extract_json_body(req: &mut Request) -> Result { - if *req.method() != http::method::Method::POST { + if *req.method() != Method::POST { return Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()); } let correct_mime_type = match req.headers().get(header::CONTENT_TYPE) { @@ -560,7 +554,6 @@ impl Service { } async fn signals(&self, req: Request, caller: Caller) -> ResponseResult { - use http::method::Method; match *req.method() { Method::POST => self.post_signals(req, caller).await, Method::GET | Method::HEAD => self.get_signals(&req), @@ -614,6 +607,10 @@ impl Service { self.signals(req, caller).await?, ), Path::Static => (CacheControl::None, self.static_file(req).await?), + Path::User(id) => ( + CacheControl::PrivateDynamic, + self.user(req, caller, id).await?, + ), }; match cache { CacheControl::PrivateStatic => { @@ -683,7 +680,7 @@ impl Service { &json::TopLevel { time_zone_name: &self.time_zone_name, cameras: (&db, days, camera_configs), - session: caller.session, + user: caller.user, signals: (&db, days), signal_types: &db, }, @@ -1019,6 +1016,39 @@ impl Service { Ok(http_serve::serve(e, &req)) } + async fn user(&self, req: Request, caller: Caller, id: i32) -> ResponseResult { + if caller.user.map(|u| u.id) != Some(id) { + bail_t!(Unauthenticated, "must be authenticated as supplied user"); + } + match *req.method() { + Method::POST => self.post_user(req, id).await, + _ => Err(plain_response(StatusCode::METHOD_NOT_ALLOWED, "POST expected").into()), + } + } + + async fn post_user(&self, mut req: Request, id: i32) -> ResponseResult { + let r = extract_json_body(&mut req).await?; + let r: json::PostUser = serde_json::from_slice(&r).map_err(|e| bad_req(e.to_string()))?; + let mut db = self.db.lock(); + let user = db + .users_by_id() + .get(&id) + .ok_or_else(|| format_err_t!(Internal, "can't find currently authenticated user"))?; + if let Some(precondition) = r.precondition { + if matches!(precondition.preferences, Some(p) if p != user.preferences) { + bail_t!(FailedPrecondition, "preferences mismatch"); + } + } + if let Some(update) = r.update { + let mut change = user.change(); + if let Some(preferences) = update.preferences { + change.set_preferences(preferences); + } + db.apply_user_change(change).map_err(internal_server_err)?; + } + Ok(plain_response(StatusCode::NO_CONTENT, &b""[..])) + } + fn authreq(&self, req: &Request<::hyper::Body>) -> auth::Request { auth::Request { when_sec: Some(self.db.clocks().realtime().sec), @@ -1251,9 +1281,11 @@ impl Service { Ok((s, u)) => { return Ok(Caller { permissions: s.permissions.clone(), - session: Some(json::Session { - username: u.username.clone(), - csrf: s.csrf(), + user: Some(json::ToplevelUser { + id: s.user_id, + name: u.username.clone(), + preferences: u.preferences.clone(), + session: Some(json::Session { csrf: s.csrf() }), }), }) } @@ -1270,14 +1302,14 @@ impl Service { if let Some(s) = self.allow_unauthenticated_permissions.as_ref() { return Ok(Caller { permissions: s.clone(), - session: None, + user: None, }); } if unauth_path { return Ok(Caller { permissions: db::Permissions::default(), - session: None, + user: None, }); } @@ -1526,6 +1558,8 @@ mod tests { assert_eq!(Path::decode("/api/logout"), Path::Logout); assert_eq!(Path::decode("/api/signals"), Path::Signals); assert_eq!(Path::decode("/api/junk"), Path::NotFound); + assert_eq!(Path::decode("/api/users/42"), Path::User(42)); + assert_eq!(Path::decode("/api/users/asdf"), Path::NotFound); } #[test] @@ -1693,6 +1727,8 @@ mod tests { .await .unwrap(); let csrf = toplevel + .get("user") + .unwrap() .get("session") .unwrap() .get("csrf") diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 49daaf7..8125124 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -97,9 +97,11 @@ function App() { case "success": setError(null); setLoginState( - resp.response.session === undefined ? "not-logged-in" : "logged-in" + resp.response.user?.session === undefined + ? "not-logged-in" + : "logged-in" ); - setSession(resp.response.session || null); + setSession(resp.response.user?.session || null); setCameras(resp.response.cameras); setTimeZoneName(resp.response.timeZoneName); } diff --git a/ui/src/api.ts b/ui/src/api.ts index e641b94..3d1aa41 100644 --- a/ui/src/api.ts +++ b/ui/src/api.ts @@ -145,6 +145,12 @@ async function json( export interface ToplevelResponse { timeZoneName: string; cameras: Camera[]; + user: ToplevelUser | undefined; +} + +export interface ToplevelUser { + name: string; + id: number; session: Session | undefined; } diff --git a/ui/src/types.ts b/ui/src/types.ts index b3fc5c3..464e909 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -10,7 +10,6 @@ export type StreamType = "main" | "sub"; export interface Session { - username: string; csrf: string; }