diff --git a/melon-head/src/App/MemberDetail.res b/melon-head/src/App/MemberDetail.res index 9bcd3df..99bef7f 100644 --- a/melon-head/src/App/MemberDetail.res +++ b/melon-head/src/App/MemberDetail.res @@ -107,14 +107,25 @@ let timeRows: array> = [ ("Left at", d => View.option(d.leftAt, a => a->Js.Date.toLocaleString->React.string)), ] +module Loading = { + @react.component + let make = () => { + React.string("loading...") + } +} + module Actions = { open MemberData module Accept = { + type acceptTabs = + | Create + | Pair + @react.component let make = (~modal, ~api, ~id, ~setDetail) => { let (error, setError) = React.useState(() => None) - + let tabHandlers = Tabbed.make(Create) let doAccept = (_: JsxEvent.Mouse.t) => { let req = api->Api.patchJson( @@ -134,18 +145,99 @@ module Actions = { }) } + let (selectedId, setId) = React.useState(_ => None) + + let selectId = (event: JsxEvent.Form.t) => { + let newVal = ReactEvent.Form.currentTarget(event)["value"] + setId(_ => Some(newVal)) + } + + let doPair = (_: JsxEvent.Mouse.t) => { + let uuid = switch selectedId { + | Some(uuid) => Json.Encode.string(uuid) + | None => Json.Encode.null + } + let req = + api->Api.patchJson( + ~path="/members/" ++ Uuid.toString(id) ++ "/pair_oid", + ~decoder=MemberData.Decode.detail, + ~body=Json.Encode.object([("sub", uuid)]), + ) + + req->Future.get(res => { + switch res { + | Ok(data) => { + setDetail(_ => RemoteData.Success(data)) + Modal.Interface.closeModal(modal) + } + | Error(e) => setError(_ => Some(e)) + } + }) + } + + let (candidates: Api.webData>, _, _) = + api->Hook.getData( + ~path="/members/" ++ Uuid.toString(id) ++ "/list_candidate_users", + ~decoder=Json.Decode.array(Session.Decode.user), + ) + -

{React.string("Accept member and allow them to access internal resources.")}

- {switch error { - | None => React.null - | Some(err) => {React.string(err->Api.showError)} - }} - - - - + + {React.string("Create")} + {React.string("Pair Existing")} + + +
+

{React.string("Accept member and allow them to access internal resources.")}

+ {switch error { + | None => React.null + | Some(err) => {React.string(err->Api.showError)} + }} +
+ + + + +
+ +
+

{React.string("Pair existing OID account with member")}

+ {switch candidates { + | Idle => + | Loading => + | Failure(err) => React.string(Api.showError(err)) + | Success(candidates) => + if candidates == [] { + React.string("No candidates found") + } else { +
+ {candidates + ->Array.map(user => { + + }) + ->React.array} +
+ } + }} + {switch error { + | None => React.null + | Some(err) => {React.string(err->Api.showError)} + }} +
+ + + + +
} } diff --git a/melon-head/src/App/MemberDetail/styles.module.scss b/melon-head/src/App/MemberDetail/styles.module.scss index f700128..0491e8e 100644 --- a/melon-head/src/App/MemberDetail/styles.module.scss +++ b/melon-head/src/App/MemberDetail/styles.module.scss @@ -78,3 +78,22 @@ } } } + +.modal-body { + margin-bottom: 12px; +} + +.radio-list { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 0; +} + +.radio { + display: block; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid var(--color4); + background: var(--background-color); +} diff --git a/melon-head/src/Data/Session.res b/melon-head/src/Data/Session.res index dfc451a..9120bfa 100644 --- a/melon-head/src/Data/Session.res +++ b/melon-head/src/Data/Session.res @@ -56,6 +56,13 @@ let hasRealmRole = (session, ~role: realmRole): bool => { Array.some(allRoles, r => r == role) } +type user = { + id: Data.Uuid.t, + email: Email.t, + firstName: option, + lastName: option, +} + module Decode = { open Json.Decode @@ -101,4 +108,11 @@ module Decode = { tokenClaims: field.required(. "token_claims", tokenClaims), memberId: field.required(. "member_id", option(Data.Uuid.decode)), }) + + let user = object(field => { + id: field.required(. "id", Data.Uuid.decode), + email: field.required(. "email", Email.decode), + firstName: field.required(. "first_name", option(string)), + lastName: field.required(. "last_name", option(string)), + }) } diff --git a/orca/src/api/members/mod.rs b/orca/src/api/members/mod.rs index 1e8ca96..f1451ed 100644 --- a/orca/src/api/members/mod.rs +++ b/orca/src/api/members/mod.rs @@ -10,7 +10,7 @@ use crate::api::files::FileInfo; use crate::api::Response; use crate::data::{Id, Member, MemberNumber, RegistrationRequest}; use crate::db::DbPool; -use crate::server::oid::{JwtToken, Provider, RealmManagementRole, Role}; +use crate::server::oid::{self, JwtToken, Provider, RealmManagementRole, Role}; use super::ApiError; @@ -178,7 +178,6 @@ async fn accept<'r>( token: JwtToken<'r>, id: Id, ) -> Response> { - // TODO: this method shoudl probably use reference oid_provider.require_realm_role(&token, RealmManagementRole::ManageUsers)?; let status = query::get_status_data(id) @@ -309,6 +308,46 @@ async fn remove_member<'r>( Ok(Json(detail)) } +#[get("//list_candidate_users")] +async fn list_candidate_users<'r>( + db_pool: &State, + oid_provider: &State, + token: JwtToken<'r>, + id: Id, +) -> Response>> { + oid_provider.require_role(&token, Role::ManageMembers)?; + + let detail = query::detail(id).fetch_one(db_pool.inner()).await?; + + match detail.email { + Some(email) => Ok(Json(oid_provider.get_matching_users(&token, email).await?)), + None => Ok(Json(Vec::new())), + } +} + +#[derive(Debug, Deserialize)] +#[serde(crate = "rocket::serde")] +struct PairRequest { + sub: Uuid, +} + +#[patch("//pair_oid", format = "json", data = "")] +async fn pair_oid<'r>( + db_pool: &State, + oid_provider: &State, + token: JwtToken<'r>, + id: Id, + data: Json, +) -> Response> { + oid_provider.require_role(&token, Role::ManageMembers)?; + + let detail = query::assing_member_oid_sub(id, data.sub) + .fetch_one(db_pool.inner()) + .await?; + + Ok(Json(detail)) +} + pub fn routes() -> Vec { routes![ list_all, @@ -322,5 +361,7 @@ pub fn routes() -> Vec { accept, update_note, remove_member, + list_candidate_users, + pair_oid, ] } diff --git a/orca/src/api/members/query.rs b/orca/src/api/members/query.rs index 1436c64..aeb1a6c 100644 --- a/orca/src/api/members/query.rs +++ b/orca/src/api/members/query.rs @@ -247,7 +247,7 @@ ORDER BY created_at DESC pub fn get_new_oid_user<'a>(id: Id) -> QueryAs<'a, oid::User> { sqlx::query_as( " -SELECT first_name, last_name, email +SELECT NULL as id, first_name, last_name, email FROM members WHERE id = $1 ", diff --git a/orca/src/server/oid.rs b/orca/src/server/oid.rs index 6d86d90..03ea3dc 100644 --- a/orca/src/server/oid.rs +++ b/orca/src/server/oid.rs @@ -14,8 +14,9 @@ use crate::config; mod keycloak; use self::keycloak::KeycloakProvider; -#[derive(Debug, sqlx::FromRow)] +#[derive(Debug, sqlx::FromRow, Deserialize, Serialize)] pub struct User { + id: Option, email: String, first_name: Option, last_name: Option, @@ -70,7 +71,7 @@ impl RealmManagementRole { } enum ProviderState { - Keyclaok(Box), + Keycloak(Box), Disconnected, } @@ -96,6 +97,11 @@ trait OidProvider { async fn create_user<'a>(&self, token: &JwtToken<'a>, user: &User) -> Result; async fn remove_user<'a>(&self, token: &JwtToken<'a>, id: Uuid) -> Result<(), Error>; + async fn get_matching_users<'a>( + &self, + token: &JwtToken<'a>, + email: String, + ) -> Result, Error>; } pub struct Provider(ProviderState); @@ -108,7 +114,7 @@ impl Provider { &config.keycloak_client_id, ) { let k = KeycloakProvider::fetch(host, realm, client_id.clone()).await?; - return Ok(Provider(ProviderState::Keyclaok(Box::new(k)))); + return Ok(Provider(ProviderState::Keycloak(Box::new(k)))); } warn!("Keycloak authorization not configured. Authorization disabled"); @@ -117,7 +123,7 @@ impl Provider { pub fn decode_jwt(&self, token: &JwtToken) -> Result, Error> { match &self.0 { - ProviderState::Keyclaok(k) => { + ProviderState::Keycloak(k) => { let res = k.decode_jwt(token)?; Ok(res) } @@ -131,7 +137,7 @@ impl Provider { role: Role, ) -> Result, Error> { match &self.0 { - ProviderState::Keyclaok(k) => k.require_role(token, role), + ProviderState::Keycloak(k) => k.require_role(token, role), ProviderState::Disconnected => Err(Error::Disabled), } } @@ -142,7 +148,7 @@ impl Provider { role: RealmManagementRole, ) -> Result, Error> { match &self.0 { - ProviderState::Keyclaok(k) => k.require_realm_role(token, role), + ProviderState::Keycloak(k) => k.require_realm_role(token, role), ProviderState::Disconnected => Err(Error::Disabled), } } @@ -153,28 +159,39 @@ impl Provider { role: &[Role], ) -> Result, Error> { match &self.0 { - ProviderState::Keyclaok(k) => k.require_any_role(token, role), + ProviderState::Keycloak(k) => k.require_any_role(token, role), ProviderState::Disconnected => Err(Error::Disabled), } } pub fn is_connected(&self) -> bool { match self.0 { - ProviderState::Keyclaok(_) => true, + ProviderState::Keycloak(_) => true, ProviderState::Disconnected => false, } } pub async fn create_user<'a>(&self, token: &JwtToken<'a>, user: &User) -> Result { match &self.0 { - ProviderState::Keyclaok(k) => k.create_user(token, user).await, + ProviderState::Keycloak(k) => k.create_user(token, user).await, ProviderState::Disconnected => Err(Error::Disabled), } } pub async fn remove_user<'a>(&self, token: &JwtToken<'a>, id: uuid::Uuid) -> Result<(), Error> { match &self.0 { - ProviderState::Keyclaok(k) => k.remove_user(token, id).await, + ProviderState::Keycloak(k) => k.remove_user(token, id).await, + ProviderState::Disconnected => Err(Error::Disabled), + } + } + + pub async fn get_matching_users<'a>( + &self, + token: &JwtToken<'a>, + email: String, + ) -> Result, Error> { + match &self.0 { + ProviderState::Keycloak(k) => k.get_matching_users(token, email).await, ProviderState::Disconnected => Err(Error::Disabled), } } diff --git a/orca/src/server/oid/keycloak.rs b/orca/src/server/oid/keycloak.rs index c9a14a2..d7cc730 100644 --- a/orca/src/server/oid/keycloak.rs +++ b/orca/src/server/oid/keycloak.rs @@ -1,12 +1,13 @@ /// These implementations are very keycloak specific /// We're not using keycloak library from crates io because we want these to wrork differently -use super::{Error, JwtClaims, JwtToken, OidProvider, RealmManagementRole, Role}; -use jsonwebtoken::{self, Algorithm, DecodingKey, TokenData, Validation}; use reqwest; use rocket::serde::json::json; use url::Url; use uuid::Uuid; +use super::{Error, JwtClaims, JwtToken, OidProvider, RealmManagementRole, Role, User}; +use jsonwebtoken::{self, Algorithm, DecodingKey, TokenData, Validation}; + use crate::server::jwk; fn keycloak_url(host: &str, realm: &str) -> String { @@ -108,11 +109,7 @@ impl OidProvider for KeycloakProvider { Err(Error::MissingOneOfRoles(roles.to_vec())) } - async fn create_user<'a>( - &self, - token: &JwtToken<'a>, - user: &super::User, - ) -> Result { + async fn create_user<'a>(&self, token: &JwtToken<'a>, user: &User) -> Result { // Keycalok expects json body with data about user let json = json!({ "firstName": user.first_name, @@ -163,7 +160,7 @@ impl OidProvider for KeycloakProvider { } } - async fn remove_user<'a>(&self, token: &JwtToken<'a>, id: uuid::Uuid) -> Result<(), Error> { + async fn remove_user<'a>(&self, token: &JwtToken<'a>, id: Uuid) -> Result<(), Error> { // Send request to create a new user let client = reqwest::Client::new(); let response = client @@ -186,4 +183,25 @@ impl OidProvider for KeycloakProvider { Err(Error::Proxy(status)) } } + + async fn get_matching_users<'a>( + &self, + token: &JwtToken<'a>, + email: String, + ) -> Result, Error> { + // query keycloak for user using email filtering + let client = reqwest::Client::new(); + let response = client + .get(&format!( + "{}/admin/realms/{}/users?exact=true&email={}", + self.host, self.realm, email, + )) + .header("Authorization", format!("Bearer {}", token.string)) + .send() + .await?; + + debug!("Keycloak response response {:?}", response); + + Ok(response.json::>().await?) + } }