Skip to content

Commit

Permalink
Pair pre-existing oid accounts with members (#171)
Browse files Browse the repository at this point in the history
Implement pairing for member with pre-existing keycloak account.


![image](https://github.com/ictunion/main-system/assets/2130305/c7cd8bce-9034-4d19-b04c-ca891726b2d8)

---------

[anonymized]
  • Loading branch information
turboMaCk authored and ICTGuerrilla committed Apr 10, 2024
1 parent b502593 commit cb47df6
Show file tree
Hide file tree
Showing 7 changed files with 234 additions and 33 deletions.
116 changes: 104 additions & 12 deletions melon-head/src/App/MemberDetail.res
Original file line number Diff line number Diff line change
Expand Up @@ -107,14 +107,25 @@ let timeRows: array<RowBasedTable.row<MemberData.detail>> = [
("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(
Expand All @@ -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<array<Session.user>>, _, _) =
api->Hook.getData(
~path="/members/" ++ Uuid.toString(id) ++ "/list_candidate_users",
~decoder=Json.Decode.array(Session.Decode.user),
)

<Modal.Content>
<p> {React.string("Accept member and allow them to access internal resources.")} </p>
{switch error {
| None => React.null
| Some(err) => <Message.Error> {React.string(err->Api.showError)} </Message.Error>
}}
<Button.Panel>
<Button onClick={_ => modal->Modal.Interface.closeModal}>
{React.string("Cancel")}
</Button>
<Button variant=Button.Cta onClick=doAccept> {React.string("Accept member")} </Button>
</Button.Panel>
<Tabbed.Tabs>
<Tabbed.Tab value=Create handlers=tabHandlers> {React.string("Create")} </Tabbed.Tab>
<Tabbed.Tab value=Pair handlers=tabHandlers> {React.string("Pair Existing")} </Tabbed.Tab>
</Tabbed.Tabs>
<Tabbed.Content tab=Create handlers=tabHandlers>
<div className={styles["modalBody"]}>
<p> {React.string("Accept member and allow them to access internal resources.")} </p>
{switch error {
| None => React.null
| Some(err) => <Message.Error> {React.string(err->Api.showError)} </Message.Error>
}}
</div>
<Button.Panel>
<Button onClick={_ => modal->Modal.Interface.closeModal}>
{React.string("Cancel")}
</Button>
<Button variant=Button.Cta onClick=doAccept> {React.string("Accept member")} </Button>
</Button.Panel>
</Tabbed.Content>
<Tabbed.Content tab=Pair handlers=tabHandlers>
<div className={styles["modalBody"]}>
<p> {React.string("Pair existing OID account with member")} </p>
{switch candidates {
| Idle => <Loading />
| Loading => <Loading />
| Failure(err) => React.string(Api.showError(err))
| Success(candidates) =>
if candidates == [] {
React.string("No candidates found")
} else {
<div className={styles["radioList"]}>
{candidates
->Array.map(user => {
<label className={styles["radio"]}>
<input value={user.id->Uuid.toString} type_="radio" onInput=selectId />
{React.string(user.email->Data.Email.toString)}
</label>
})
->React.array}
</div>
}
}}
{switch error {
| None => React.null
| Some(err) => <Message.Error> {React.string(err->Api.showError)} </Message.Error>
}}
</div>
<Button.Panel>
<Button onClick={_ => modal->Modal.Interface.closeModal}>
{React.string("Cancel")}
</Button>
<Button variant=Button.Cta onClick=doPair disabled={selectedId == None}>
{React.string("Pair Selected")}
</Button>
</Button.Panel>
</Tabbed.Content>
</Modal.Content>
}
}
Expand Down
19 changes: 19 additions & 0 deletions melon-head/src/App/MemberDetail/styles.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
14 changes: 14 additions & 0 deletions melon-head/src/Data/Session.res
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
lastName: option<string>,
}

module Decode = {
open Json.Decode

Expand Down Expand Up @@ -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)),
})
}
45 changes: 43 additions & 2 deletions orca/src/api/members/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -178,7 +178,6 @@ async fn accept<'r>(
token: JwtToken<'r>,
id: Id<Member>,
) -> Response<Json<Detail>> {
// TODO: this method shoudl probably use reference
oid_provider.require_realm_role(&token, RealmManagementRole::ManageUsers)?;

let status = query::get_status_data(id)
Expand Down Expand Up @@ -309,6 +308,46 @@ async fn remove_member<'r>(
Ok(Json(detail))
}

#[get("/<id>/list_candidate_users")]
async fn list_candidate_users<'r>(
db_pool: &State<DbPool>,
oid_provider: &State<Provider>,
token: JwtToken<'r>,
id: Id<Member>,
) -> Response<Json<Vec<oid::User>>> {
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("/<id>/pair_oid", format = "json", data = "<data>")]
async fn pair_oid<'r>(
db_pool: &State<DbPool>,
oid_provider: &State<Provider>,
token: JwtToken<'r>,
id: Id<Member>,
data: Json<PairRequest>,
) -> Response<Json<Detail>> {
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<Route> {
routes![
list_all,
Expand All @@ -322,5 +361,7 @@ pub fn routes() -> Vec<Route> {
accept,
update_note,
remove_member,
list_candidate_users,
pair_oid,
]
}
2 changes: 1 addition & 1 deletion orca/src/api/members/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ ORDER BY created_at DESC
pub fn get_new_oid_user<'a>(id: Id<Member>) -> 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
",
Expand Down
37 changes: 27 additions & 10 deletions orca/src/server/oid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
email: String,
first_name: Option<String>,
last_name: Option<String>,
Expand Down Expand Up @@ -70,7 +71,7 @@ impl RealmManagementRole {
}

enum ProviderState {
Keyclaok(Box<keycloak::KeycloakProvider>),
Keycloak(Box<keycloak::KeycloakProvider>),
Disconnected,
}

Expand All @@ -96,6 +97,11 @@ trait OidProvider {

async fn create_user<'a>(&self, token: &JwtToken<'a>, user: &User) -> Result<Uuid, Error>;
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<Vec<User>, Error>;
}

pub struct Provider(ProviderState);
Expand All @@ -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");
Expand All @@ -117,7 +123,7 @@ impl Provider {

pub fn decode_jwt(&self, token: &JwtToken) -> Result<TokenData<JwtClaims>, Error> {
match &self.0 {
ProviderState::Keyclaok(k) => {
ProviderState::Keycloak(k) => {
let res = k.decode_jwt(token)?;
Ok(res)
}
Expand All @@ -131,7 +137,7 @@ impl Provider {
role: Role,
) -> Result<TokenData<JwtClaims>, 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),
}
}
Expand All @@ -142,7 +148,7 @@ impl Provider {
role: RealmManagementRole,
) -> Result<TokenData<JwtClaims>, 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),
}
}
Expand All @@ -153,28 +159,39 @@ impl Provider {
role: &[Role],
) -> Result<TokenData<JwtClaims>, 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<Uuid, Error> {
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<Vec<User>, Error> {
match &self.0 {
ProviderState::Keycloak(k) => k.get_matching_users(token, email).await,
ProviderState::Disconnected => Err(Error::Disabled),
}
}
Expand Down
Loading

0 comments on commit cb47df6

Please sign in to comment.