Compare commits
24 Commits
group-ui
...
user-attri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b817980a9 | ||
|
|
66097f1880 | ||
|
|
adf3577f0e | ||
|
|
5c5b87d5af | ||
|
|
f65a6f524a | ||
|
|
96f5b31e0c | ||
|
|
4955b7fac1 | ||
|
|
646fe32645 | ||
|
|
fa9743be6a | ||
|
|
38c4296d62 | ||
|
|
1c65cd115e | ||
|
|
8f2391a792 | ||
|
|
bb2654f9c2 | ||
|
|
770e934859 | ||
|
|
cc0827f271 | ||
|
|
93f3057b8f | ||
|
|
206e98c986 | ||
|
|
28e6fa0f10 | ||
|
|
d4b3b4649e | ||
|
|
b78e093205 | ||
|
|
c2eed8909a | ||
|
|
b82a2d5705 | ||
|
|
addd453287 | ||
|
|
e308a5e9a1 |
@@ -1,4 +1,4 @@
|
|||||||
FROM rust:1.72
|
FROM rust:1.74
|
||||||
|
|
||||||
ARG USERNAME=lldapdev
|
ARG USERNAME=lldapdev
|
||||||
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
# We need to keep the user as 1001 to match the GitHub runner's UID.
|
||||||
|
|||||||
11
.github/codecov.yml
vendored
11
.github/codecov.yml
vendored
@@ -1,10 +1,19 @@
|
|||||||
codecov:
|
codecov:
|
||||||
require_ci_to_pass: yes
|
require_ci_to_pass: yes
|
||||||
comment:
|
comment:
|
||||||
layout: "diff,flags"
|
layout: "header,diff,files"
|
||||||
require_changes: true
|
require_changes: true
|
||||||
require_base: true
|
require_base: true
|
||||||
require_head: true
|
require_head: true
|
||||||
|
coverage:
|
||||||
|
status:
|
||||||
|
project:
|
||||||
|
default:
|
||||||
|
target: "75%"
|
||||||
|
threshold: "0.1%"
|
||||||
|
removed_code_behavior: adjust_base
|
||||||
|
github_checks:
|
||||||
|
annotations: true
|
||||||
ignore:
|
ignore:
|
||||||
- "app"
|
- "app"
|
||||||
- "docs"
|
- "docs"
|
||||||
|
|||||||
4
.github/workflows/docker-build-static.yml
vendored
4
.github/workflows/docker-build-static.yml
vendored
@@ -641,7 +641,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Update repo description
|
- name: Update repo description
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: peter-evans/dockerhub-description@v3
|
uses: peter-evans/dockerhub-description@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
@@ -649,7 +649,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Update lldap repo description
|
- name: Update lldap repo description
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: peter-evans/dockerhub-description@v3
|
uses: peter-evans/dockerhub-description@v4
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
password: ${{ secrets.DOCKERHUB_PASSWORD }}
|
||||||
|
|||||||
@@ -385,6 +385,7 @@ folder for help with:
|
|||||||
- [Portainer](example_configs/portainer.md)
|
- [Portainer](example_configs/portainer.md)
|
||||||
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
- [PowerDNS Admin](example_configs/powerdns_admin.md)
|
||||||
- [Proxmox VE](example_configs/proxmox.md)
|
- [Proxmox VE](example_configs/proxmox.md)
|
||||||
|
- [Radicale](example_configs/radicale.md)
|
||||||
- [Rancher](example_configs/rancher.md)
|
- [Rancher](example_configs/rancher.md)
|
||||||
- [Seafile](example_configs/seafile.md)
|
- [Seafile](example_configs/seafile.md)
|
||||||
- [Shaarli](example_configs/shaarli.md)
|
- [Shaarli](example_configs/shaarli.md)
|
||||||
|
|||||||
@@ -37,12 +37,16 @@ version = "0.3"
|
|||||||
features = [
|
features = [
|
||||||
"Document",
|
"Document",
|
||||||
"Element",
|
"Element",
|
||||||
|
"Event",
|
||||||
"FileReader",
|
"FileReader",
|
||||||
|
"FormData",
|
||||||
"HtmlDocument",
|
"HtmlDocument",
|
||||||
|
"HtmlFormElement",
|
||||||
"HtmlInputElement",
|
"HtmlInputElement",
|
||||||
"HtmlOptionElement",
|
"HtmlOptionElement",
|
||||||
"HtmlOptionsCollection",
|
"HtmlOptionsCollection",
|
||||||
"HtmlSelectElement",
|
"HtmlSelectElement",
|
||||||
|
"SubmitEvent",
|
||||||
"console",
|
"console",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
5
app/queries/create_group_attribute.graphql
Normal file
5
app/queries/create_group_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!) {
|
||||||
|
addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: false) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/queries/create_user_attribute.graphql
Normal file
5
app/queries/create_user_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
|
||||||
|
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/queries/delete_group_attribute.graphql
Normal file
5
app/queries/delete_group_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation DeleteGroupAttributeQuery($name: String!) {
|
||||||
|
deleteGroupAttribute(name: $name) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
5
app/queries/delete_user_attribute.graphql
Normal file
5
app/queries/delete_user_attribute.graphql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mutation DeleteUserAttributeQuery($name: String!) {
|
||||||
|
deleteUserAttribute(name: $name) {
|
||||||
|
ok
|
||||||
|
}
|
||||||
|
}
|
||||||
13
app/queries/get_group_attributes_schema.graphql
Normal file
13
app/queries/get_group_attributes_schema.graphql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
query GetGroupAttributesSchema {
|
||||||
|
schema {
|
||||||
|
groupSchema {
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isHardcoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/queries/get_user_attributes_schema.graphql
Normal file
14
app/queries/get_user_attributes_schema.graphql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
query GetUserAttributesSchema {
|
||||||
|
schema {
|
||||||
|
userSchema {
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isEditable
|
||||||
|
isHardcoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,5 +12,17 @@ query GetUserDetails($id: String!) {
|
|||||||
id
|
id
|
||||||
displayName
|
displayName
|
||||||
}
|
}
|
||||||
|
attributes {
|
||||||
|
name
|
||||||
|
value
|
||||||
|
schema {
|
||||||
|
name
|
||||||
|
attributeType
|
||||||
|
isList
|
||||||
|
isVisible
|
||||||
|
isEditable
|
||||||
|
isHardcoded
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,26 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::{
|
components::{
|
||||||
|
banner::Banner,
|
||||||
change_password::ChangePasswordForm,
|
change_password::ChangePasswordForm,
|
||||||
create_group::CreateGroupForm,
|
create_group::CreateGroupForm,
|
||||||
|
create_group_attribute::CreateGroupAttributeForm,
|
||||||
create_user::CreateUserForm,
|
create_user::CreateUserForm,
|
||||||
|
create_user_attribute::CreateUserAttributeForm,
|
||||||
group_details::GroupDetails,
|
group_details::GroupDetails,
|
||||||
|
group_schema_table::ListGroupSchema,
|
||||||
group_table::GroupTable,
|
group_table::GroupTable,
|
||||||
login::LoginForm,
|
login::LoginForm,
|
||||||
logout::LogoutButton,
|
|
||||||
reset_password_step1::ResetPasswordStep1Form,
|
reset_password_step1::ResetPasswordStep1Form,
|
||||||
reset_password_step2::ResetPasswordStep2Form,
|
reset_password_step2::ResetPasswordStep2Form,
|
||||||
router::{AppRoute, Link, Redirect},
|
router::{AppRoute, Link, Redirect},
|
||||||
user_details::UserDetails,
|
user_details::UserDetails,
|
||||||
|
user_schema_table::ListUserSchema,
|
||||||
user_table::UserTable,
|
user_table::UserTable,
|
||||||
},
|
},
|
||||||
infra::{api::HostService, cookies::get_cookie},
|
infra::{api::HostService, cookies::get_cookie},
|
||||||
};
|
};
|
||||||
|
|
||||||
use gloo_console::error;
|
use gloo_console::error;
|
||||||
use wasm_bindgen::prelude::*;
|
|
||||||
use yew::{
|
use yew::{
|
||||||
function_component,
|
function_component,
|
||||||
html::Scope,
|
html::Scope,
|
||||||
@@ -30,25 +33,6 @@ use yew_router::{
|
|||||||
BrowserRouter, Switch,
|
BrowserRouter, Switch,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
extern "C" {
|
|
||||||
#[wasm_bindgen(js_namespace = darkmode)]
|
|
||||||
fn toggleDarkMode(doSave: bool);
|
|
||||||
|
|
||||||
#[wasm_bindgen]
|
|
||||||
fn inDarkMode() -> bool;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component(DarkModeToggle)]
|
|
||||||
pub fn dark_mode_toggle() -> Html {
|
|
||||||
html! {
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
|
|
||||||
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[function_component(AppContainer)]
|
#[function_component(AppContainer)]
|
||||||
pub fn app_container() -> Html {
|
pub fn app_container() -> Html {
|
||||||
html! {
|
html! {
|
||||||
@@ -135,10 +119,11 @@ impl Component for App {
|
|||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
let link = ctx.link().clone();
|
let link = ctx.link().clone();
|
||||||
let is_admin = self.is_admin();
|
let is_admin = self.is_admin();
|
||||||
|
let username = self.user_info.clone().map(|(username, _)| username);
|
||||||
let password_reset_enabled = self.password_reset_enabled;
|
let password_reset_enabled = self.password_reset_enabled;
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div>
|
||||||
{self.view_banner(ctx)}
|
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
|
||||||
<div class="container py-3 bg-kug">
|
<div class="container py-3 bg-kug">
|
||||||
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
<div class="row justify-content-center" style="padding-bottom: 80px;">
|
||||||
<main class="py-3" style="max-width: 1000px">
|
<main class="py-3" style="max-width: 1000px">
|
||||||
@@ -227,6 +212,12 @@ impl App {
|
|||||||
AppRoute::CreateGroup => html! {
|
AppRoute::CreateGroup => html! {
|
||||||
<CreateGroupForm/>
|
<CreateGroupForm/>
|
||||||
},
|
},
|
||||||
|
AppRoute::CreateUserAttribute => html! {
|
||||||
|
<CreateUserAttributeForm/>
|
||||||
|
},
|
||||||
|
AppRoute::CreateGroupAttribute => html! {
|
||||||
|
<CreateGroupAttributeForm/>
|
||||||
|
},
|
||||||
AppRoute::ListGroups => html! {
|
AppRoute::ListGroups => html! {
|
||||||
<div>
|
<div>
|
||||||
<GroupTable />
|
<GroupTable />
|
||||||
@@ -236,6 +227,12 @@ impl App {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
},
|
},
|
||||||
|
AppRoute::ListUserSchema => html! {
|
||||||
|
<ListUserSchema />
|
||||||
|
},
|
||||||
|
AppRoute::ListGroupSchema => html! {
|
||||||
|
<ListGroupSchema />
|
||||||
|
},
|
||||||
AppRoute::GroupDetails { group_id } => html! {
|
AppRoute::GroupDetails { group_id } => html! {
|
||||||
<GroupDetails group_id={*group_id} />
|
<GroupDetails group_id={*group_id} />
|
||||||
},
|
},
|
||||||
@@ -263,91 +260,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn view_banner(&self, ctx: &Context<Self>) -> Html {
|
|
||||||
html! {
|
|
||||||
<header class="p-2 mb-3 border-bottom">
|
|
||||||
<div class="container">
|
|
||||||
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
|
||||||
<a href={yew_router::utils::base_url().unwrap_or("/".to_string())} class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
|
|
||||||
<h2>{"LLDAP"}</h2>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
|
||||||
{if self.is_admin() { html! {
|
|
||||||
<>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
classes="nav-link px-2 h6"
|
|
||||||
to={AppRoute::ListUsers}>
|
|
||||||
<i class="bi-people me-2"></i>
|
|
||||||
{"Users"}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
classes="nav-link px-2 h6"
|
|
||||||
to={AppRoute::ListGroups}>
|
|
||||||
<i class="bi-collection me-2"></i>
|
|
||||||
{"Groups"}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</>
|
|
||||||
} } else { html!{} } }
|
|
||||||
</ul>
|
|
||||||
{ self.view_user_menu(ctx) }
|
|
||||||
<DarkModeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_user_menu(&self, ctx: &Context<Self>) -> Html {
|
|
||||||
if let Some((user_id, _)) = &self.user_info {
|
|
||||||
let link = ctx.link();
|
|
||||||
html! {
|
|
||||||
<div class="dropdown text-end">
|
|
||||||
<a href="#"
|
|
||||||
class="d-block nav-link text-decoration-none dropdown-toggle"
|
|
||||||
id="dropdownUser"
|
|
||||||
data-bs-toggle="dropdown"
|
|
||||||
aria-expanded="false">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="32"
|
|
||||||
height="32"
|
|
||||||
fill="currentColor"
|
|
||||||
class="bi bi-person-circle"
|
|
||||||
viewBox="0 0 16 16">
|
|
||||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
|
||||||
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
|
||||||
</svg>
|
|
||||||
<span class="ms-2">
|
|
||||||
{user_id}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
<ul
|
|
||||||
class="dropdown-menu text-small dropdown-menu-lg-end"
|
|
||||||
aria-labelledby="dropdownUser1"
|
|
||||||
style="">
|
|
||||||
<li>
|
|
||||||
<Link
|
|
||||||
classes="dropdown-item"
|
|
||||||
to={AppRoute::UserDetails{ user_id: user_id.clone() }}>
|
|
||||||
{"View details"}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li><hr class="dropdown-divider" /></li>
|
|
||||||
<li>
|
|
||||||
<LogoutButton on_logged_out={link.callback(|_| Msg::Logout)} />
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html! {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn view_footer(&self) -> Html {
|
fn view_footer(&self) -> Html {
|
||||||
html! {
|
html! {
|
||||||
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
<footer class="text-center fixed-bottom text-muted bg-light py-2">
|
||||||
|
|||||||
87
app/src/components/avatar.rs
Normal file
87
app/src/components/avatar.rs
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
use crate::infra::functional::{use_graphql_call, LoadableResult};
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Properties};
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_user_details.graphql",
|
||||||
|
response_derives = "Debug, Hash, PartialEq, Eq, Clone",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetUserDetails;
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub user: AttrValue,
|
||||||
|
#[prop_or(32)]
|
||||||
|
pub width: i32,
|
||||||
|
#[prop_or(32)]
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Avatar)]
|
||||||
|
pub fn avatar(props: &Props) -> Html {
|
||||||
|
let user_details = use_graphql_call::<GetUserDetails>(get_user_details::Variables {
|
||||||
|
id: props.user.to_string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
match &(*user_details) {
|
||||||
|
LoadableResult::Loaded(Ok(response)) => {
|
||||||
|
let avatar = response.user.avatar.clone();
|
||||||
|
match &avatar {
|
||||||
|
Some(data) => html! {
|
||||||
|
<img
|
||||||
|
id="avatarDisplay"
|
||||||
|
src={format!("data:image/jpeg;base64, {}", data)}
|
||||||
|
style={format!("max-height:{}px;max-width:{}px;height:auto;width:auto;", props.height, props.width)}
|
||||||
|
alt="Avatar" />
|
||||||
|
},
|
||||||
|
None => html! {
|
||||||
|
<BlankAvatarDisplay
|
||||||
|
width={props.width}
|
||||||
|
height={props.height} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LoadableResult::Loaded(Err(error)) => html! {
|
||||||
|
<BlankAvatarDisplay
|
||||||
|
error={error.to_string()}
|
||||||
|
width={props.width}
|
||||||
|
height={props.height} />
|
||||||
|
},
|
||||||
|
LoadableResult::Loading => html! {
|
||||||
|
<BlankAvatarDisplay
|
||||||
|
width={props.width}
|
||||||
|
height={props.height} />
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct BlankAvatarDisplayProps {
|
||||||
|
#[prop_or(None)]
|
||||||
|
pub error: Option<AttrValue>,
|
||||||
|
pub width: i32,
|
||||||
|
pub height: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(BlankAvatarDisplay)]
|
||||||
|
fn blank_avatar_display(props: &BlankAvatarDisplayProps) -> Html {
|
||||||
|
let fill = match &props.error {
|
||||||
|
Some(_) => "red",
|
||||||
|
None => "currentColor",
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={props.width.to_string()}
|
||||||
|
height={props.height.to_string()}
|
||||||
|
fill={fill}
|
||||||
|
class="bi bi-person-circle"
|
||||||
|
viewBox="0 0 16 16">
|
||||||
|
<title>{props.error.clone().unwrap_or(AttrValue::Static("Avatar"))}</title>
|
||||||
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
|
<path fill-rule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8zm8-7a7 7 0 0 0-5.468 11.37C3.242 11.226 4.805 10 8 10s4.757 1.225 5.468 2.37A7 7 0 0 0 8 1z"/>
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/src/components/banner.rs
Normal file
132
app/src/components/banner.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
use crate::components::{
|
||||||
|
avatar::Avatar,
|
||||||
|
logout::LogoutButton,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
};
|
||||||
|
use wasm_bindgen::prelude::wasm_bindgen;
|
||||||
|
use yew::{function_component, html, Callback, Properties};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub is_admin: bool,
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub on_logged_out: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Banner)]
|
||||||
|
pub fn banner(props: &Props) -> Html {
|
||||||
|
html! {
|
||||||
|
<header class="p-2 mb-3 border-bottom">
|
||||||
|
<div class="container">
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-center justify-content-lg-start">
|
||||||
|
<a href={yew_router::utils::base_url().unwrap_or("/".to_string())} class="d-flex align-items-center mt-2 mb-lg-0 me-md-5 text-decoration-none">
|
||||||
|
<h2>{"LLDAP"}</h2>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="nav col-12 col-lg-auto me-lg-auto mb-2 justify-content-center mb-md-0">
|
||||||
|
{if props.is_admin { html! {
|
||||||
|
<>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListUsers}>
|
||||||
|
<i class="bi-people me-2"></i>
|
||||||
|
{"Users"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListGroups}>
|
||||||
|
<i class="bi-collection me-2"></i>
|
||||||
|
{"Groups"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListUserSchema}>
|
||||||
|
<i class="bi-list-ul me-2"></i>
|
||||||
|
{"User schema"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="nav-link px-2 h6"
|
||||||
|
to={AppRoute::ListGroupSchema}>
|
||||||
|
<i class="bi-list-ul me-2"></i>
|
||||||
|
{"Group schema"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</>
|
||||||
|
} } else { html!{} } }
|
||||||
|
</ul>
|
||||||
|
<UserMenu username={props.username.clone()} on_logged_out={props.on_logged_out.clone()}/>
|
||||||
|
<DarkModeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct UserMenuProps {
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub on_logged_out: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(UserMenu)]
|
||||||
|
fn user_menu(props: &UserMenuProps) -> Html {
|
||||||
|
match &props.username {
|
||||||
|
Some(username) => html! {
|
||||||
|
<div class="dropdown text-end">
|
||||||
|
<a href="#"
|
||||||
|
class="d-block nav-link text-decoration-none dropdown-toggle"
|
||||||
|
id="dropdownUser"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false">
|
||||||
|
<Avatar user={username.clone()} />
|
||||||
|
<span class="ms-2">
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<ul
|
||||||
|
class="dropdown-menu text-small dropdown-menu-lg-end"
|
||||||
|
aria-labelledby="dropdownUser1"
|
||||||
|
style="">
|
||||||
|
<li>
|
||||||
|
<Link
|
||||||
|
classes="dropdown-item"
|
||||||
|
to={AppRoute::UserDetails{ user_id: username.to_string() }}>
|
||||||
|
{"View details"}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider" /></li>
|
||||||
|
<li>
|
||||||
|
<LogoutButton on_logged_out={props.on_logged_out.clone()} />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
},
|
||||||
|
_ => html! {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
extern "C" {
|
||||||
|
#[wasm_bindgen(js_namespace = darkmode)]
|
||||||
|
fn toggleDarkMode(doSave: bool);
|
||||||
|
|
||||||
|
#[wasm_bindgen]
|
||||||
|
fn inDarkMode() -> bool;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(DarkModeToggle)]
|
||||||
|
fn dark_mode_toggle() -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" onclick={|_| toggleDarkMode(true)} type="checkbox" id="darkModeToggle" checked={inDarkMode()}/>
|
||||||
|
<label class="form-check-label" for="darkModeToggle">{"Dark mode"}</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, Link},
|
components::{
|
||||||
|
form::{field::Field, submit::Submit},
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -207,7 +210,6 @@ impl Component for ChangePasswordForm {
|
|||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
let is_admin = ctx.props().is_admin;
|
let is_admin = ctx.props().is_admin;
|
||||||
let link = ctx.link();
|
let link = ctx.link();
|
||||||
type Field = yew_form::Field<FormModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<div class="mb-2 mt-2">
|
<div class="mb-2 mt-2">
|
||||||
@@ -224,90 +226,44 @@ impl Component for ChangePasswordForm {
|
|||||||
}
|
}
|
||||||
} else { html! {} }
|
} else { html! {} }
|
||||||
}
|
}
|
||||||
<form
|
<form class="form">
|
||||||
class="form">
|
|
||||||
{if !is_admin { html! {
|
{if !is_admin { html! {
|
||||||
<div class="form-group row">
|
<Field<FormModel>
|
||||||
<label for="old_password"
|
form={&self.form}
|
||||||
class="form-label col-sm-2 col-form-label">
|
required=true
|
||||||
{"Current password*:"}
|
label="Current password"
|
||||||
</label>
|
field_name="old_password"
|
||||||
<div class="col-sm-10">
|
input_type="password"
|
||||||
<Field
|
autocomplete="current-password"
|
||||||
form={&self.form}
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
field_name="old_password"
|
|
||||||
input_type="password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="current-password"
|
|
||||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("old_password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}} else { html! {} }}
|
}} else { html! {} }}
|
||||||
<div class="form-group row mb-3">
|
<Field<FormModel>
|
||||||
<label for="new_password"
|
form={&self.form}
|
||||||
class="form-label col-sm-2 col-form-label">
|
required=true
|
||||||
{"New Password"}
|
label="New password"
|
||||||
<span class="text-danger">{"*"}</span>
|
field_name="password"
|
||||||
{":"}
|
input_type="password"
|
||||||
</label>
|
autocomplete="new-password"
|
||||||
<div class="col-sm-10">
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
<Field
|
<Field<FormModel>
|
||||||
form={&self.form}
|
form={&self.form}
|
||||||
field_name="password"
|
required=true
|
||||||
input_type="password"
|
label="Confirm password"
|
||||||
class="form-control"
|
field_name="confirm_password"
|
||||||
class_invalid="is-invalid has-error"
|
input_type="password"
|
||||||
class_valid="has-success"
|
autocomplete="new-password"
|
||||||
autocomplete="new-password"
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
<Submit
|
||||||
<div class="invalid-feedback">
|
disabled={self.common.is_task_running()}
|
||||||
{&self.form.field_message("password")}
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}
|
||||||
</div>
|
text="Save changes" >
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="confirm_password"
|
|
||||||
class="form-label col-sm-2 col-form-label">
|
|
||||||
{"Confirm Password"}
|
|
||||||
<span class="text-danger">{"*"}</span>
|
|
||||||
{":"}
|
|
||||||
</label>
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<Field
|
|
||||||
form={&self.form}
|
|
||||||
field_name="confirm_password"
|
|
||||||
input_type="password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="new-password"
|
|
||||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("confirm_password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row justify-content-center">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary col-auto col-form-label"
|
|
||||||
type="submit"
|
|
||||||
disabled={self.common.is_task_running()}
|
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
|
||||||
<i class="bi-save me-2"></i>
|
|
||||||
{"Save changes"}
|
|
||||||
</button>
|
|
||||||
<Link
|
<Link
|
||||||
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
classes="btn btn-secondary ms-2 col-auto col-form-label"
|
||||||
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
|
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
|
||||||
<i class="bi-arrow-return-left me-2"></i>
|
<i class="bi-arrow-return-left me-2"></i>
|
||||||
{"Back"}
|
{"Back"}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</Submit>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::AppRoute,
|
components::{
|
||||||
|
form::{field::Field, submit::Submit},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
infra::common_component::{CommonComponent, CommonComponentParts},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Result};
|
use anyhow::{bail, Result};
|
||||||
@@ -93,44 +96,21 @@ impl Component for CreateGroupForm {
|
|||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
let link = ctx.link();
|
let link = ctx.link();
|
||||||
type Field = yew_form::Field<CreateGroupModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="form py-3" style="max-width: 636px">
|
<form class="form py-3" style="max-width: 636px">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<h5 class="fw-bold">{"Create a group"}</h5>
|
<h5 class="fw-bold">{"Create a group"}</h5>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group row mb-3">
|
<Field<CreateGroupModel>
|
||||||
<label for="groupname"
|
form={&self.form}
|
||||||
class="form-label col-4 col-form-label">
|
required=true
|
||||||
{"Group name"}
|
label="Group name"
|
||||||
<span class="text-danger">{"*"}</span>
|
field_name="groupname"
|
||||||
{":"}
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
</label>
|
<Submit
|
||||||
<div class="col-8">
|
disabled={self.common.is_task_running()}
|
||||||
<Field
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||||
form={&self.form}
|
|
||||||
field_name="groupname"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="groupname"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("groupname")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row justify-content-center">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary col-auto col-form-label"
|
|
||||||
type="submit"
|
|
||||||
disabled={self.common.is_task_running()}
|
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
|
||||||
<i class="bi-save me-2"></i>
|
|
||||||
{"Submit"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
|
|||||||
168
app/src/components/create_group_attribute.rs
Normal file
168
app/src/components/create_group_attribute.rs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::{validate_attribute_type, AttributeType},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use validator_derive::Validate;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_form_derive::Model;
|
||||||
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/create_group_attribute.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct CreateGroupAttribute;
|
||||||
|
|
||||||
|
convert_attribute_type!(create_group_attribute::AttributeType);
|
||||||
|
|
||||||
|
pub struct CreateGroupAttributeForm {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
form: yew_form::Form<CreateGroupAttributeModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
|
||||||
|
pub struct CreateGroupAttributeModel {
|
||||||
|
#[validate(length(min = 1, message = "attribute_name is required"))]
|
||||||
|
attribute_name: String,
|
||||||
|
#[validate(custom = "validate_attribute_type")]
|
||||||
|
attribute_type: String,
|
||||||
|
is_list: bool,
|
||||||
|
is_visible: bool, // remove when backend doesn't return group attributes for normal users
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
Update,
|
||||||
|
SubmitForm,
|
||||||
|
CreateGroupAttributeResponse(Result<create_group_attribute::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<CreateGroupAttributeForm> for CreateGroupAttributeForm {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::Update => Ok(true),
|
||||||
|
Msg::SubmitForm => {
|
||||||
|
if !self.form.validate() {
|
||||||
|
bail!("Check the form for errors");
|
||||||
|
}
|
||||||
|
let model = self.form.model();
|
||||||
|
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||||
|
let req = create_group_attribute::Variables {
|
||||||
|
name: model.attribute_name,
|
||||||
|
attribute_type: create_group_attribute::AttributeType::from(attribute_type),
|
||||||
|
is_list: model.is_list,
|
||||||
|
is_visible: model.is_visible,
|
||||||
|
};
|
||||||
|
self.common.call_graphql::<CreateGroupAttribute, _>(
|
||||||
|
ctx,
|
||||||
|
req,
|
||||||
|
Msg::CreateGroupAttributeResponse,
|
||||||
|
"Error trying to create group attribute",
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::CreateGroupAttributeResponse(response) => {
|
||||||
|
response?;
|
||||||
|
let model = self.form.model();
|
||||||
|
log!(&format!(
|
||||||
|
"Created group attribute '{}'",
|
||||||
|
model.attribute_name
|
||||||
|
));
|
||||||
|
ctx.link()
|
||||||
|
.history()
|
||||||
|
.unwrap()
|
||||||
|
.push(AppRoute::ListGroupSchema);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for CreateGroupAttributeForm {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = ();
|
||||||
|
|
||||||
|
fn create(_: &Context<Self>) -> Self {
|
||||||
|
let model = CreateGroupAttributeModel {
|
||||||
|
attribute_type: AttributeType::String.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
form: yew_form::Form::<CreateGroupAttributeModel>::new(model),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
|
html! {
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<form class="form py-3" style="max-width: 636px">
|
||||||
|
<h5 class="fw-bold">{"Create a group attribute"}</h5>
|
||||||
|
<Field<CreateGroupAttributeModel>
|
||||||
|
label="Name"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Select<CreateGroupAttributeModel>
|
||||||
|
label="Type"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_type"
|
||||||
|
oninput={link.callback(|_| Msg::Update)}>
|
||||||
|
<option selected=true value="String">{"String"}</option>
|
||||||
|
<option value="Integer">{"Integer"}</option>
|
||||||
|
<option value="Jpeg">{"Jpeg"}</option>
|
||||||
|
<option value="DateTime">{"DateTime"}</option>
|
||||||
|
</Select<CreateGroupAttributeModel>>
|
||||||
|
<CheckBox<CreateGroupAttributeModel>
|
||||||
|
label="Multiple values"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_list"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<CheckBox<CreateGroupAttributeModel>
|
||||||
|
label="Visible to users"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_visible"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<Submit
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
|
||||||
|
</form>
|
||||||
|
{ if let Some(e) = &self.common.error {
|
||||||
|
html! {
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{e.to_string() }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::AppRoute,
|
components::{
|
||||||
|
form::{field::Field, submit::Submit},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -187,163 +190,57 @@ impl Component for CreateUserForm {
|
|||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
let link = &ctx.link();
|
let link = &ctx.link();
|
||||||
type Field = yew_form::Field<CreateUserModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<form class="form py-3" style="max-width: 636px">
|
<form class="form py-3" style="max-width: 636px">
|
||||||
<div class="row mb-3">
|
<Field<CreateUserModel>
|
||||||
<h5 class="fw-bold">{"Create a user"}</h5>
|
form={&self.form}
|
||||||
</div>
|
required=true
|
||||||
<div class="form-group row mb-3">
|
label="User name"
|
||||||
<label for="username"
|
field_name="username"
|
||||||
class="form-label col-4 col-form-label">
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
{"User name"}
|
<Field<CreateUserModel>
|
||||||
<span class="text-danger">{"*"}</span>
|
form={&self.form}
|
||||||
{":"}
|
required=true
|
||||||
</label>
|
label="Email"
|
||||||
<div class="col-8">
|
field_name="email"
|
||||||
<Field
|
input_type="email"
|
||||||
form={&self.form}
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
field_name="username"
|
<Field<CreateUserModel>
|
||||||
class="form-control"
|
form={&self.form}
|
||||||
class_invalid="is-invalid has-error"
|
label="Display name"
|
||||||
class_valid="has-success"
|
field_name="display_name"
|
||||||
autocomplete="username"
|
autocomplete="name"
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="invalid-feedback">
|
<Field<CreateUserModel>
|
||||||
{&self.form.field_message("username")}
|
form={&self.form}
|
||||||
</div>
|
label="First name"
|
||||||
</div>
|
field_name="first_name"
|
||||||
</div>
|
autocomplete="given-name"
|
||||||
<div class="form-group row mb-3">
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<label for="email"
|
<Field<CreateUserModel>
|
||||||
class="form-label col-4 col-form-label">
|
form={&self.form}
|
||||||
{"Email"}
|
label="Last name"
|
||||||
<span class="text-danger">{"*"}</span>
|
field_name="last_name"
|
||||||
{":"}
|
autocomplete="family-name"
|
||||||
</label>
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="col-8">
|
<Field<CreateUserModel>
|
||||||
<Field
|
form={&self.form}
|
||||||
form={&self.form}
|
label="Password"
|
||||||
input_type="email"
|
field_name="password"
|
||||||
field_name="email"
|
input_type="password"
|
||||||
class="form-control"
|
autocomplete="new-password"
|
||||||
class_invalid="is-invalid has-error"
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
class_valid="has-success"
|
<Field<CreateUserModel>
|
||||||
autocomplete="email"
|
form={&self.form}
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
label="Confirm password"
|
||||||
<div class="invalid-feedback">
|
field_name="confirm_password"
|
||||||
{&self.form.field_message("email")}
|
input_type="password"
|
||||||
</div>
|
autocomplete="new-password"
|
||||||
</div>
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
</div>
|
<Submit
|
||||||
<div class="form-group row mb-3">
|
disabled={self.common.is_task_running()}
|
||||||
<label for="display_name"
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Display name:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form={&self.form}
|
|
||||||
autocomplete="name"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
field_name="display_name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("display_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="first_name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"First name:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form={&self.form}
|
|
||||||
autocomplete="given-name"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
field_name="first_name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("first_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="last_name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Last name:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form={&self.form}
|
|
||||||
autocomplete="family-name"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
field_name="last_name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("last_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="password"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Password:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form={&self.form}
|
|
||||||
input_type="password"
|
|
||||||
field_name="password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="new-password"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="confirm_password"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Confirm password:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
form={&self.form}
|
|
||||||
input_type="password"
|
|
||||||
field_name="confirm_password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="new-password"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("confirm_password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row justify-content-center">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary col-auto col-form-label mt-4"
|
|
||||||
disabled={self.common.is_task_running()}
|
|
||||||
type="submit"
|
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}>
|
|
||||||
<i class="bi-save me-2"></i>
|
|
||||||
{"Submit"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{
|
{
|
||||||
if let Some(e) = &self.common.error {
|
if let Some(e) = &self.common.error {
|
||||||
|
|||||||
175
app/src/components/create_user_attribute.rs
Normal file
175
app/src/components/create_user_attribute.rs
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
form::{checkbox::CheckBox, field::Field, select::Select, submit::Submit},
|
||||||
|
router::AppRoute,
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::{validate_attribute_type, AttributeType},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use validator_derive::Validate;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew_form_derive::Model;
|
||||||
|
use yew_router::{prelude::History, scope_ext::RouterScopeExt};
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/create_user_attribute.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct CreateUserAttribute;
|
||||||
|
|
||||||
|
convert_attribute_type!(create_user_attribute::AttributeType);
|
||||||
|
|
||||||
|
pub struct CreateUserAttributeForm {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
form: yew_form::Form<CreateUserAttributeModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Model, Validate, PartialEq, Eq, Clone, Default, Debug)]
|
||||||
|
pub struct CreateUserAttributeModel {
|
||||||
|
#[validate(length(min = 1, message = "attribute_name is required"))]
|
||||||
|
attribute_name: String,
|
||||||
|
#[validate(custom = "validate_attribute_type")]
|
||||||
|
attribute_type: String,
|
||||||
|
is_editable: bool,
|
||||||
|
is_list: bool,
|
||||||
|
is_visible: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
Update,
|
||||||
|
SubmitForm,
|
||||||
|
CreateUserAttributeResponse(Result<create_user_attribute::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<CreateUserAttributeForm> for CreateUserAttributeForm {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::Update => Ok(true),
|
||||||
|
Msg::SubmitForm => {
|
||||||
|
if !self.form.validate() {
|
||||||
|
bail!("Check the form for errors");
|
||||||
|
}
|
||||||
|
let model = self.form.model();
|
||||||
|
if model.is_editable && !model.is_visible {
|
||||||
|
bail!("Editable attributes must also be visible");
|
||||||
|
}
|
||||||
|
let attribute_type = model.attribute_type.parse::<AttributeType>().unwrap();
|
||||||
|
let req = create_user_attribute::Variables {
|
||||||
|
name: model.attribute_name,
|
||||||
|
attribute_type: create_user_attribute::AttributeType::from(attribute_type),
|
||||||
|
is_editable: model.is_editable,
|
||||||
|
is_list: model.is_list,
|
||||||
|
is_visible: model.is_visible,
|
||||||
|
};
|
||||||
|
self.common.call_graphql::<CreateUserAttribute, _>(
|
||||||
|
ctx,
|
||||||
|
req,
|
||||||
|
Msg::CreateUserAttributeResponse,
|
||||||
|
"Error trying to create user attribute",
|
||||||
|
);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::CreateUserAttributeResponse(response) => {
|
||||||
|
response?;
|
||||||
|
let model = self.form.model();
|
||||||
|
log!(&format!(
|
||||||
|
"Created user attribute '{}'",
|
||||||
|
model.attribute_name
|
||||||
|
));
|
||||||
|
ctx.link().history().unwrap().push(AppRoute::ListUserSchema);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for CreateUserAttributeForm {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = ();
|
||||||
|
|
||||||
|
fn create(_: &Context<Self>) -> Self {
|
||||||
|
let model = CreateUserAttributeModel {
|
||||||
|
attribute_type: AttributeType::String.to_string(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
form: yew_form::Form::<CreateUserAttributeModel>::new(model),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
|
html! {
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<form class="form py-3" style="max-width: 636px">
|
||||||
|
<h5 class="fw-bold">{"Create a user attribute"}</h5>
|
||||||
|
<Field<CreateUserAttributeModel>
|
||||||
|
label="Name"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_name"
|
||||||
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
|
<Select<CreateUserAttributeModel>
|
||||||
|
label="Type"
|
||||||
|
required={true}
|
||||||
|
form={&self.form}
|
||||||
|
field_name="attribute_type"
|
||||||
|
oninput={link.callback(|_| Msg::Update)}>
|
||||||
|
<option selected=true value="String">{"String"}</option>
|
||||||
|
<option value="Integer">{"Integer"}</option>
|
||||||
|
<option value="Jpeg">{"Jpeg"}</option>
|
||||||
|
<option value="DateTime">{"DateTime"}</option>
|
||||||
|
</Select<CreateUserAttributeModel>>
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Multiple values"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_list"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Visible to users"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_visible"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<CheckBox<CreateUserAttributeModel>
|
||||||
|
label="Editable by users"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="is_editable"
|
||||||
|
ontoggle={link.callback(|_| Msg::Update)} />
|
||||||
|
<Submit
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})}/>
|
||||||
|
</form>
|
||||||
|
{ if let Some(e) = &self.common.error {
|
||||||
|
html! {
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
{e.to_string() }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/src/components/delete_group_attribute.rs
Normal file
172
app/src/components/delete_group_attribute.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use crate::infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
modal::Modal,
|
||||||
|
};
|
||||||
|
use anyhow::{Error, Result};
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/delete_group_attribute.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct DeleteGroupAttributeQuery;
|
||||||
|
|
||||||
|
pub struct DeleteGroupAttribute {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
node_ref: NodeRef,
|
||||||
|
modal: Option<Modal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||||
|
pub struct DeleteGroupAttributeProps {
|
||||||
|
pub attribute_name: String,
|
||||||
|
pub on_attribute_deleted: Callback<String>,
|
||||||
|
pub on_error: Callback<Error>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ClickedDeleteGroupAttribute,
|
||||||
|
ConfirmDeleteGroupAttribute,
|
||||||
|
DismissModal,
|
||||||
|
DeleteGroupAttributeResponse(Result<delete_group_attribute_query::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<DeleteGroupAttribute> for DeleteGroupAttribute {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::ClickedDeleteGroupAttribute => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
|
}
|
||||||
|
Msg::ConfirmDeleteGroupAttribute => {
|
||||||
|
self.update(ctx, Msg::DismissModal);
|
||||||
|
self.common.call_graphql::<DeleteGroupAttributeQuery, _>(
|
||||||
|
ctx,
|
||||||
|
delete_group_attribute_query::Variables {
|
||||||
|
name: ctx.props().attribute_name.clone(),
|
||||||
|
},
|
||||||
|
Msg::DeleteGroupAttributeResponse,
|
||||||
|
"Error trying to delete group attribute",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Msg::DismissModal => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
|
}
|
||||||
|
Msg::DeleteGroupAttributeResponse(response) => {
|
||||||
|
response?;
|
||||||
|
ctx.props()
|
||||||
|
.on_attribute_deleted
|
||||||
|
.emit(ctx.props().attribute_name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for DeleteGroupAttribute {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = DeleteGroupAttributeProps;
|
||||||
|
|
||||||
|
fn create(_: &Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
node_ref: NodeRef::default(),
|
||||||
|
modal: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
|
if first_render {
|
||||||
|
self.modal = Some(Modal::new(
|
||||||
|
self.node_ref
|
||||||
|
.cast::<web_sys::Element>()
|
||||||
|
.expect("Modal node is not an element"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
|
self,
|
||||||
|
ctx,
|
||||||
|
msg,
|
||||||
|
ctx.props().on_error.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|_| Msg::ClickedDeleteGroupAttribute)}>
|
||||||
|
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||||
|
</button>
|
||||||
|
{self.show_modal(ctx)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteGroupAttribute {
|
||||||
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class="modal fade"
|
||||||
|
id={"deleteGroupAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="deleteGroupAttributeModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
ref={self.node_ref.clone()}>
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteGroupAttributeModalLabel">{"Delete group attribute?"}</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Close"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<span>
|
||||||
|
{"Are you sure you want to delete group attribute "}
|
||||||
|
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
|
<i class="bi-x-circle me-2"></i>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| Msg::ConfirmDeleteGroupAttribute)}
|
||||||
|
class="btn btn-danger">
|
||||||
|
<i class="bi-check-circle me-2"></i>
|
||||||
|
{"Yes, I'm sure"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
172
app/src/components/delete_user_attribute.rs
Normal file
172
app/src/components/delete_user_attribute.rs
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
use crate::infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
modal::Modal,
|
||||||
|
};
|
||||||
|
use anyhow::{Error, Result};
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/delete_user_attribute.graphql",
|
||||||
|
response_derives = "Debug",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct DeleteUserAttributeQuery;
|
||||||
|
|
||||||
|
pub struct DeleteUserAttribute {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
node_ref: NodeRef,
|
||||||
|
modal: Option<Modal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Debug)]
|
||||||
|
pub struct DeleteUserAttributeProps {
|
||||||
|
pub attribute_name: String,
|
||||||
|
pub on_attribute_deleted: Callback<String>,
|
||||||
|
pub on_error: Callback<Error>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ClickedDeleteUserAttribute,
|
||||||
|
ConfirmDeleteUserAttribute,
|
||||||
|
DismissModal,
|
||||||
|
DeleteUserAttributeResponse(Result<delete_user_attribute_query::ResponseData>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<DeleteUserAttribute> for DeleteUserAttribute {
|
||||||
|
fn handle_msg(
|
||||||
|
&mut self,
|
||||||
|
ctx: &Context<Self>,
|
||||||
|
msg: <Self as Component>::Message,
|
||||||
|
) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::ClickedDeleteUserAttribute => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").show();
|
||||||
|
}
|
||||||
|
Msg::ConfirmDeleteUserAttribute => {
|
||||||
|
self.update(ctx, Msg::DismissModal);
|
||||||
|
self.common.call_graphql::<DeleteUserAttributeQuery, _>(
|
||||||
|
ctx,
|
||||||
|
delete_user_attribute_query::Variables {
|
||||||
|
name: ctx.props().attribute_name.clone(),
|
||||||
|
},
|
||||||
|
Msg::DeleteUserAttributeResponse,
|
||||||
|
"Error trying to delete user attribute",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Msg::DismissModal => {
|
||||||
|
self.modal.as_ref().expect("modal not initialized").hide();
|
||||||
|
}
|
||||||
|
Msg::DeleteUserAttributeResponse(response) => {
|
||||||
|
response?;
|
||||||
|
ctx.props()
|
||||||
|
.on_attribute_deleted
|
||||||
|
.emit(ctx.props().attribute_name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for DeleteUserAttribute {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = DeleteUserAttributeProps;
|
||||||
|
|
||||||
|
fn create(_: &Context<Self>) -> Self {
|
||||||
|
Self {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
node_ref: NodeRef::default(),
|
||||||
|
modal: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rendered(&mut self, _: &Context<Self>, first_render: bool) {
|
||||||
|
if first_render {
|
||||||
|
self.modal = Some(Modal::new(
|
||||||
|
self.node_ref
|
||||||
|
.cast::<web_sys::Element>()
|
||||||
|
.expect("Modal node is not an element"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update_and_report_error(
|
||||||
|
self,
|
||||||
|
ctx,
|
||||||
|
msg,
|
||||||
|
ctx.props().on_error.clone(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|_| Msg::ClickedDeleteUserAttribute)}>
|
||||||
|
<i class="bi-x-circle-fill" aria-label="Delete attribute" />
|
||||||
|
</button>
|
||||||
|
{self.show_modal(ctx)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DeleteUserAttribute {
|
||||||
|
fn show_modal(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let link = &ctx.link();
|
||||||
|
html! {
|
||||||
|
<div
|
||||||
|
class="modal fade"
|
||||||
|
id={"deleteUserAttributeModal".to_string() + &ctx.props().attribute_name}
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="deleteUserAttributeModalLabel"
|
||||||
|
aria-hidden="true"
|
||||||
|
ref={self.node_ref.clone()}>
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteUserAttributeModalLabel">{"Delete user attribute?"}</h5>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
aria-label="Close"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)} />
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<span>
|
||||||
|
{"Are you sure you want to delete user attribute "}
|
||||||
|
<b>{&ctx.props().attribute_name}</b>{"?"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-secondary"
|
||||||
|
onclick={link.callback(|_| Msg::DismissModal)}>
|
||||||
|
<i class="bi-x-circle me-2"></i>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={link.callback(|_| Msg::ConfirmDeleteUserAttribute)}
|
||||||
|
class="btn btn-danger">
|
||||||
|
<i class="bi-check-circle me-2"></i>
|
||||||
|
{"Yes, I'm sure"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
70
app/src/components/form/attribute_input.rs
Normal file
70
app/src/components/form/attribute_input.rs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
use crate::infra::schema::AttributeType;
|
||||||
|
use yew::{
|
||||||
|
function_component, html, virtual_dom::AttrValue, Callback, InputEvent, NodeRef, Properties,
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
<input
|
||||||
|
ref={&ctx.props().input_ref}
|
||||||
|
type="text"
|
||||||
|
class="input-component"
|
||||||
|
placeholder={placeholder}
|
||||||
|
onmouseover={ctx.link().callback(|_| Msg::Hover)}
|
||||||
|
/>
|
||||||
|
*/
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct AttributeInputProps {
|
||||||
|
name: AttrValue,
|
||||||
|
attribute_type: AttributeType,
|
||||||
|
#[prop_or(None)]
|
||||||
|
value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(AttributeInput)]
|
||||||
|
fn attribute_input(props: &AttributeInputProps) -> Html {
|
||||||
|
let input_type = match props.attribute_type {
|
||||||
|
AttributeType::String => "text",
|
||||||
|
AttributeType::Integer => "number",
|
||||||
|
AttributeType::DateTime => "datetime-local",
|
||||||
|
AttributeType::Jpeg => "file",
|
||||||
|
};
|
||||||
|
let accept = match props.attribute_type {
|
||||||
|
AttributeType::Jpeg => Some("image/jpeg"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
html! {
|
||||||
|
<input
|
||||||
|
type={input_type}
|
||||||
|
accept={accept}
|
||||||
|
name={props.name.clone()}
|
||||||
|
class="form-control"
|
||||||
|
value={props.value.clone()} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct SingleAttributeInputProps {
|
||||||
|
pub name: String,
|
||||||
|
pub attribute_type: AttributeType,
|
||||||
|
#[prop_or(None)]
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(SingleAttributeInput)]
|
||||||
|
pub fn single_attribute_input(props: &SingleAttributeInputProps) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for={props.name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.name}{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<AttributeInput
|
||||||
|
attribute_type={props.attribute_type.clone()}
|
||||||
|
name={props.name.clone()}
|
||||||
|
value={props.value.clone()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/src/components/form/checkbox.rs
Normal file
35
app/src/components/form/checkbox.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Properties};
|
||||||
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props<T: Model> {
|
||||||
|
pub label: AttrValue,
|
||||||
|
pub field_name: String,
|
||||||
|
pub form: Form<T>,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
|
#[prop_or_else(Callback::noop)]
|
||||||
|
pub ontoggle: Callback<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(CheckBox)]
|
||||||
|
pub fn checkbox<T: Model>(props: &Props<T>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="form-group row mb-3">
|
||||||
|
<label for={props.field_name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.label}
|
||||||
|
{if props.required {
|
||||||
|
html!{<span class="text-danger">{"*"}</span>}
|
||||||
|
} else {html!{}}}
|
||||||
|
{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<yew_form::CheckBox<T>
|
||||||
|
form={&props.form}
|
||||||
|
field_name={props.field_name.clone()}
|
||||||
|
ontoggle={props.ontoggle.clone()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/src/components/form/field.rs
Normal file
48
app/src/components/form/field.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, InputEvent, Properties};
|
||||||
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props<T: Model> {
|
||||||
|
pub label: AttrValue,
|
||||||
|
pub field_name: String,
|
||||||
|
pub form: Form<T>,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
|
#[prop_or(String::from("text"))]
|
||||||
|
pub input_type: String,
|
||||||
|
// If not present, will default to field_name
|
||||||
|
#[prop_or(None)]
|
||||||
|
pub autocomplete: Option<String>,
|
||||||
|
#[prop_or_else(Callback::noop)]
|
||||||
|
pub oninput: Callback<InputEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Field)]
|
||||||
|
pub fn field<T: Model>(props: &Props<T>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for={props.field_name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.label}
|
||||||
|
{if props.required {
|
||||||
|
html!{<span class="text-danger">{"*"}</span>}
|
||||||
|
} else {html!{}}}
|
||||||
|
{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<yew_form::Field<T>
|
||||||
|
form={&props.form}
|
||||||
|
field_name={props.field_name.clone()}
|
||||||
|
input_type={props.input_type.clone()}
|
||||||
|
class="form-control"
|
||||||
|
class_invalid="is-invalid has-error"
|
||||||
|
class_valid="has-success"
|
||||||
|
autocomplete={props.autocomplete.clone().unwrap_or(props.field_name.clone())}
|
||||||
|
oninput={&props.oninput} />
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
{&props.form.field_message(&props.field_name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/src/components/form/mod.rs
Normal file
6
app/src/components/form/mod.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
pub mod attribute_input;
|
||||||
|
pub mod checkbox;
|
||||||
|
pub mod field;
|
||||||
|
pub mod select;
|
||||||
|
pub mod static_value;
|
||||||
|
pub mod submit;
|
||||||
46
app/src/components/form/select.rs
Normal file
46
app/src/components/form/select.rs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
use yew::{
|
||||||
|
function_component, html, virtual_dom::AttrValue, Callback, Children, InputEvent, Properties,
|
||||||
|
};
|
||||||
|
use yew_form::{Form, Model};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props<T: Model> {
|
||||||
|
pub label: AttrValue,
|
||||||
|
pub field_name: String,
|
||||||
|
pub form: Form<T>,
|
||||||
|
#[prop_or(false)]
|
||||||
|
pub required: bool,
|
||||||
|
#[prop_or_else(Callback::noop)]
|
||||||
|
pub oninput: Callback<InputEvent>,
|
||||||
|
pub children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Select)]
|
||||||
|
pub fn select<T: Model>(props: &Props<T>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for={props.field_name.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.label}
|
||||||
|
{if props.required {
|
||||||
|
html!{<span class="text-danger">{"*"}</span>}
|
||||||
|
} else {html!{}}}
|
||||||
|
{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<yew_form::Select<T>
|
||||||
|
form={&props.form}
|
||||||
|
class="form-control"
|
||||||
|
class_invalid="is-invalid has-error"
|
||||||
|
class_valid="has-success"
|
||||||
|
field_name={props.field_name.clone()}
|
||||||
|
oninput={&props.oninput} >
|
||||||
|
{for props.children.iter()}
|
||||||
|
</yew_form::Select<T>>
|
||||||
|
<div class="invalid-feedback">
|
||||||
|
{&props.form.field_message(&props.field_name)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/src/components/form/static_value.rs
Normal file
26
app/src/components/form/static_value.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Children, Properties};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub label: AttrValue,
|
||||||
|
pub id: AttrValue,
|
||||||
|
pub children: Children,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(StaticValue)]
|
||||||
|
pub fn static_value(props: &Props) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="row mb-3">
|
||||||
|
<label for={props.id.clone()}
|
||||||
|
class="form-label col-4 col-form-label">
|
||||||
|
{&props.label}
|
||||||
|
{":"}
|
||||||
|
</label>
|
||||||
|
<div class="col-8">
|
||||||
|
<span id={props.id.clone()} class="form-control-static">
|
||||||
|
{for props.children.iter()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/src/components/form/submit.rs
Normal file
30
app/src/components/form/submit.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use web_sys::MouseEvent;
|
||||||
|
use yew::{function_component, html, virtual_dom::AttrValue, Callback, Children, Properties};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub disabled: bool,
|
||||||
|
pub onclick: Callback<MouseEvent>,
|
||||||
|
// Additional elements to insert after the button, in the same div
|
||||||
|
#[prop_or_default]
|
||||||
|
pub children: Children,
|
||||||
|
#[prop_or(AttrValue::from("Submit"))]
|
||||||
|
pub text: AttrValue,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(Submit)]
|
||||||
|
pub fn submit(props: &Props) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="form-group row justify-content-center">
|
||||||
|
<button
|
||||||
|
class="btn btn-primary col-auto col-form-label"
|
||||||
|
type="submit"
|
||||||
|
disabled={props.disabled}
|
||||||
|
onclick={&props.onclick}>
|
||||||
|
<i class="bi-save me-2"></i>
|
||||||
|
{props.text.clone()}
|
||||||
|
</button>
|
||||||
|
{for props.children.iter()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
198
app/src/components/group_schema_table.rs
Normal file
198
app/src/components/group_schema_table.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
delete_group_attribute::DeleteGroupAttribute,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{anyhow, Error, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_group_attributes_schema.graphql",
|
||||||
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetGroupAttributesSchema;
|
||||||
|
|
||||||
|
use get_group_attributes_schema::ResponseData;
|
||||||
|
|
||||||
|
pub type Attribute =
|
||||||
|
get_group_attributes_schema::GetGroupAttributesSchemaSchemaGroupSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_group_attributes_schema::AttributeType);
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub hardcoded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GroupSchemaTable {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
attributes: Option<Vec<Attribute>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ListAttributesResponse(Result<ResponseData>),
|
||||||
|
OnAttributeDeleted(String),
|
||||||
|
OnError(Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<GroupSchemaTable> for GroupSchemaTable {
|
||||||
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::ListAttributesResponse(schema) => {
|
||||||
|
self.attributes =
|
||||||
|
Some(schema?.schema.group_schema.attributes.into_iter().collect());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::OnError(e) => Err(e),
|
||||||
|
Msg::OnAttributeDeleted(attribute_name) => {
|
||||||
|
match self.attributes {
|
||||||
|
None => {
|
||||||
|
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
|
||||||
|
Err(anyhow!("invalid state"))
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
self.attributes
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|a| a.name != attribute_name);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for GroupSchemaTable {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = Props;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
let mut table = GroupSchemaTable {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
attributes: None,
|
||||||
|
};
|
||||||
|
table.common.call_graphql::<GetGroupAttributesSchema, _>(
|
||||||
|
ctx,
|
||||||
|
get_group_attributes_schema::Variables {},
|
||||||
|
Msg::ListAttributesResponse,
|
||||||
|
"Error trying to fetch group schema",
|
||||||
|
);
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
{self.view_attributes(ctx)}
|
||||||
|
{self.view_errors()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GroupSchemaTable {
|
||||||
|
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
let make_table = |attributes: &Vec<Attribute>| {
|
||||||
|
html! {
|
||||||
|
<div class="table-responsive">
|
||||||
|
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{"Attribute name"}</th>
|
||||||
|
<th>{"Type"}</th>
|
||||||
|
<th>{"Visible"}</th>
|
||||||
|
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match &self.attributes {
|
||||||
|
None => html! {{"Loading..."}},
|
||||||
|
Some(attributes) => {
|
||||||
|
let mut attributes = attributes.clone();
|
||||||
|
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
|
||||||
|
make_table(&attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
|
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||||
|
let checkmark = html! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||||
|
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
||||||
|
</svg>
|
||||||
|
};
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
html! {
|
||||||
|
<tr key={attribute.name.clone()}>
|
||||||
|
<td>{&attribute.name}</td>
|
||||||
|
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
||||||
|
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
||||||
|
{
|
||||||
|
if hardcoded {
|
||||||
|
html!{}
|
||||||
|
} else {
|
||||||
|
html!{
|
||||||
|
<td>
|
||||||
|
<DeleteGroupAttribute
|
||||||
|
attribute_name={attribute.name.clone()}
|
||||||
|
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
|
||||||
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_errors(&self) -> Html {
|
||||||
|
match &self.common.error {
|
||||||
|
None => html! {},
|
||||||
|
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ListGroupSchema)]
|
||||||
|
pub fn list_group_schema() -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<GroupSchemaTable hardcoded={true} />
|
||||||
|
<GroupSchemaTable hardcoded={false} />
|
||||||
|
<Link classes="btn btn-primary" to={AppRoute::CreateGroupAttribute}>
|
||||||
|
<i class="bi-plus-circle me-2"></i>
|
||||||
|
{"Create an attribute"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, Link},
|
components::{
|
||||||
|
form::submit::Submit,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -155,68 +158,62 @@ impl Component for LoginForm {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
<form
|
<form class="form center-block col-sm-4 col-offset-4">
|
||||||
class="form center-block col-sm-4 col-offset-4">
|
<div class="input-group">
|
||||||
<div class="input-group">
|
<div class="input-group-prepend">
|
||||||
<div class="input-group-prepend">
|
<span class="input-group-text">
|
||||||
<span class="input-group-text">
|
<i class="bi-person-fill"/>
|
||||||
<i class="bi-person-fill"/>
|
</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Field
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
form={&self.form}
|
|
||||||
field_name="username"
|
|
||||||
placeholder="Username"
|
|
||||||
autocomplete="username"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group">
|
<Field
|
||||||
<div class="input-group-prepend">
|
class="form-control"
|
||||||
<span class="input-group-text">
|
class_invalid="is-invalid has-error"
|
||||||
<i class="bi-lock-fill"/>
|
class_valid="has-success"
|
||||||
</span>
|
form={&self.form}
|
||||||
</div>
|
field_name="username"
|
||||||
<Field
|
placeholder="Username"
|
||||||
class="form-control"
|
autocomplete="username"
|
||||||
class_invalid="is-invalid has-error"
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
class_valid="has-success"
|
</div>
|
||||||
form={&self.form}
|
<div class="input-group">
|
||||||
field_name="password"
|
<div class="input-group-prepend">
|
||||||
input_type="password"
|
<span class="input-group-text">
|
||||||
placeholder="Password"
|
<i class="bi-lock-fill"/>
|
||||||
autocomplete="current-password" />
|
</span>
|
||||||
</div>
|
|
||||||
<div class="form-group mt-3">
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
class="btn btn-primary"
|
|
||||||
disabled={self.common.is_task_running()}
|
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
|
||||||
<i class="bi-box-arrow-in-right me-2"/>
|
|
||||||
{"Login"}
|
|
||||||
</button>
|
|
||||||
{ if password_reset_enabled {
|
|
||||||
html! {
|
|
||||||
<Link
|
|
||||||
classes="btn-link btn"
|
|
||||||
disabled={self.common.is_task_running()}
|
|
||||||
to={AppRoute::StartResetPassword}>
|
|
||||||
{"Forgot your password?"}
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
html!{}
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
{ if let Some(e) = &self.common.error {
|
|
||||||
html! { e.to_string() }
|
|
||||||
} else { html! {} }
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Field
|
||||||
|
class="form-control"
|
||||||
|
class_invalid="is-invalid has-error"
|
||||||
|
class_valid="has-success"
|
||||||
|
form={&self.form}
|
||||||
|
field_name="password"
|
||||||
|
input_type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
autocomplete="current-password" />
|
||||||
|
</div>
|
||||||
|
<Submit
|
||||||
|
text="Login"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
||||||
|
{ if password_reset_enabled {
|
||||||
|
html! {
|
||||||
|
<Link
|
||||||
|
classes="btn-link btn"
|
||||||
|
disabled={self.common.is_task_running()}
|
||||||
|
to={AppRoute::StartResetPassword}>
|
||||||
|
{"Forgot your password?"}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html!{}
|
||||||
|
}}
|
||||||
|
</Submit>
|
||||||
|
<div class="form-group">
|
||||||
|
{ if let Some(e) = &self.common.error {
|
||||||
|
html! { e.to_string() }
|
||||||
|
} else { html! {} }
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
pub mod add_group_member;
|
pub mod add_group_member;
|
||||||
pub mod add_user_to_group;
|
pub mod add_user_to_group;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod avatar;
|
||||||
|
pub mod banner;
|
||||||
pub mod change_password;
|
pub mod change_password;
|
||||||
pub mod create_group;
|
pub mod create_group;
|
||||||
|
pub mod create_group_attribute;
|
||||||
pub mod create_user;
|
pub mod create_user;
|
||||||
|
pub mod create_user_attribute;
|
||||||
pub mod delete_group;
|
pub mod delete_group;
|
||||||
|
pub mod delete_group_attribute;
|
||||||
pub mod delete_user;
|
pub mod delete_user;
|
||||||
|
pub mod delete_user_attribute;
|
||||||
|
pub mod form;
|
||||||
pub mod group_details;
|
pub mod group_details;
|
||||||
|
pub mod group_schema_table;
|
||||||
pub mod group_table;
|
pub mod group_table;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod logout;
|
pub mod logout;
|
||||||
@@ -17,4 +25,5 @@ pub mod router;
|
|||||||
pub mod select;
|
pub mod select;
|
||||||
pub mod user_details;
|
pub mod user_details;
|
||||||
pub mod user_details_form;
|
pub mod user_details_form;
|
||||||
|
pub mod user_schema_table;
|
||||||
pub mod user_table;
|
pub mod user_table;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
components::router::{AppRoute, Link},
|
components::{
|
||||||
|
form::{field::Field, submit::Submit},
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
infra::{
|
infra::{
|
||||||
api::HostService,
|
api::HostService,
|
||||||
common_component::{CommonComponent, CommonComponentParts},
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
@@ -164,61 +167,29 @@ impl Component for ResetPasswordStep2Form {
|
|||||||
}
|
}
|
||||||
_ => (),
|
_ => (),
|
||||||
};
|
};
|
||||||
type Field = yew_form::Field<FormModel>;
|
|
||||||
html! {
|
html! {
|
||||||
<>
|
<>
|
||||||
<h2>{"Reset your password"}</h2>
|
<h2>{"Reset your password"}</h2>
|
||||||
<form
|
<form class="form">
|
||||||
class="form">
|
<Field<FormModel>
|
||||||
<div class="form-group row">
|
label="New password"
|
||||||
<label for="new_password"
|
required=true
|
||||||
class="form-label col-sm-2 col-form-label">
|
form={&self.form}
|
||||||
{"New password*:"}
|
field_name="password"
|
||||||
</label>
|
autocomplete="new-password"
|
||||||
<div class="col-sm-10">
|
input_type="password"
|
||||||
<Field
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
form={&self.form}
|
<Field<FormModel>
|
||||||
field_name="password"
|
label="Confirm password"
|
||||||
class="form-control"
|
required=true
|
||||||
class_invalid="is-invalid has-error"
|
form={&self.form}
|
||||||
class_valid="has-success"
|
field_name="confirm_password"
|
||||||
autocomplete="new-password"
|
autocomplete="new-password"
|
||||||
input_type="password"
|
input_type="password"
|
||||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
oninput={link.callback(|_| Msg::FormUpdate)} />
|
||||||
<div class="invalid-feedback">
|
<Submit
|
||||||
{&self.form.field_message("password")}
|
disabled={self.common.is_task_running()}
|
||||||
</div>
|
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})} />
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row">
|
|
||||||
<label for="confirm_password"
|
|
||||||
class="form-label col-sm-2 col-form-label">
|
|
||||||
{"Confirm password*:"}
|
|
||||||
</label>
|
|
||||||
<div class="col-sm-10">
|
|
||||||
<Field
|
|
||||||
form={&self.form}
|
|
||||||
field_name="confirm_password"
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
autocomplete="new-password"
|
|
||||||
input_type="password"
|
|
||||||
oninput={link.callback(|_| Msg::FormUpdate)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("confirm_password")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mt-2">
|
|
||||||
<button
|
|
||||||
class="btn btn-primary col-sm-1 col-form-label"
|
|
||||||
type="submit"
|
|
||||||
disabled={self.common.is_task_running()}
|
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}>
|
|
||||||
{"Submit"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{ if let Some(e) = &self.common.error {
|
{ if let Some(e) = &self.common.error {
|
||||||
html! {
|
html! {
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ pub enum AppRoute {
|
|||||||
ListGroups,
|
ListGroups,
|
||||||
#[at("/group/:group_id")]
|
#[at("/group/:group_id")]
|
||||||
GroupDetails { group_id: i64 },
|
GroupDetails { group_id: i64 },
|
||||||
|
#[at("/user-attributes")]
|
||||||
|
ListUserSchema,
|
||||||
|
#[at("/user-attributes/create")]
|
||||||
|
CreateUserAttribute,
|
||||||
|
#[at("/group-attributes")]
|
||||||
|
ListGroupSchema,
|
||||||
|
#[at("/group-attributes/create")]
|
||||||
|
CreateGroupAttribute,
|
||||||
#[at("/")]
|
#[at("/")]
|
||||||
Index,
|
Index,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ use crate::{
|
|||||||
remove_user_from_group::RemoveUserFromGroupComponent,
|
remove_user_from_group::RemoveUserFromGroupComponent,
|
||||||
router::{AppRoute, Link},
|
router::{AppRoute, Link},
|
||||||
user_details_form::UserDetailsForm,
|
user_details_form::UserDetailsForm,
|
||||||
},
|
}, infra::{schema::AttributeType, common_component::{CommonComponent, CommonComponentParts}},
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
convert_attribute_type
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{bail, Error, Result};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
@@ -22,6 +22,10 @@ pub struct GetUserDetails;
|
|||||||
|
|
||||||
pub type User = get_user_details::GetUserDetailsUser;
|
pub type User = get_user_details::GetUserDetailsUser;
|
||||||
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
pub type Group = get_user_details::GetUserDetailsUserGroups;
|
||||||
|
pub type Attribute = get_user_details::GetUserDetailsUserAttributes;
|
||||||
|
pub type AttributeSchema = get_user_details::GetUserDetailsUserAttributesSchema;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_user_details::AttributeType);
|
||||||
|
|
||||||
pub struct UserDetails {
|
pub struct UserDetails {
|
||||||
common: CommonComponentParts<Self>,
|
common: CommonComponentParts<Self>,
|
||||||
|
|||||||
@@ -1,20 +1,26 @@
|
|||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components::user_details::User,
|
components::{
|
||||||
infra::common_component::{CommonComponent, CommonComponentParts},
|
form::{attribute_input::SingleAttributeInput, field::Field, static_value::StaticValue, submit::Submit},
|
||||||
|
user_details::{AttributeSchema, User},
|
||||||
|
}, convert_attribute_type, infra::{common_component::{CommonComponent, CommonComponentParts}, schema::AttributeType}
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Error, Result};
|
use anyhow::{anyhow, bail, Error, Ok, Result};
|
||||||
|
use gloo_console::log;
|
||||||
use gloo_file::{
|
use gloo_file::{
|
||||||
callbacks::{read_as_bytes, FileReader},
|
callbacks::{read_as_bytes, FileReader},
|
||||||
File,
|
File,
|
||||||
};
|
};
|
||||||
use graphql_client::GraphQLQuery;
|
use graphql_client::GraphQLQuery;
|
||||||
|
use validator::HasLen;
|
||||||
use validator_derive::Validate;
|
use validator_derive::Validate;
|
||||||
use web_sys::{FileList, HtmlInputElement, InputEvent};
|
use web_sys::{FileList, FormData, HtmlFormElement, HtmlInputElement, InputEvent};
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_form_derive::Model;
|
use yew_form_derive::Model;
|
||||||
|
|
||||||
|
use super::user_details::Attribute;
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct JsFile {
|
struct JsFile {
|
||||||
file: Option<File>,
|
file: Option<File>,
|
||||||
@@ -70,6 +76,7 @@ pub struct UserDetailsForm {
|
|||||||
/// True if we just successfully updated the user, to display a success message.
|
/// True if we just successfully updated the user, to display a success message.
|
||||||
just_updated: bool,
|
just_updated: bool,
|
||||||
user: User,
|
user: User,
|
||||||
|
form_ref: NodeRef,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum Msg {
|
pub enum Msg {
|
||||||
@@ -147,7 +154,14 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
|
|||||||
}
|
}
|
||||||
self.reader = None;
|
self.reader = None;
|
||||||
Ok(false)
|
Ok(false)
|
||||||
}
|
} // Msg::OnSubmit(e) => {
|
||||||
|
// e.prevent_default();
|
||||||
|
// let form: HtmlFormElement = e.target_unchecked_into();
|
||||||
|
// let data = FormData::new_with_form(&form).unwrap();
|
||||||
|
// log!(format!("form data{:#?}", data));
|
||||||
|
// log!(format!("form data data{:#?}", *data));
|
||||||
|
// Ok(true)
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +188,7 @@ impl Component for UserDetailsForm {
|
|||||||
just_updated: false,
|
just_updated: false,
|
||||||
reader: None,
|
reader: None,
|
||||||
user: ctx.props().user.clone(),
|
user: ctx.props().user.clone(),
|
||||||
|
form_ref: NodeRef::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +198,6 @@ impl Component for UserDetailsForm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn view(&self, ctx: &Context<Self>) -> Html {
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
type Field = yew_form::Field<UserModel>;
|
|
||||||
let link = &ctx.link();
|
let link = &ctx.link();
|
||||||
|
|
||||||
let avatar_string = match &self.avatar {
|
let avatar_string = match &self.avatar {
|
||||||
@@ -196,107 +210,40 @@ impl Component for UserDetailsForm {
|
|||||||
html! {
|
html! {
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
<form class="form">
|
<form class="form">
|
||||||
<div class="form-group row mb-3">
|
<StaticValue label="User ID" id="userId">
|
||||||
<label for="userId"
|
<i>{&self.user.id}</i>
|
||||||
class="form-label col-4 col-form-label">
|
</StaticValue>
|
||||||
{"User ID: "}
|
<StaticValue label="Creation date" id="creationDate">
|
||||||
</label>
|
{&self.user.creation_date.naive_local().date()}
|
||||||
<div class="col-8">
|
</StaticValue>
|
||||||
<span id="userId" class="form-control-static"><i>{&self.user.id}</i></span>
|
<StaticValue label="UUID" id="uuid">
|
||||||
</div>
|
{&self.user.uuid}
|
||||||
</div>
|
</StaticValue>
|
||||||
<div class="form-group row mb-3">
|
<Field<UserModel>
|
||||||
<label for="creationDate"
|
form={&self.form}
|
||||||
class="form-label col-4 col-form-label">
|
required=true
|
||||||
{"Creation date: "}
|
label="Email"
|
||||||
</label>
|
field_name="email"
|
||||||
<div class="col-8">
|
input_type="email"
|
||||||
<span id="creationDate" class="form-control-static">{&self.user.creation_date.naive_local().date()}</span>
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
</div>
|
<Field<UserModel>
|
||||||
</div>
|
form={&self.form}
|
||||||
<div class="form-group row mb-3">
|
label="Display name"
|
||||||
<label for="uuid"
|
field_name="display_name"
|
||||||
class="form-label col-4 col-form-label">
|
autocomplete="name"
|
||||||
{"UUID: "}
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
</label>
|
<Field<UserModel>
|
||||||
<div class="col-8">
|
form={&self.form}
|
||||||
<span id="creationDate" class="form-control-static">{&self.user.uuid}</span>
|
label="First name"
|
||||||
</div>
|
field_name="first_name"
|
||||||
</div>
|
autocomplete="given-name"
|
||||||
<div class="form-group row mb-3">
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<label for="email"
|
<Field<UserModel>
|
||||||
class="form-label col-4 col-form-label">
|
form={&self.form}
|
||||||
{"Email"}
|
label="Last name"
|
||||||
<span class="text-danger">{"*"}</span>
|
field_name="last_name"
|
||||||
{":"}
|
autocomplete="family-name"
|
||||||
</label>
|
oninput={link.callback(|_| Msg::Update)} />
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
form={&self.form}
|
|
||||||
field_name="email"
|
|
||||||
autocomplete="email"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("email")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="display_name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Display Name: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
class="form-control"
|
|
||||||
class_invalid="is-invalid has-error"
|
|
||||||
class_valid="has-success"
|
|
||||||
form={&self.form}
|
|
||||||
field_name="display_name"
|
|
||||||
autocomplete="name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("display_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="first_name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"First Name: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
class="form-control"
|
|
||||||
form={&self.form}
|
|
||||||
field_name="first_name"
|
|
||||||
autocomplete="given-name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("first_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row mb-3">
|
|
||||||
<label for="last_name"
|
|
||||||
class="form-label col-4 col-form-label">
|
|
||||||
{"Last Name: "}
|
|
||||||
</label>
|
|
||||||
<div class="col-8">
|
|
||||||
<Field
|
|
||||||
class="form-control"
|
|
||||||
form={&self.form}
|
|
||||||
field_name="last_name"
|
|
||||||
autocomplete="family-name"
|
|
||||||
oninput={link.callback(|_| Msg::Update)} />
|
|
||||||
<div class="invalid-feedback">
|
|
||||||
{&self.form.field_message("last_name")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="form-group row align-items-center mb-3">
|
<div class="form-group row align-items-center mb-3">
|
||||||
<label for="avatar"
|
<label for="avatar"
|
||||||
class="form-label col-4 col-form-label">
|
class="form-label col-4 col-form-label">
|
||||||
@@ -340,16 +287,11 @@ impl Component for UserDetailsForm {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group row justify-content-center mt-3">
|
{self.user.attributes.iter().map(get_custom_attribute_input).collect::<Vec<_>>()}
|
||||||
<button
|
<Submit
|
||||||
type="submit"
|
text="Save changes"
|
||||||
class="btn btn-primary col-auto col-form-label"
|
disabled={self.common.is_task_running()}
|
||||||
disabled={self.common.is_task_running()}
|
onclick={link.callback(|e: MouseEvent| {Msg::SubmitClicked})} />
|
||||||
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})}>
|
|
||||||
<i class="bi-save me-2"></i>
|
|
||||||
{"Save changes"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
{
|
{
|
||||||
if let Some(e) = &self.common.error {
|
if let Some(e) = &self.common.error {
|
||||||
@@ -368,6 +310,45 @@ impl Component for UserDetailsForm {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AttributeValue = (String, Vec<String>);
|
||||||
|
|
||||||
|
fn get_values_from_form_data(
|
||||||
|
schema: Vec<AttributeSchema>,
|
||||||
|
form: &FormData,
|
||||||
|
) -> Result<Vec<AttributeValue>> {
|
||||||
|
schema
|
||||||
|
.into_iter()
|
||||||
|
.map(|attr| -> Result<AttributeValue> {
|
||||||
|
let val = form
|
||||||
|
.get_all(attr.name.as_str())
|
||||||
|
.iter()
|
||||||
|
.map(|js_val| js_val.as_string().unwrap())
|
||||||
|
.filter(|val| !val.is_empty())
|
||||||
|
.collect::<Vec<String>>();
|
||||||
|
if val.length() > 1 && !attr.is_list {
|
||||||
|
return Err(anyhow!(
|
||||||
|
"Multiple values supplied for non-list attribute {}",
|
||||||
|
attr.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((attr.name.clone(), val))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_custom_attribute_input(attribute: &Attribute) -> Html {
|
||||||
|
if attribute.schema.is_list {
|
||||||
|
html!{<p>{"list attr"}</p>}
|
||||||
|
} else {
|
||||||
|
let value = if attribute.value.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(attribute.value[0].clone())
|
||||||
|
};
|
||||||
|
html!{<SingleAttributeInput name={attribute.name.clone()} attribute_type={Into::<AttributeType>::into(attribute.schema.attribute_type.clone())} value={value}/>}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl UserDetailsForm {
|
impl UserDetailsForm {
|
||||||
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
|
||||||
if !self.form.validate() {
|
if !self.form.validate() {
|
||||||
@@ -380,7 +361,40 @@ impl UserDetailsForm {
|
|||||||
{
|
{
|
||||||
bail!("Image file hasn't finished loading, try again");
|
bail!("Image file hasn't finished loading, try again");
|
||||||
}
|
}
|
||||||
|
let form = self.form_ref.cast::<HtmlFormElement>().unwrap();
|
||||||
|
let form_data = FormData::new_with_form(&form)
|
||||||
|
.map_err(|e| anyhow!("Failed to get FormData: {:#?}", e.as_string()))?;
|
||||||
|
let mut all_values = get_values_from_form_data(
|
||||||
|
self.user
|
||||||
|
.attributes
|
||||||
|
.iter()
|
||||||
|
.map(|attr| attr.schema.clone())
|
||||||
|
.filter(|attr| !attr.is_hardcoded)
|
||||||
|
.filter(|attr| attr.is_editable)
|
||||||
|
.collect(),
|
||||||
|
&form_data,
|
||||||
|
)?;
|
||||||
let base_user = &self.user;
|
let base_user = &self.user;
|
||||||
|
let base_attrs = &self.user.attributes;
|
||||||
|
all_values.retain(|(name, val)| {
|
||||||
|
let name = name.clone();
|
||||||
|
let base_val = base_attrs
|
||||||
|
.into_iter()
|
||||||
|
.find(|base_val| base_val.name == name)
|
||||||
|
.unwrap();
|
||||||
|
let new_values = val.clone();
|
||||||
|
base_val.value != new_values
|
||||||
|
});
|
||||||
|
let remove_names: Option<Vec<String>> = if all_values.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(all_values.iter().map(|(name, _)| name.clone()).collect())
|
||||||
|
};
|
||||||
|
let insert_attrs: Option<Vec<update_user::AttributeValueInput>> = if remove_names.is_none() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(all_values.into_iter().map(|(name, value)| update_user::AttributeValueInput{name, value}).collect())
|
||||||
|
};
|
||||||
let mut user_input = update_user::UpdateUserInput {
|
let mut user_input = update_user::UpdateUserInput {
|
||||||
id: self.user.id.clone(),
|
id: self.user.id.clone(),
|
||||||
email: None,
|
email: None,
|
||||||
@@ -388,8 +402,8 @@ impl UserDetailsForm {
|
|||||||
firstName: None,
|
firstName: None,
|
||||||
lastName: None,
|
lastName: None,
|
||||||
avatar: None,
|
avatar: None,
|
||||||
removeAttributes: None,
|
removeAttributes: remove_names,
|
||||||
insertAttributes: None,
|
insertAttributes: insert_attrs,
|
||||||
};
|
};
|
||||||
let default_user_input = user_input.clone();
|
let default_user_input = user_input.clone();
|
||||||
let model = self.form.model();
|
let model = self.form.model();
|
||||||
|
|||||||
198
app/src/components/user_schema_table.rs
Normal file
198
app/src/components/user_schema_table.rs
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
use crate::{
|
||||||
|
components::{
|
||||||
|
delete_user_attribute::DeleteUserAttribute,
|
||||||
|
router::{AppRoute, Link},
|
||||||
|
},
|
||||||
|
convert_attribute_type,
|
||||||
|
infra::{
|
||||||
|
common_component::{CommonComponent, CommonComponentParts},
|
||||||
|
schema::AttributeType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use anyhow::{anyhow, Error, Result};
|
||||||
|
use gloo_console::log;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[derive(GraphQLQuery)]
|
||||||
|
#[graphql(
|
||||||
|
schema_path = "../schema.graphql",
|
||||||
|
query_path = "queries/get_user_attributes_schema.graphql",
|
||||||
|
response_derives = "Debug,Clone,PartialEq,Eq",
|
||||||
|
custom_scalars_module = "crate::infra::graphql"
|
||||||
|
)]
|
||||||
|
pub struct GetUserAttributesSchema;
|
||||||
|
|
||||||
|
use get_user_attributes_schema::ResponseData;
|
||||||
|
|
||||||
|
pub type Attribute = get_user_attributes_schema::GetUserAttributesSchemaSchemaUserSchemaAttributes;
|
||||||
|
|
||||||
|
convert_attribute_type!(get_user_attributes_schema::AttributeType);
|
||||||
|
|
||||||
|
#[derive(yew::Properties, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Props {
|
||||||
|
pub hardcoded: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct UserSchemaTable {
|
||||||
|
common: CommonComponentParts<Self>,
|
||||||
|
attributes: Option<Vec<Attribute>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Msg {
|
||||||
|
ListAttributesResponse(Result<ResponseData>),
|
||||||
|
OnAttributeDeleted(String),
|
||||||
|
OnError(Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommonComponent<UserSchemaTable> for UserSchemaTable {
|
||||||
|
fn handle_msg(&mut self, _: &Context<Self>, msg: <Self as Component>::Message) -> Result<bool> {
|
||||||
|
match msg {
|
||||||
|
Msg::ListAttributesResponse(schema) => {
|
||||||
|
self.attributes = Some(schema?.schema.user_schema.attributes.into_iter().collect());
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
Msg::OnError(e) => Err(e),
|
||||||
|
Msg::OnAttributeDeleted(attribute_name) => {
|
||||||
|
match self.attributes {
|
||||||
|
None => {
|
||||||
|
log!(format!("Attribute {attribute_name} was deleted but component has no attributes"));
|
||||||
|
Err(anyhow!("invalid state"))
|
||||||
|
}
|
||||||
|
Some(_) => {
|
||||||
|
self.attributes
|
||||||
|
.as_mut()
|
||||||
|
.unwrap()
|
||||||
|
.retain(|a| a.name != attribute_name);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mut_common(&mut self) -> &mut CommonComponentParts<Self> {
|
||||||
|
&mut self.common
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Component for UserSchemaTable {
|
||||||
|
type Message = Msg;
|
||||||
|
type Properties = Props;
|
||||||
|
|
||||||
|
fn create(ctx: &Context<Self>) -> Self {
|
||||||
|
let mut table = UserSchemaTable {
|
||||||
|
common: CommonComponentParts::<Self>::create(),
|
||||||
|
attributes: None,
|
||||||
|
};
|
||||||
|
table.common.call_graphql::<GetUserAttributesSchema, _>(
|
||||||
|
ctx,
|
||||||
|
get_user_attributes_schema::Variables {},
|
||||||
|
Msg::ListAttributesResponse,
|
||||||
|
"Error trying to fetch user schema",
|
||||||
|
);
|
||||||
|
table
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
|
||||||
|
CommonComponentParts::<Self>::update(self, ctx, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
{self.view_attributes(ctx)}
|
||||||
|
{self.view_errors()}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserSchemaTable {
|
||||||
|
fn view_attributes(&self, ctx: &Context<Self>) -> Html {
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
let make_table = |attributes: &Vec<Attribute>| {
|
||||||
|
html! {
|
||||||
|
<div class="table-responsive">
|
||||||
|
<h3>{if hardcoded {"Hardcoded"} else {"User-defined"}}{" attributes"}</h3>
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{"Attribute name"}</th>
|
||||||
|
<th>{"Type"}</th>
|
||||||
|
<th>{"Editable"}</th>
|
||||||
|
<th>{"Visible"}</th>
|
||||||
|
{if hardcoded {html!{}} else {html!{<th>{"Delete"}</th>}}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{attributes.iter().map(|u| self.view_attribute(ctx, u)).collect::<Vec<_>>()}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match &self.attributes {
|
||||||
|
None => html! {{"Loading..."}},
|
||||||
|
Some(attributes) => {
|
||||||
|
let mut attributes = attributes.clone();
|
||||||
|
attributes.retain(|attribute| attribute.is_hardcoded == ctx.props().hardcoded);
|
||||||
|
make_table(&attributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_attribute(&self, ctx: &Context<Self>, attribute: &Attribute) -> Html {
|
||||||
|
let link = ctx.link();
|
||||||
|
let attribute_type = AttributeType::from(attribute.attribute_type.clone());
|
||||||
|
let checkmark = html! {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-check" viewBox="0 0 16 16">
|
||||||
|
<path d="M10.97 4.97a.75.75 0 0 1 1.07 1.05l-3.99 4.99a.75.75 0 0 1-1.08.02L4.324 8.384a.75.75 0 1 1 1.06-1.06l2.094 2.093 3.473-4.425z"></path>
|
||||||
|
</svg>
|
||||||
|
};
|
||||||
|
let hardcoded = ctx.props().hardcoded;
|
||||||
|
html! {
|
||||||
|
<tr key={attribute.name.clone()}>
|
||||||
|
<td>{&attribute.name}</td>
|
||||||
|
<td>{if attribute.is_list { format!("List<{attribute_type}>")} else {attribute_type.to_string()}}</td>
|
||||||
|
<td>{if attribute.is_editable {checkmark.clone()} else {html!{}}}</td>
|
||||||
|
<td>{if attribute.is_visible {checkmark.clone()} else {html!{}}}</td>
|
||||||
|
{
|
||||||
|
if hardcoded {
|
||||||
|
html!{}
|
||||||
|
} else {
|
||||||
|
html!{
|
||||||
|
<td>
|
||||||
|
<DeleteUserAttribute
|
||||||
|
attribute_name={attribute.name.clone()}
|
||||||
|
on_attribute_deleted={link.callback(Msg::OnAttributeDeleted)}
|
||||||
|
on_error={link.callback(Msg::OnError)}/>
|
||||||
|
</td>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view_errors(&self) -> Html {
|
||||||
|
match &self.common.error {
|
||||||
|
None => html! {},
|
||||||
|
Some(e) => html! {<div>{"Error: "}{e.to_string()}</div>},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(ListUserSchema)]
|
||||||
|
pub fn list_user_schema() -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<UserSchemaTable hardcoded={true} />
|
||||||
|
<UserSchemaTable hardcoded={false} />
|
||||||
|
<Link classes="btn btn-primary" to={AppRoute::CreateUserAttribute}>
|
||||||
|
<i class="bi-plus-circle me-2"></i>
|
||||||
|
{"Create an attribute"}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,21 +16,26 @@ fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
|
|||||||
Ok(token.claims().clone())
|
Ok(token.claims().clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
const NO_BODY: Option<()> = None;
|
enum RequestType<Body: Serialize> {
|
||||||
|
Get,
|
||||||
|
Post(Body),
|
||||||
|
}
|
||||||
|
|
||||||
|
const GET_REQUEST: RequestType<()> = RequestType::Get;
|
||||||
|
|
||||||
fn base_url() -> String {
|
fn base_url() -> String {
|
||||||
yew_router::utils::base_url().unwrap_or_default()
|
yew_router::utils::base_url().unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn call_server(
|
async fn call_server<Body: Serialize>(
|
||||||
url: &str,
|
url: &str,
|
||||||
body: Option<impl Serialize>,
|
body: RequestType<Body>,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<String> {
|
) -> Result<String> {
|
||||||
let mut request = Request::new(url)
|
let mut request = Request::new(url)
|
||||||
.header("Content-Type", "application/json")
|
.header("Content-Type", "application/json")
|
||||||
.credentials(RequestCredentials::SameOrigin);
|
.credentials(RequestCredentials::SameOrigin);
|
||||||
if let Some(b) = body {
|
if let RequestType::Post(b) = body {
|
||||||
request = request
|
request = request
|
||||||
.body(serde_json::to_string(&b)?)
|
.body(serde_json::to_string(&b)?)
|
||||||
.method(Method::POST);
|
.method(Method::POST);
|
||||||
@@ -51,7 +56,7 @@ async fn call_server(
|
|||||||
|
|
||||||
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
|
||||||
url: &str,
|
url: &str,
|
||||||
request: Option<Body>,
|
request: RequestType<Body>,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<CallbackResult>
|
) -> Result<CallbackResult>
|
||||||
where
|
where
|
||||||
@@ -63,7 +68,7 @@ where
|
|||||||
|
|
||||||
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
async fn call_server_empty_response_with_error_message<Body: Serialize>(
|
||||||
url: &str,
|
url: &str,
|
||||||
request: Option<Body>,
|
request: RequestType<Body>,
|
||||||
error_message: &'static str,
|
error_message: &'static str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
call_server(url, request, error_message).await.map(|_| ())
|
call_server(url, request, error_message).await.map(|_| ())
|
||||||
@@ -102,7 +107,7 @@ impl HostService {
|
|||||||
let request_body = QueryType::build_query(variables);
|
let request_body = QueryType::build_query(variables);
|
||||||
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
|
||||||
&(base_url() + "/api/graphql"),
|
&(base_url() + "/api/graphql"),
|
||||||
Some(request_body),
|
RequestType::Post(request_body),
|
||||||
error_message,
|
error_message,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -114,7 +119,7 @@ impl HostService {
|
|||||||
) -> Result<Box<login::ServerLoginStartResponse>> {
|
) -> Result<Box<login::ServerLoginStartResponse>> {
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&(base_url() + "/auth/opaque/login/start"),
|
&(base_url() + "/auth/opaque/login/start"),
|
||||||
Some(request),
|
RequestType::Post(request),
|
||||||
"Could not start authentication: ",
|
"Could not start authentication: ",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -123,7 +128,7 @@ impl HostService {
|
|||||||
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
|
||||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
&(base_url() + "/auth/opaque/login/finish"),
|
&(base_url() + "/auth/opaque/login/finish"),
|
||||||
Some(request),
|
RequestType::Post(request),
|
||||||
"Could not finish authentication",
|
"Could not finish authentication",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -135,7 +140,7 @@ impl HostService {
|
|||||||
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&(base_url() + "/auth/opaque/register/start"),
|
&(base_url() + "/auth/opaque/register/start"),
|
||||||
Some(request),
|
RequestType::Post(request),
|
||||||
"Could not start registration: ",
|
"Could not start registration: ",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -146,7 +151,7 @@ impl HostService {
|
|||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
&(base_url() + "/auth/opaque/register/finish"),
|
&(base_url() + "/auth/opaque/register/finish"),
|
||||||
Some(request),
|
RequestType::Post(request),
|
||||||
"Could not finish registration",
|
"Could not finish registration",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -155,7 +160,7 @@ impl HostService {
|
|||||||
pub async fn refresh() -> Result<(String, bool)> {
|
pub async fn refresh() -> Result<(String, bool)> {
|
||||||
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
|
||||||
&(base_url() + "/auth/refresh"),
|
&(base_url() + "/auth/refresh"),
|
||||||
NO_BODY,
|
GET_REQUEST,
|
||||||
"Could not start authentication: ",
|
"Could not start authentication: ",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -166,7 +171,7 @@ impl HostService {
|
|||||||
pub async fn logout() -> Result<()> {
|
pub async fn logout() -> Result<()> {
|
||||||
call_server_empty_response_with_error_message(
|
call_server_empty_response_with_error_message(
|
||||||
&(base_url() + "/auth/logout"),
|
&(base_url() + "/auth/logout"),
|
||||||
NO_BODY,
|
GET_REQUEST,
|
||||||
"Could not logout",
|
"Could not logout",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -179,7 +184,7 @@ impl HostService {
|
|||||||
base_url(),
|
base_url(),
|
||||||
url_escape::encode_query(&username)
|
url_escape::encode_query(&username)
|
||||||
),
|
),
|
||||||
NO_BODY,
|
RequestType::Post(""),
|
||||||
"Could not initiate password reset",
|
"Could not initiate password reset",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -190,7 +195,7 @@ impl HostService {
|
|||||||
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
|
||||||
call_server_json_with_error_message(
|
call_server_json_with_error_message(
|
||||||
&format!("{}/auth/reset/step2/{}", base_url(), token),
|
&format!("{}/auth/reset/step2/{}", base_url(), token),
|
||||||
NO_BODY,
|
GET_REQUEST,
|
||||||
"Could not validate token",
|
"Could not validate token",
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
|
|||||||
52
app/src/infra/functional.rs
Normal file
52
app/src/infra/functional.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
use crate::infra::api::HostService;
|
||||||
|
use anyhow::Result;
|
||||||
|
use graphql_client::GraphQLQuery;
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
use yew::{use_effect, use_state_eq, UseStateHandle};
|
||||||
|
|
||||||
|
// Enum to represent a result that is fetched asynchronously.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum LoadableResult<T> {
|
||||||
|
// The result is still being fetched
|
||||||
|
Loading,
|
||||||
|
// The async call is completed
|
||||||
|
Loaded(Result<T>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: PartialEq> PartialEq for LoadableResult<T> {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
match (self, other) {
|
||||||
|
(LoadableResult::Loading, LoadableResult::Loading) => true,
|
||||||
|
(LoadableResult::Loaded(Ok(d1)), LoadableResult::Loaded(Ok(d2))) => d1.eq(d2),
|
||||||
|
(LoadableResult::Loaded(Err(e1)), LoadableResult::Loaded(Err(e2))) => {
|
||||||
|
e1.to_string().eq(&e2.to_string())
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn use_graphql_call<QueryType>(
|
||||||
|
variables: QueryType::Variables,
|
||||||
|
) -> UseStateHandle<LoadableResult<QueryType::ResponseData>>
|
||||||
|
where
|
||||||
|
QueryType: GraphQLQuery + 'static,
|
||||||
|
<QueryType as graphql_client::GraphQLQuery>::ResponseData: std::cmp::PartialEq,
|
||||||
|
{
|
||||||
|
let loadable_result: UseStateHandle<LoadableResult<QueryType::ResponseData>> =
|
||||||
|
use_state_eq(|| LoadableResult::Loading);
|
||||||
|
{
|
||||||
|
let loadable_result = loadable_result.clone();
|
||||||
|
use_effect(move || {
|
||||||
|
let task = HostService::graphql_query::<QueryType>(variables, "Failed graphql query");
|
||||||
|
|
||||||
|
spawn_local(async move {
|
||||||
|
let response = task.await;
|
||||||
|
loadable_result.set(LoadableResult::Loaded(response));
|
||||||
|
});
|
||||||
|
|
||||||
|
|| ()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
loadable_result.clone()
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod api;
|
pub mod api;
|
||||||
pub mod common_component;
|
pub mod common_component;
|
||||||
pub mod cookies;
|
pub mod cookies;
|
||||||
|
pub mod functional;
|
||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
pub mod modal;
|
pub mod modal;
|
||||||
|
pub mod schema;
|
||||||
|
|||||||
66
app/src/infra/schema.rs
Normal file
66
app/src/infra/schema.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use std::{fmt::Display, str::FromStr};
|
||||||
|
use validator::ValidationError;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum AttributeType {
|
||||||
|
String,
|
||||||
|
Integer,
|
||||||
|
DateTime,
|
||||||
|
Jpeg,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Display for AttributeType {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{:?}", self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for AttributeType {
|
||||||
|
type Err = ();
|
||||||
|
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||||
|
match value {
|
||||||
|
"String" => Ok(AttributeType::String),
|
||||||
|
"Integer" => Ok(AttributeType::Integer),
|
||||||
|
"DateTime" => Ok(AttributeType::DateTime),
|
||||||
|
"Jpeg" => Ok(AttributeType::Jpeg),
|
||||||
|
_ => Err(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Macro to generate traits for converting between AttributeType and the
|
||||||
|
// graphql generated equivalents.
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! convert_attribute_type {
|
||||||
|
($source_type:ty) => {
|
||||||
|
impl From<$source_type> for AttributeType {
|
||||||
|
fn from(value: $source_type) -> Self {
|
||||||
|
match value {
|
||||||
|
<$source_type>::STRING => AttributeType::String,
|
||||||
|
<$source_type>::INTEGER => AttributeType::Integer,
|
||||||
|
<$source_type>::DATE_TIME => AttributeType::DateTime,
|
||||||
|
<$source_type>::JPEG_PHOTO => AttributeType::Jpeg,
|
||||||
|
_ => panic!("Unknown attribute type"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AttributeType> for $source_type {
|
||||||
|
fn from(value: AttributeType) -> Self {
|
||||||
|
match value {
|
||||||
|
AttributeType::String => <$source_type>::STRING,
|
||||||
|
AttributeType::Integer => <$source_type>::INTEGER,
|
||||||
|
AttributeType::DateTime => <$source_type>::DATE_TIME,
|
||||||
|
AttributeType::Jpeg => <$source_type>::JPEG_PHOTO,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_attribute_type(attribute_type: &str) -> Result<(), ValidationError> {
|
||||||
|
AttributeType::from_str(attribute_type)
|
||||||
|
.map_err(|_| ValidationError::new("Invalid attribute type"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -55,13 +55,13 @@ services:
|
|||||||
# >>> Postfix LDAP Integration
|
# >>> Postfix LDAP Integration
|
||||||
- ACCOUNT_PROVISIONER=LDAP
|
- ACCOUNT_PROVISIONER=LDAP
|
||||||
- LDAP_SERVER_HOST=lldap:3890
|
- LDAP_SERVER_HOST=lldap:3890
|
||||||
- LDAP_SEARCH_BASE=dc=example,dc=com
|
- LDAP_SEARCH_BASE=ou=people,dc=example,dc=com
|
||||||
- LDAP_BIND_DN=uid=admin,ou=people,dc=example,dc=com
|
- LDAP_BIND_DN=uid=admin,ou=people,dc=example,dc=com
|
||||||
- LDAP_BIND_PW=adminpassword
|
- LDAP_BIND_PW=adminpassword
|
||||||
- LDAP_QUERY_FILTER_USER=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
|
- LDAP_QUERY_FILTER_USER=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
|
||||||
- LDAP_QUERY_FILTER_GROUP=(&(objectClass=groupOfUniqueNames)(uid=%s))
|
- LDAP_QUERY_FILTER_GROUP=(&(objectClass=groupOfUniqueNames)(uid=%s))
|
||||||
- LDAP_QUERY_FILTER_ALIAS=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
|
- LDAP_QUERY_FILTER_ALIAS=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
|
||||||
- LDAP_QUERY_FILTER_DOMAIN=((mail=*@%s))
|
- LDAP_QUERY_FILTER_DOMAIN=(mail=*@%s)
|
||||||
# <<< Postfix LDAP Integration
|
# <<< Postfix LDAP Integration
|
||||||
# >>> Dovecot LDAP Integration
|
# >>> Dovecot LDAP Integration
|
||||||
- DOVECOT_AUTH_BIND=yes
|
- DOVECOT_AUTH_BIND=yes
|
||||||
|
|||||||
20
example_configs/radicale.md
Normal file
20
example_configs/radicale.md
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Configuration of RADICALE authentification with lldap.
|
||||||
|
|
||||||
|
# Fork of the radicale LDAP plugin to work with LLDAP : https://github.com/shroomify-it/radicale-auth-ldap-plugin
|
||||||
|
|
||||||
|
# Full docker-compose stack : https://github.com/shroomify-it/docker-deploy_radicale-agendav-lldap
|
||||||
|
|
||||||
|
# Radicale config file v0.3 (inside docker container /etc/radicale/config https://radicale.org/v3.html#configuration)
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[auth]
|
||||||
|
type = radicale_auth_ldap
|
||||||
|
ldap_url = ldap://lldap:3890
|
||||||
|
ldap_base = dc=example,dc=com
|
||||||
|
ldap_attribute = uid
|
||||||
|
ldap_filter = (objectClass=person)
|
||||||
|
ldap_binddn = uid=admin,ou=people,dc=example,dc=com
|
||||||
|
ldap_password = CHANGEME
|
||||||
|
ldap_scope = LEVEL
|
||||||
|
ldap_support_extended = no
|
||||||
|
```
|
||||||
14
schema.graphql
generated
14
schema.graphql
generated
@@ -1,6 +1,7 @@
|
|||||||
type AttributeValue {
|
type AttributeValue {
|
||||||
name: String!
|
name: String!
|
||||||
value: [String!]!
|
value: [String!]!
|
||||||
|
schema: AttributeSchema!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
@@ -17,6 +18,10 @@ type Mutation {
|
|||||||
addGroupAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
|
addGroupAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
|
||||||
deleteUserAttribute(name: String!): Success!
|
deleteUserAttribute(name: String!): Success!
|
||||||
deleteGroupAttribute(name: String!): Success!
|
deleteGroupAttribute(name: String!): Success!
|
||||||
|
addUserObjectClass(name: String!): Success!
|
||||||
|
addGroupObjectClass(name: String!): Success!
|
||||||
|
deleteUserObjectClass(name: String!): Success!
|
||||||
|
deleteGroupObjectClass(name: String!): Success!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Group {
|
type Group {
|
||||||
@@ -152,10 +157,6 @@ type User {
|
|||||||
groups: [Group!]!
|
groups: [Group!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type AttributeList {
|
|
||||||
attributes: [AttributeSchema!]!
|
|
||||||
}
|
|
||||||
|
|
||||||
enum AttributeType {
|
enum AttributeType {
|
||||||
STRING
|
STRING
|
||||||
INTEGER
|
INTEGER
|
||||||
@@ -163,6 +164,11 @@ enum AttributeType {
|
|||||||
DATE_TIME
|
DATE_TIME
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AttributeList {
|
||||||
|
attributes: [AttributeSchema!]!
|
||||||
|
extraLdapObjectClasses: [String!]!
|
||||||
|
}
|
||||||
|
|
||||||
type Success {
|
type Success {
|
||||||
ok: Boolean!
|
ok: Boolean!
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ use crate::domain::{
|
|||||||
error::Result,
|
error::Result,
|
||||||
types::{
|
types::{
|
||||||
AttributeName, AttributeType, AttributeValue, Email, Group, GroupDetails, GroupId,
|
AttributeName, AttributeType, AttributeValue, Email, Group, GroupDetails, GroupId,
|
||||||
GroupName, JpegPhoto, Serialized, User, UserAndGroups, UserColumn, UserId, Uuid,
|
GroupName, JpegPhoto, LdapObjectClass, Serialized, User, UserAndGroups, UserColumn, UserId,
|
||||||
|
Uuid,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
@@ -175,6 +176,8 @@ impl AttributeList {
|
|||||||
pub struct Schema {
|
pub struct Schema {
|
||||||
pub user_attributes: AttributeList,
|
pub user_attributes: AttributeList,
|
||||||
pub group_attributes: AttributeList,
|
pub group_attributes: AttributeList,
|
||||||
|
pub extra_user_object_classes: Vec<LdapObjectClass>,
|
||||||
|
pub extra_group_object_classes: Vec<LdapObjectClass>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -227,6 +230,11 @@ pub trait SchemaBackendHandler: ReadSchemaBackendHandler {
|
|||||||
// Note: It's up to the caller to make sure that the attribute is not hardcoded.
|
// Note: It's up to the caller to make sure that the attribute is not hardcoded.
|
||||||
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
|
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||||
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
|
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||||
|
|
||||||
|
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||||
|
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||||
|
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||||
|
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ use crate::domain::{
|
|||||||
handler::{GroupListerBackendHandler, GroupRequestFilter},
|
handler::{GroupListerBackendHandler, GroupRequestFilter},
|
||||||
ldap::error::LdapError,
|
ldap::error::LdapError,
|
||||||
schema::{PublicSchema, SchemaGroupAttributeExtractor},
|
schema::{PublicSchema, SchemaGroupAttributeExtractor},
|
||||||
types::{AttributeName, AttributeType, Group, UserId, Uuid},
|
types::{AttributeName, AttributeType, Group, LdapObjectClass, UserId, Uuid},
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -30,7 +30,17 @@ pub fn get_group_attribute(
|
|||||||
) -> Option<Vec<Vec<u8>>> {
|
) -> Option<Vec<Vec<u8>>> {
|
||||||
let attribute = AttributeName::from(attribute);
|
let attribute = AttributeName::from(attribute);
|
||||||
let attribute_values = match map_group_field(&attribute, schema) {
|
let attribute_values = match map_group_field(&attribute, schema) {
|
||||||
GroupFieldType::ObjectClass => vec![b"groupOfUniqueNames".to_vec()],
|
GroupFieldType::ObjectClass => {
|
||||||
|
let mut classes = vec![b"groupOfUniqueNames".to_vec()];
|
||||||
|
classes.extend(
|
||||||
|
schema
|
||||||
|
.get_schema()
|
||||||
|
.extra_group_object_classes
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.as_str().as_bytes().to_vec()),
|
||||||
|
);
|
||||||
|
classes
|
||||||
|
}
|
||||||
// Always returned as part of the base response.
|
// Always returned as part of the base response.
|
||||||
GroupFieldType::Dn => return None,
|
GroupFieldType::Dn => return None,
|
||||||
GroupFieldType::EntryDn => {
|
GroupFieldType::EntryDn => {
|
||||||
@@ -100,13 +110,11 @@ fn expand_group_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
|
|||||||
fn make_ldap_search_group_result_entry(
|
fn make_ldap_search_group_result_entry(
|
||||||
group: Group,
|
group: Group,
|
||||||
base_dn_str: &str,
|
base_dn_str: &str,
|
||||||
attributes: &[String],
|
expanded_attributes: &[&str],
|
||||||
user_filter: &Option<UserId>,
|
user_filter: &Option<UserId>,
|
||||||
ignored_group_attributes: &[AttributeName],
|
ignored_group_attributes: &[AttributeName],
|
||||||
schema: &PublicSchema,
|
schema: &PublicSchema,
|
||||||
) -> LdapSearchResultEntry {
|
) -> LdapSearchResultEntry {
|
||||||
let expanded_attributes = expand_group_attribute_wildcards(attributes);
|
|
||||||
|
|
||||||
LdapSearchResultEntry {
|
LdapSearchResultEntry {
|
||||||
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
|
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
|
||||||
attributes: expanded_attributes
|
attributes: expanded_attributes
|
||||||
@@ -169,10 +177,13 @@ fn convert_group_filter(
|
|||||||
)?;
|
)?;
|
||||||
Ok(GroupRequestFilter::Member(user_name))
|
Ok(GroupRequestFilter::Member(user_name))
|
||||||
}
|
}
|
||||||
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(matches!(
|
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(
|
||||||
value.as_str(),
|
matches!(value.as_str(), "groupofuniquenames" | "groupofnames")
|
||||||
"groupofuniquenames" | "groupofnames"
|
|| schema
|
||||||
))),
|
.get_schema()
|
||||||
|
.extra_group_object_classes
|
||||||
|
.contains(&LdapObjectClass::from(value)),
|
||||||
|
)),
|
||||||
GroupFieldType::Dn | GroupFieldType::EntryDn => {
|
GroupFieldType::Dn | GroupFieldType::EntryDn => {
|
||||||
Ok(get_group_id_from_distinguished_name(
|
Ok(get_group_id_from_distinguished_name(
|
||||||
value.as_str(),
|
value.as_str(),
|
||||||
@@ -267,11 +278,17 @@ pub fn convert_groups_to_ldap_op<'a>(
|
|||||||
user_filter: &'a Option<UserId>,
|
user_filter: &'a Option<UserId>,
|
||||||
schema: &'a PublicSchema,
|
schema: &'a PublicSchema,
|
||||||
) -> impl Iterator<Item = LdapOp> + 'a {
|
) -> impl Iterator<Item = LdapOp> + 'a {
|
||||||
|
let expanded_attributes = if groups.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(expand_group_attribute_wildcards(attributes))
|
||||||
|
};
|
||||||
|
|
||||||
groups.into_iter().map(move |g| {
|
groups.into_iter().map(move |g| {
|
||||||
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
|
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
|
||||||
g,
|
g,
|
||||||
&ldap_info.base_dn_str,
|
&ldap_info.base_dn_str,
|
||||||
attributes,
|
expanded_attributes.as_ref().unwrap(),
|
||||||
user_filter,
|
user_filter,
|
||||||
&ldap_info.ignored_group_attributes,
|
&ldap_info.ignored_group_attributes,
|
||||||
schema,
|
schema,
|
||||||
|
|||||||
@@ -15,7 +15,10 @@ use crate::domain::{
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
schema::{PublicSchema, SchemaUserAttributeExtractor},
|
schema::{PublicSchema, SchemaUserAttributeExtractor},
|
||||||
types::{AttributeName, AttributeType, GroupDetails, User, UserAndGroups, UserColumn, UserId},
|
types::{
|
||||||
|
AttributeName, AttributeType, GroupDetails, LdapObjectClass, User, UserAndGroups,
|
||||||
|
UserColumn, UserId,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub fn get_user_attribute(
|
pub fn get_user_attribute(
|
||||||
@@ -28,12 +31,22 @@ pub fn get_user_attribute(
|
|||||||
) -> Option<Vec<Vec<u8>>> {
|
) -> Option<Vec<Vec<u8>>> {
|
||||||
let attribute = AttributeName::from(attribute);
|
let attribute = AttributeName::from(attribute);
|
||||||
let attribute_values = match map_user_field(&attribute, schema) {
|
let attribute_values = match map_user_field(&attribute, schema) {
|
||||||
UserFieldType::ObjectClass => vec![
|
UserFieldType::ObjectClass => {
|
||||||
b"inetOrgPerson".to_vec(),
|
let mut classes = vec![
|
||||||
b"posixAccount".to_vec(),
|
b"inetOrgPerson".to_vec(),
|
||||||
b"mailAccount".to_vec(),
|
b"posixAccount".to_vec(),
|
||||||
b"person".to_vec(),
|
b"mailAccount".to_vec(),
|
||||||
],
|
b"person".to_vec(),
|
||||||
|
];
|
||||||
|
classes.extend(
|
||||||
|
schema
|
||||||
|
.get_schema()
|
||||||
|
.extra_user_object_classes
|
||||||
|
.iter()
|
||||||
|
.map(|c| c.as_str().as_bytes().to_vec()),
|
||||||
|
);
|
||||||
|
classes
|
||||||
|
}
|
||||||
// dn is always returned as part of the base response.
|
// dn is always returned as part of the base response.
|
||||||
UserFieldType::Dn => return None,
|
UserFieldType::Dn => return None,
|
||||||
UserFieldType::EntryDn => {
|
UserFieldType::EntryDn => {
|
||||||
@@ -119,12 +132,11 @@ const ALL_USER_ATTRIBUTE_KEYS: &[&str] = &[
|
|||||||
fn make_ldap_search_user_result_entry(
|
fn make_ldap_search_user_result_entry(
|
||||||
user: User,
|
user: User,
|
||||||
base_dn_str: &str,
|
base_dn_str: &str,
|
||||||
attributes: &[String],
|
expanded_attributes: &[&str],
|
||||||
groups: Option<&[GroupDetails]>,
|
groups: Option<&[GroupDetails]>,
|
||||||
ignored_user_attributes: &[AttributeName],
|
ignored_user_attributes: &[AttributeName],
|
||||||
schema: &PublicSchema,
|
schema: &PublicSchema,
|
||||||
) -> LdapSearchResultEntry {
|
) -> LdapSearchResultEntry {
|
||||||
let expanded_attributes = expand_user_attribute_wildcards(attributes);
|
|
||||||
let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str);
|
let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str);
|
||||||
LdapSearchResultEntry {
|
LdapSearchResultEntry {
|
||||||
dn,
|
dn,
|
||||||
@@ -197,10 +209,15 @@ fn convert_user_filter(
|
|||||||
}
|
}
|
||||||
Ok(UserRequestFilter::from(false))
|
Ok(UserRequestFilter::from(false))
|
||||||
}
|
}
|
||||||
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(matches!(
|
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(
|
||||||
value.as_str(),
|
matches!(
|
||||||
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
|
value.as_str(),
|
||||||
))),
|
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
|
||||||
|
) || schema
|
||||||
|
.get_schema()
|
||||||
|
.extra_user_object_classes
|
||||||
|
.contains(&LdapObjectClass::from(value)),
|
||||||
|
)),
|
||||||
UserFieldType::MemberOf => Ok(UserRequestFilter::MemberOf(
|
UserFieldType::MemberOf => Ok(UserRequestFilter::MemberOf(
|
||||||
get_group_id_from_distinguished_name(
|
get_group_id_from_distinguished_name(
|
||||||
&value,
|
&value,
|
||||||
@@ -295,11 +312,16 @@ pub fn convert_users_to_ldap_op<'a>(
|
|||||||
ldap_info: &'a LdapInfo,
|
ldap_info: &'a LdapInfo,
|
||||||
schema: &'a PublicSchema,
|
schema: &'a PublicSchema,
|
||||||
) -> impl Iterator<Item = LdapOp> + 'a {
|
) -> impl Iterator<Item = LdapOp> + 'a {
|
||||||
|
let expanded_attributes = if users.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(expand_user_attribute_wildcards(attributes))
|
||||||
|
};
|
||||||
users.into_iter().map(move |u| {
|
users.into_iter().map(move |u| {
|
||||||
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
|
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
|
||||||
u.user,
|
u.user,
|
||||||
&ldap_info.base_dn_str,
|
&ldap_info.base_dn_str,
|
||||||
attributes,
|
expanded_attributes.as_ref().unwrap(),
|
||||||
u.groups.as_deref(),
|
u.groups.as_deref(),
|
||||||
&ldap_info.ignored_user_attributes,
|
&ldap_info.ignored_user_attributes,
|
||||||
schema,
|
schema,
|
||||||
|
|||||||
@@ -114,21 +114,21 @@ pub fn expand_attribute_wildcards<'a>(
|
|||||||
ldap_attributes: &'a [String],
|
ldap_attributes: &'a [String],
|
||||||
all_attribute_keys: &'a [&'static str],
|
all_attribute_keys: &'a [&'static str],
|
||||||
) -> Vec<&'a str> {
|
) -> Vec<&'a str> {
|
||||||
let mut attributes_out = ldap_attributes
|
let extra_attributes =
|
||||||
|
if ldap_attributes.iter().any(|x| x == "*") || ldap_attributes.is_empty() {
|
||||||
|
all_attribute_keys
|
||||||
|
} else {
|
||||||
|
&[]
|
||||||
|
}
|
||||||
.iter()
|
.iter()
|
||||||
.map(String::as_str)
|
.copied();
|
||||||
.collect::<Vec<_>>();
|
let attributes_out = ldap_attributes
|
||||||
|
.iter()
|
||||||
if attributes_out.iter().any(|&x| x == "*") || attributes_out.is_empty() {
|
.map(|s| s.as_str())
|
||||||
// Remove occurrences of '*'
|
.filter(|&s| s != "*" && s != "+" && s != "1.1");
|
||||||
attributes_out.retain(|&x| x != "*");
|
|
||||||
// Splice in all non-operational attributes
|
|
||||||
attributes_out.extend(all_attribute_keys.iter());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduplicate, preserving order
|
// Deduplicate, preserving order
|
||||||
let resolved_attributes = attributes_out
|
let resolved_attributes = itertools::chain(attributes_out, extra_attributes)
|
||||||
.into_iter()
|
|
||||||
.unique_by(|a| a.to_ascii_lowercase())
|
.unique_by(|a| a.to_ascii_lowercase())
|
||||||
.collect_vec();
|
.collect_vec();
|
||||||
debug!(?resolved_attributes);
|
debug!(?resolved_attributes);
|
||||||
|
|||||||
23
server/src/domain/model/group_object_classes.rs
Normal file
23
server/src/domain/model/group_object_classes.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::types::LdapObjectClass;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "group_object_classes")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub lower_object_class: String,
|
||||||
|
pub object_class: LdapObjectClass,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
||||||
|
impl From<Model> for LdapObjectClass {
|
||||||
|
fn from(value: Model) -> Self {
|
||||||
|
value.object_class
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
|
|
||||||
|
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
pub mod groups;
|
pub mod groups;
|
||||||
@@ -11,8 +9,10 @@ pub mod users;
|
|||||||
|
|
||||||
pub mod user_attribute_schema;
|
pub mod user_attribute_schema;
|
||||||
pub mod user_attributes;
|
pub mod user_attributes;
|
||||||
|
pub mod user_object_classes;
|
||||||
|
|
||||||
pub mod group_attribute_schema;
|
pub mod group_attribute_schema;
|
||||||
pub mod group_attributes;
|
pub mod group_attributes;
|
||||||
|
pub mod group_object_classes;
|
||||||
|
|
||||||
pub use prelude::*;
|
pub use prelude::*;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ pub use super::group_attribute_schema::Column as GroupAttributeSchemaColumn;
|
|||||||
pub use super::group_attribute_schema::Entity as GroupAttributeSchema;
|
pub use super::group_attribute_schema::Entity as GroupAttributeSchema;
|
||||||
pub use super::group_attributes::Column as GroupAttributesColumn;
|
pub use super::group_attributes::Column as GroupAttributesColumn;
|
||||||
pub use super::group_attributes::Entity as GroupAttributes;
|
pub use super::group_attributes::Entity as GroupAttributes;
|
||||||
|
pub use super::group_object_classes::Column as GroupObjectClassesColumn;
|
||||||
|
pub use super::group_object_classes::Entity as GroupObjectClasses;
|
||||||
pub use super::groups::Column as GroupColumn;
|
pub use super::groups::Column as GroupColumn;
|
||||||
pub use super::groups::Entity as Group;
|
pub use super::groups::Entity as Group;
|
||||||
pub use super::jwt_refresh_storage::Column as JwtRefreshStorageColumn;
|
pub use super::jwt_refresh_storage::Column as JwtRefreshStorageColumn;
|
||||||
@@ -18,5 +20,7 @@ pub use super::user_attribute_schema::Column as UserAttributeSchemaColumn;
|
|||||||
pub use super::user_attribute_schema::Entity as UserAttributeSchema;
|
pub use super::user_attribute_schema::Entity as UserAttributeSchema;
|
||||||
pub use super::user_attributes::Column as UserAttributesColumn;
|
pub use super::user_attributes::Column as UserAttributesColumn;
|
||||||
pub use super::user_attributes::Entity as UserAttributes;
|
pub use super::user_attributes::Entity as UserAttributes;
|
||||||
|
pub use super::user_object_classes::Column as UserObjectClassesColumn;
|
||||||
|
pub use super::user_object_classes::Entity as UserObjectClasses;
|
||||||
pub use super::users::Column as UserColumn;
|
pub use super::users::Column as UserColumn;
|
||||||
pub use super::users::Entity as User;
|
pub use super::users::Entity as User;
|
||||||
|
|||||||
23
server/src/domain/model/user_object_classes.rs
Normal file
23
server/src/domain/model/user_object_classes.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::domain::types::LdapObjectClass;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "user_object_classes")]
|
||||||
|
pub struct Model {
|
||||||
|
#[sea_orm(primary_key, auto_increment = false)]
|
||||||
|
pub lower_object_class: String,
|
||||||
|
pub object_class: LdapObjectClass,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
|
|
||||||
|
impl ActiveModelBehavior for ActiveModel {}
|
||||||
|
|
||||||
|
impl From<Model> for LdapObjectClass {
|
||||||
|
fn from(value: Model) -> Self {
|
||||||
|
value.object_class
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ use crate::domain::{
|
|||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
|
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct PublicSchema(Schema);
|
pub struct PublicSchema(Schema);
|
||||||
|
|
||||||
impl PublicSchema {
|
impl PublicSchema {
|
||||||
|
|||||||
@@ -79,25 +79,24 @@ fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond {
|
|||||||
impl GroupListerBackendHandler for SqlBackendHandler {
|
impl GroupListerBackendHandler for SqlBackendHandler {
|
||||||
#[instrument(skip(self), level = "debug", ret, err)]
|
#[instrument(skip(self), level = "debug", ret, err)]
|
||||||
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
|
async fn list_groups(&self, filters: Option<GroupRequestFilter>) -> Result<Vec<Group>> {
|
||||||
|
let filters = filters
|
||||||
|
.map(|f| {
|
||||||
|
GroupColumn::GroupId
|
||||||
|
.in_subquery(
|
||||||
|
model::Group::find()
|
||||||
|
.find_also_linked(model::memberships::GroupToUser)
|
||||||
|
.select_only()
|
||||||
|
.column(GroupColumn::GroupId)
|
||||||
|
.filter(get_group_filter_expr(f))
|
||||||
|
.into_query(),
|
||||||
|
)
|
||||||
|
.into_condition()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| SimpleExpr::Value(true.into()).into_condition());
|
||||||
let results = model::Group::find()
|
let results = model::Group::find()
|
||||||
.order_by_asc(GroupColumn::GroupId)
|
.order_by_asc(GroupColumn::GroupId)
|
||||||
.find_with_related(model::Membership)
|
.find_with_related(model::Membership)
|
||||||
.filter(
|
.filter(filters.clone())
|
||||||
filters
|
|
||||||
.map(|f| {
|
|
||||||
GroupColumn::GroupId
|
|
||||||
.in_subquery(
|
|
||||||
model::Group::find()
|
|
||||||
.find_also_linked(model::memberships::GroupToUser)
|
|
||||||
.select_only()
|
|
||||||
.column(GroupColumn::GroupId)
|
|
||||||
.filter(get_group_filter_expr(f))
|
|
||||||
.into_query(),
|
|
||||||
)
|
|
||||||
.into_condition()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| SimpleExpr::Value(true.into()).into_condition()),
|
|
||||||
)
|
|
||||||
.all(&self.sql_pool)
|
.all(&self.sql_pool)
|
||||||
.await?;
|
.await?;
|
||||||
let mut groups: Vec<_> = results
|
let mut groups: Vec<_> = results
|
||||||
@@ -110,9 +109,16 @@ impl GroupListerBackendHandler for SqlBackendHandler {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let group_ids = groups.iter().map(|u| &u.id);
|
|
||||||
let attributes = model::GroupAttributes::find()
|
let attributes = model::GroupAttributes::find()
|
||||||
.filter(model::GroupAttributesColumn::GroupId.is_in(group_ids))
|
.filter(
|
||||||
|
model::GroupAttributesColumn::GroupId.in_subquery(
|
||||||
|
model::Group::find()
|
||||||
|
.filter(filters)
|
||||||
|
.select_only()
|
||||||
|
.column(model::groups::Column::GroupId)
|
||||||
|
.into_query(),
|
||||||
|
),
|
||||||
|
)
|
||||||
.order_by_asc(model::GroupAttributesColumn::GroupId)
|
.order_by_asc(model::GroupAttributesColumn::GroupId)
|
||||||
.order_by_asc(model::GroupAttributesColumn::AttributeName)
|
.order_by_asc(model::GroupAttributesColumn::AttributeName)
|
||||||
.all(&self.sql_pool)
|
.all(&self.sql_pool)
|
||||||
@@ -120,12 +126,6 @@ impl GroupListerBackendHandler for SqlBackendHandler {
|
|||||||
let mut attributes_iter = attributes.into_iter().peekable();
|
let mut attributes_iter = attributes.into_iter().peekable();
|
||||||
use itertools::Itertools; // For take_while_ref
|
use itertools::Itertools; // For take_while_ref
|
||||||
for group in groups.iter_mut() {
|
for group in groups.iter_mut() {
|
||||||
assert!(attributes_iter
|
|
||||||
.peek()
|
|
||||||
.map(|u| u.group_id >= group.id)
|
|
||||||
.unwrap_or(true),
|
|
||||||
"Attributes are not sorted, groups are not sorted, or previous group didn't consume all the attributes");
|
|
||||||
|
|
||||||
group.attributes = attributes_iter
|
group.attributes = attributes_iter
|
||||||
.take_while_ref(|u| u.group_id == group.id)
|
.take_while_ref(|u| u.group_id == group.id)
|
||||||
.map(AttributeValue::from)
|
.map(AttributeValue::from)
|
||||||
|
|||||||
@@ -88,6 +88,20 @@ pub enum GroupAttributes {
|
|||||||
GroupAttributeValue,
|
GroupAttributeValue,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
|
||||||
|
pub enum UserObjectClasses {
|
||||||
|
Table,
|
||||||
|
LowerObjectClass,
|
||||||
|
ObjectClass,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(DeriveIden, PartialEq, Eq, Debug, Serialize, Deserialize, Clone, Copy)]
|
||||||
|
pub enum GroupObjectClasses {
|
||||||
|
Table,
|
||||||
|
LowerObjectClass,
|
||||||
|
ObjectClass,
|
||||||
|
}
|
||||||
|
|
||||||
// Metadata about the SQL DB.
|
// Metadata about the SQL DB.
|
||||||
#[derive(DeriveIden)]
|
#[derive(DeriveIden)]
|
||||||
pub enum Metadata {
|
pub enum Metadata {
|
||||||
@@ -1031,6 +1045,51 @@ async fn migrate_to_v8(transaction: DatabaseTransaction) -> Result<DatabaseTrans
|
|||||||
Ok(transaction)
|
Ok(transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn migrate_to_v9(transaction: DatabaseTransaction) -> Result<DatabaseTransaction, DbErr> {
|
||||||
|
let builder = transaction.get_database_backend();
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
builder.build(
|
||||||
|
Table::create()
|
||||||
|
.table(UserObjectClasses::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserObjectClasses::LowerObjectClass)
|
||||||
|
.string_len(255)
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(UserObjectClasses::ObjectClass)
|
||||||
|
.string_len(255)
|
||||||
|
.not_null(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
transaction
|
||||||
|
.execute(
|
||||||
|
builder.build(
|
||||||
|
Table::create()
|
||||||
|
.table(GroupObjectClasses::Table)
|
||||||
|
.if_not_exists()
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(GroupObjectClasses::LowerObjectClass)
|
||||||
|
.string_len(255)
|
||||||
|
.not_null()
|
||||||
|
.primary_key(),
|
||||||
|
)
|
||||||
|
.col(
|
||||||
|
ColumnDef::new(GroupObjectClasses::ObjectClass)
|
||||||
|
.string_len(255)
|
||||||
|
.not_null(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(transaction)
|
||||||
|
}
|
||||||
|
|
||||||
// This is needed to make an array of async functions.
|
// This is needed to make an array of async functions.
|
||||||
macro_rules! to_sync {
|
macro_rules! to_sync {
|
||||||
($l:ident) => {
|
($l:ident) => {
|
||||||
@@ -1059,6 +1118,7 @@ pub async fn migrate_from_version(
|
|||||||
to_sync!(migrate_to_v6),
|
to_sync!(migrate_to_v6),
|
||||||
to_sync!(migrate_to_v7),
|
to_sync!(migrate_to_v7),
|
||||||
to_sync!(migrate_to_v8),
|
to_sync!(migrate_to_v8),
|
||||||
|
to_sync!(migrate_to_v9),
|
||||||
];
|
];
|
||||||
assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize);
|
assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize);
|
||||||
for migration in 2..=last_version.0 {
|
for migration in 2..=last_version.0 {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use crate::domain::{
|
|||||||
},
|
},
|
||||||
model,
|
model,
|
||||||
sql_backend_handler::SqlBackendHandler,
|
sql_backend_handler::SqlBackendHandler,
|
||||||
types::AttributeName,
|
types::{AttributeName, LdapObjectClass},
|
||||||
};
|
};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
@@ -66,6 +66,44 @@ impl SchemaBackendHandler for SqlBackendHandler {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||||
|
let mut name_key = name.to_string();
|
||||||
|
name_key.make_ascii_lowercase();
|
||||||
|
model::user_object_classes::ActiveModel {
|
||||||
|
lower_object_class: Set(name_key),
|
||||||
|
object_class: Set(name.clone()),
|
||||||
|
}
|
||||||
|
.insert(&self.sql_pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||||
|
let mut name_key = name.to_string();
|
||||||
|
name_key.make_ascii_lowercase();
|
||||||
|
model::group_object_classes::ActiveModel {
|
||||||
|
lower_object_class: Set(name_key),
|
||||||
|
object_class: Set(name.clone()),
|
||||||
|
}
|
||||||
|
.insert(&self.sql_pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||||
|
model::UserObjectClasses::delete_by_id(name.as_str().to_ascii_lowercase())
|
||||||
|
.exec(&self.sql_pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||||
|
model::GroupObjectClasses::delete_by_id(name.as_str().to_ascii_lowercase())
|
||||||
|
.exec(&self.sql_pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SqlBackendHandler {
|
impl SqlBackendHandler {
|
||||||
@@ -79,6 +117,8 @@ impl SqlBackendHandler {
|
|||||||
group_attributes: AttributeList {
|
group_attributes: AttributeList {
|
||||||
attributes: Self::get_group_attributes(transaction).await?,
|
attributes: Self::get_group_attributes(transaction).await?,
|
||||||
},
|
},
|
||||||
|
extra_user_object_classes: Self::get_user_object_classes(transaction).await?,
|
||||||
|
extra_group_object_classes: Self::get_group_object_classes(transaction).await?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +145,30 @@ impl SqlBackendHandler {
|
|||||||
.map(|m| m.into())
|
.map(|m| m.into())
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_user_object_classes(
|
||||||
|
transaction: &DatabaseTransaction,
|
||||||
|
) -> Result<Vec<LdapObjectClass>> {
|
||||||
|
Ok(model::UserObjectClasses::find()
|
||||||
|
.order_by_asc(model::UserObjectClassesColumn::ObjectClass)
|
||||||
|
.all(transaction)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_group_object_classes(
|
||||||
|
transaction: &DatabaseTransaction,
|
||||||
|
) -> Result<Vec<LdapObjectClass>> {
|
||||||
|
Ok(model::GroupObjectClasses::find()
|
||||||
|
.order_by_asc(model::GroupObjectClassesColumn::ObjectClass)
|
||||||
|
.all(transaction)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(Into::into)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -151,7 +215,9 @@ mod tests {
|
|||||||
},
|
},
|
||||||
group_attributes: AttributeList {
|
group_attributes: AttributeList {
|
||||||
attributes: Vec::new()
|
attributes: Vec::new()
|
||||||
}
|
},
|
||||||
|
extra_user_object_classes: Vec::new(),
|
||||||
|
extra_group_object_classes: Vec::new(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -247,4 +313,50 @@ mod tests {
|
|||||||
.attributes
|
.attributes
|
||||||
.contains(&expected_value));
|
.contains(&expected_value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_user_object_class_add_and_delete() {
|
||||||
|
let fixture = TestFixture::new().await;
|
||||||
|
let new_object_class = LdapObjectClass::new("newObjectClass");
|
||||||
|
fixture
|
||||||
|
.handler
|
||||||
|
.add_user_object_class(&new_object_class)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.handler
|
||||||
|
.get_schema()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.extra_user_object_classes,
|
||||||
|
vec![new_object_class.clone()]
|
||||||
|
);
|
||||||
|
fixture
|
||||||
|
.handler
|
||||||
|
.add_user_object_class(&LdapObjectClass::new("newobjEctclass"))
|
||||||
|
.await
|
||||||
|
.expect_err("Should not be able to add the same object class twice");
|
||||||
|
assert_eq!(
|
||||||
|
fixture
|
||||||
|
.handler
|
||||||
|
.get_schema()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.extra_user_object_classes,
|
||||||
|
vec![new_object_class.clone()]
|
||||||
|
);
|
||||||
|
fixture
|
||||||
|
.handler
|
||||||
|
.delete_user_object_class(&new_object_class)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(fixture
|
||||||
|
.handler
|
||||||
|
.get_schema()
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.extra_user_object_classes
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ pub type DbConnection = sea_orm::DatabaseConnection;
|
|||||||
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord, DeriveValueType)]
|
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord, DeriveValueType)]
|
||||||
pub struct SchemaVersion(pub i16);
|
pub struct SchemaVersion(pub i16);
|
||||||
|
|
||||||
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(8);
|
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(9);
|
||||||
|
|
||||||
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
|
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
|
||||||
pub struct PrivateKeyHash(pub [u8; 32]);
|
pub struct PrivateKeyHash(pub [u8; 32]);
|
||||||
|
|||||||
@@ -104,23 +104,22 @@ impl UserListerBackendHandler for SqlBackendHandler {
|
|||||||
// To simplify the query, we always fetch groups. TODO: cleanup.
|
// To simplify the query, we always fetch groups. TODO: cleanup.
|
||||||
_get_groups: bool,
|
_get_groups: bool,
|
||||||
) -> Result<Vec<UserAndGroups>> {
|
) -> Result<Vec<UserAndGroups>> {
|
||||||
|
let filters = filters
|
||||||
|
.map(|f| {
|
||||||
|
UserColumn::UserId
|
||||||
|
.in_subquery(
|
||||||
|
model::User::find()
|
||||||
|
.find_also_linked(model::memberships::UserToGroup)
|
||||||
|
.select_only()
|
||||||
|
.column(UserColumn::UserId)
|
||||||
|
.filter(get_user_filter_expr(f))
|
||||||
|
.into_query(),
|
||||||
|
)
|
||||||
|
.into_condition()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| SimpleExpr::Value(true.into()).into_condition());
|
||||||
let mut users: Vec<_> = model::User::find()
|
let mut users: Vec<_> = model::User::find()
|
||||||
.filter(
|
.filter(filters.clone())
|
||||||
filters
|
|
||||||
.map(|f| {
|
|
||||||
UserColumn::UserId
|
|
||||||
.in_subquery(
|
|
||||||
model::User::find()
|
|
||||||
.find_also_linked(model::memberships::UserToGroup)
|
|
||||||
.select_only()
|
|
||||||
.column(UserColumn::UserId)
|
|
||||||
.filter(get_user_filter_expr(f))
|
|
||||||
.into_query(),
|
|
||||||
)
|
|
||||||
.into_condition()
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| SimpleExpr::Value(true.into()).into_condition()),
|
|
||||||
)
|
|
||||||
.order_by_asc(UserColumn::UserId)
|
.order_by_asc(UserColumn::UserId)
|
||||||
.find_with_linked(model::memberships::UserToGroup)
|
.find_with_linked(model::memberships::UserToGroup)
|
||||||
.order_by_asc(SimpleExpr::Column(
|
.order_by_asc(SimpleExpr::Column(
|
||||||
@@ -134,10 +133,18 @@ impl UserListerBackendHandler for SqlBackendHandler {
|
|||||||
groups: Some(groups.into_iter().map(Into::<GroupDetails>::into).collect()),
|
groups: Some(groups.into_iter().map(Into::<GroupDetails>::into).collect()),
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// At this point, the users don't have attributes, we need to populate it with another query.
|
// At this point, the users don't have attributes, we need to populate it with another query.
|
||||||
let user_ids = users.iter().map(|u| &u.user.user_id);
|
|
||||||
let attributes = model::UserAttributes::find()
|
let attributes = model::UserAttributes::find()
|
||||||
.filter(model::UserAttributesColumn::UserId.is_in(user_ids))
|
.filter(
|
||||||
|
model::UserAttributesColumn::UserId.in_subquery(
|
||||||
|
model::User::find()
|
||||||
|
.filter(filters)
|
||||||
|
.select_only()
|
||||||
|
.column(model::users::Column::UserId)
|
||||||
|
.into_query(),
|
||||||
|
),
|
||||||
|
)
|
||||||
.order_by_asc(model::UserAttributesColumn::UserId)
|
.order_by_asc(model::UserAttributesColumn::UserId)
|
||||||
.order_by_asc(model::UserAttributesColumn::AttributeName)
|
.order_by_asc(model::UserAttributesColumn::AttributeName)
|
||||||
.all(&self.sql_pool)
|
.all(&self.sql_pool)
|
||||||
@@ -145,12 +152,6 @@ impl UserListerBackendHandler for SqlBackendHandler {
|
|||||||
let mut attributes_iter = attributes.into_iter().peekable();
|
let mut attributes_iter = attributes.into_iter().peekable();
|
||||||
use itertools::Itertools; // For take_while_ref
|
use itertools::Itertools; // For take_while_ref
|
||||||
for user in users.iter_mut() {
|
for user in users.iter_mut() {
|
||||||
assert!(attributes_iter
|
|
||||||
.peek()
|
|
||||||
.map(|u| u.user_id >= user.user.user_id)
|
|
||||||
.unwrap_or(true),
|
|
||||||
"Attributes are not sorted, users are not sorted, or previous user didn't consume all the attributes");
|
|
||||||
|
|
||||||
user.user.attributes = attributes_iter
|
user.user.attributes = attributes_iter
|
||||||
.take_while_ref(|u| u.user_id == user.user.user_id)
|
.take_while_ref(|u| u.user_id == user.user.user_id)
|
||||||
.map(AttributeValue::from)
|
.map(AttributeValue::from)
|
||||||
|
|||||||
@@ -271,6 +271,8 @@ impl TryFromU64 for AttributeName {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
make_case_insensitive_comparable_string!(LdapObjectClass);
|
||||||
make_case_insensitive_comparable_string!(Email);
|
make_case_insensitive_comparable_string!(Email);
|
||||||
make_case_insensitive_comparable_string!(GroupName);
|
make_case_insensitive_comparable_string!(GroupName);
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,10 @@ use crate::domain::{
|
|||||||
UpdateUserRequest, UserBackendHandler, UserListerBackendHandler, UserRequestFilter,
|
UpdateUserRequest, UserBackendHandler, UserListerBackendHandler, UserRequestFilter,
|
||||||
},
|
},
|
||||||
schema::PublicSchema,
|
schema::PublicSchema,
|
||||||
types::{AttributeName, Group, GroupDetails, GroupId, GroupName, User, UserAndGroups, UserId},
|
types::{
|
||||||
|
AttributeName, Group, GroupDetails, GroupId, GroupName, LdapObjectClass, User,
|
||||||
|
UserAndGroups, UserId,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
@@ -112,6 +115,10 @@ pub trait AdminBackendHandler:
|
|||||||
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
|
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
|
||||||
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
|
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||||
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
|
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||||
|
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||||
|
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||||
|
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||||
|
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -187,6 +194,18 @@ impl<Handler: BackendHandler> AdminBackendHandler for Handler {
|
|||||||
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()> {
|
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()> {
|
||||||
<Handler as SchemaBackendHandler>::delete_group_attribute(self, name).await
|
<Handler as SchemaBackendHandler>::delete_group_attribute(self, name).await
|
||||||
}
|
}
|
||||||
|
async fn add_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||||
|
<Handler as SchemaBackendHandler>::add_user_object_class(self, name).await
|
||||||
|
}
|
||||||
|
async fn add_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||||
|
<Handler as SchemaBackendHandler>::add_group_object_class(self, name).await
|
||||||
|
}
|
||||||
|
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||||
|
<Handler as SchemaBackendHandler>::delete_user_object_class(self, name).await
|
||||||
|
}
|
||||||
|
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()> {
|
||||||
|
<Handler as SchemaBackendHandler>::delete_group_object_class(self, name).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AccessControlledBackendHandler<Handler> {
|
pub struct AccessControlledBackendHandler<Handler> {
|
||||||
|
|||||||
@@ -677,7 +677,7 @@ where
|
|||||||
if enable_password_reset {
|
if enable_password_reset {
|
||||||
cfg.service(
|
cfg.service(
|
||||||
web::resource("/reset/step1/{user_id}")
|
web::resource("/reset/step1/{user_id}")
|
||||||
.route(web::get().to(get_password_reset_step1_handler::<Backend>)),
|
.route(web::post().to(get_password_reset_step1_handler::<Backend>)),
|
||||||
)
|
)
|
||||||
.service(
|
.service(
|
||||||
web::resource("/reset/step2/{token}")
|
web::resource("/reset/step2/{token}")
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ use lettre::message::Mailbox;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::infra::database_string::DatabaseUrl;
|
||||||
|
|
||||||
/// lldap is a lightweight LDAP server
|
/// lldap is a lightweight LDAP server
|
||||||
#[derive(Debug, Parser, Clone)]
|
#[derive(Debug, Parser, Clone)]
|
||||||
#[clap(version, author)]
|
#[clap(version, author)]
|
||||||
@@ -87,7 +89,7 @@ pub struct RunOpts {
|
|||||||
|
|
||||||
/// Database connection URL
|
/// Database connection URL
|
||||||
#[clap(short, long, env = "LLDAP_DATABASE_URL")]
|
#[clap(short, long, env = "LLDAP_DATABASE_URL")]
|
||||||
pub database_url: Option<String>,
|
pub database_url: Option<DatabaseUrl>,
|
||||||
|
|
||||||
/// Force admin password reset to the config value.
|
/// Force admin password reset to the config value.
|
||||||
#[clap(long, env = "LLDAP_FORCE_LADP_USER_PASS_RESET")]
|
#[clap(long, env = "LLDAP_FORCE_LADP_USER_PASS_RESET")]
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ use crate::{
|
|||||||
sql_tables::{ConfigLocation, PrivateKeyHash, PrivateKeyInfo, PrivateKeyLocation},
|
sql_tables::{ConfigLocation, PrivateKeyHash, PrivateKeyInfo, PrivateKeyLocation},
|
||||||
types::{AttributeName, UserId},
|
types::{AttributeName, UserId},
|
||||||
},
|
},
|
||||||
infra::cli::{GeneralConfigOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, TestEmailOpts},
|
infra::{
|
||||||
|
cli::{GeneralConfigOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, TestEmailOpts},
|
||||||
|
database_string::DatabaseUrl,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use figment::{
|
use figment::{
|
||||||
@@ -91,8 +94,8 @@ pub struct Configuration {
|
|||||||
pub force_ldap_user_pass_reset: bool,
|
pub force_ldap_user_pass_reset: bool,
|
||||||
#[builder(default = "false")]
|
#[builder(default = "false")]
|
||||||
pub force_update_private_key: bool,
|
pub force_update_private_key: bool,
|
||||||
#[builder(default = r#"String::from("sqlite://users.db?mode=rwc")"#)]
|
#[builder(default = r#"DatabaseUrl::from("sqlite://users.db?mode=rwc")"#)]
|
||||||
pub database_url: String,
|
pub database_url: DatabaseUrl,
|
||||||
#[builder(default)]
|
#[builder(default)]
|
||||||
pub ignored_user_attributes: Vec<AttributeName>,
|
pub ignored_user_attributes: Vec<AttributeName>,
|
||||||
#[builder(default)]
|
#[builder(default)]
|
||||||
@@ -411,7 +414,7 @@ impl ConfigOverrider for RunOpts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(database_url) = self.database_url.as_ref() {
|
if let Some(database_url) = self.database_url.as_ref() {
|
||||||
config.database_url = database_url.to_string();
|
config.database_url = database_url.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(force_ldap_user_pass_reset) = self.force_ldap_user_pass_reset {
|
if let Some(force_ldap_user_pass_reset) = self.force_ldap_user_pass_reset {
|
||||||
|
|||||||
54
server/src/infra/database_string.rs
Normal file
54
server/src/infra/database_string.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DatabaseUrl(Url);
|
||||||
|
|
||||||
|
impl From<Url> for DatabaseUrl {
|
||||||
|
fn from(url: Url) -> Self {
|
||||||
|
Self(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&str> for DatabaseUrl {
|
||||||
|
fn from(url: &str) -> Self {
|
||||||
|
Self(Url::parse(url).expect("Invalid database URL"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for DatabaseUrl {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
if self.0.password().is_some() {
|
||||||
|
let mut url = self.0.clone();
|
||||||
|
// It can fail for URLs that cannot have a password, like "mailto:bob@example".
|
||||||
|
let _ = url.set_password(Some("***PASSWORD***"));
|
||||||
|
f.write_fmt(format_args!("{}", url))
|
||||||
|
} else {
|
||||||
|
f.write_fmt(format_args!("{}", self.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ToString for DatabaseUrl {
|
||||||
|
fn to_string(&self) -> String {
|
||||||
|
self.0.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_database_url_debug() {
|
||||||
|
let url = DatabaseUrl::from("postgres://user:pass@localhost:5432/dbname");
|
||||||
|
assert_eq!(
|
||||||
|
format!("{:?}", url),
|
||||||
|
"postgres://user:***PASSWORD***@localhost:5432/dbname"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url.to_string(),
|
||||||
|
"postgres://user:pass@localhost:5432/dbname"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::{
|
domain::{
|
||||||
deserialize::deserialize_attribute_value,
|
deserialize::deserialize_attribute_value,
|
||||||
@@ -7,7 +9,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
types::{
|
types::{
|
||||||
AttributeName, AttributeType, AttributeValue as DomainAttributeValue, GroupId,
|
AttributeName, AttributeType, AttributeValue as DomainAttributeValue, GroupId,
|
||||||
JpegPhoto, UserId,
|
JpegPhoto, LdapObjectClass, UserId,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
infra::{
|
infra::{
|
||||||
@@ -159,11 +161,8 @@ impl<Handler: BackendHandler> Mutation<Handler> {
|
|||||||
})
|
})
|
||||||
.instrument(span.clone())
|
.instrument(span.clone())
|
||||||
.await?;
|
.await?;
|
||||||
Ok(handler
|
let user_details = handler.get_user_details(&user_id).instrument(span).await?;
|
||||||
.get_user_details(&user_id)
|
super::query::User::<Handler>::from_user(user_details, Arc::new(schema))
|
||||||
.instrument(span)
|
|
||||||
.await
|
|
||||||
.map(Into::into)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_group(
|
async fn create_group(
|
||||||
@@ -491,6 +490,90 @@ impl<Handler: BackendHandler> Mutation<Handler> {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(Success::new())
|
Ok(Success::new())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn add_user_object_class(
|
||||||
|
context: &Context<Handler>,
|
||||||
|
name: String,
|
||||||
|
) -> FieldResult<Success> {
|
||||||
|
let span = debug_span!("[GraphQL mutation] add_user_object_class");
|
||||||
|
span.in_scope(|| {
|
||||||
|
debug!(?name);
|
||||||
|
});
|
||||||
|
let handler = context
|
||||||
|
.get_admin_handler()
|
||||||
|
.ok_or_else(field_error_callback(
|
||||||
|
&span,
|
||||||
|
"Unauthorized object class addition",
|
||||||
|
))?;
|
||||||
|
handler
|
||||||
|
.add_user_object_class(&LdapObjectClass::from(name))
|
||||||
|
.instrument(span)
|
||||||
|
.await?;
|
||||||
|
Ok(Success::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn add_group_object_class(
|
||||||
|
context: &Context<Handler>,
|
||||||
|
name: String,
|
||||||
|
) -> FieldResult<Success> {
|
||||||
|
let span = debug_span!("[GraphQL mutation] add_group_object_class");
|
||||||
|
span.in_scope(|| {
|
||||||
|
debug!(?name);
|
||||||
|
});
|
||||||
|
let handler = context
|
||||||
|
.get_admin_handler()
|
||||||
|
.ok_or_else(field_error_callback(
|
||||||
|
&span,
|
||||||
|
"Unauthorized object class addition",
|
||||||
|
))?;
|
||||||
|
handler
|
||||||
|
.add_group_object_class(&LdapObjectClass::from(name))
|
||||||
|
.instrument(span)
|
||||||
|
.await?;
|
||||||
|
Ok(Success::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_user_object_class(
|
||||||
|
context: &Context<Handler>,
|
||||||
|
name: String,
|
||||||
|
) -> FieldResult<Success> {
|
||||||
|
let span = debug_span!("[GraphQL mutation] delete_user_object_class");
|
||||||
|
span.in_scope(|| {
|
||||||
|
debug!(?name);
|
||||||
|
});
|
||||||
|
let handler = context
|
||||||
|
.get_admin_handler()
|
||||||
|
.ok_or_else(field_error_callback(
|
||||||
|
&span,
|
||||||
|
"Unauthorized object class deletion",
|
||||||
|
))?;
|
||||||
|
handler
|
||||||
|
.delete_user_object_class(&LdapObjectClass::from(name))
|
||||||
|
.instrument(span)
|
||||||
|
.await?;
|
||||||
|
Ok(Success::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn delete_group_object_class(
|
||||||
|
context: &Context<Handler>,
|
||||||
|
name: String,
|
||||||
|
) -> FieldResult<Success> {
|
||||||
|
let span = debug_span!("[GraphQL mutation] delete_group_object_class");
|
||||||
|
span.in_scope(|| {
|
||||||
|
debug!(?name);
|
||||||
|
});
|
||||||
|
let handler = context
|
||||||
|
.get_admin_handler()
|
||||||
|
.ok_or_else(field_error_callback(
|
||||||
|
&span,
|
||||||
|
"Unauthorized object class deletion",
|
||||||
|
))?;
|
||||||
|
handler
|
||||||
|
.delete_group_object_class(&LdapObjectClass::from(name))
|
||||||
|
.instrument(span)
|
||||||
|
.await?;
|
||||||
|
Ok(Success::new())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_group_with_details<Handler: BackendHandler>(
|
async fn create_group_with_details<Handler: BackendHandler>(
|
||||||
@@ -513,11 +596,8 @@ async fn create_group_with_details<Handler: BackendHandler>(
|
|||||||
attributes,
|
attributes,
|
||||||
};
|
};
|
||||||
let group_id = handler.create_group(request).await?;
|
let group_id = handler.create_group(request).await?;
|
||||||
Ok(handler
|
let group_details = handler.get_group_details(group_id).instrument(span).await?;
|
||||||
.get_group_details(group_id)
|
super::query::Group::<Handler>::from_group_details(group_details, Arc::new(schema))
|
||||||
.instrument(span)
|
|
||||||
.await
|
|
||||||
.map(Into::into)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn deserialize_attribute(
|
fn deserialize_attribute(
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
domain::{
|
domain::{
|
||||||
deserialize::deserialize_attribute_value,
|
deserialize::deserialize_attribute_value,
|
||||||
handler::{BackendHandler, ReadSchemaBackendHandler},
|
handler::{BackendHandler, ReadSchemaBackendHandler},
|
||||||
ldap::utils::{map_user_field, UserFieldType},
|
ldap::utils::{map_user_field, UserFieldType},
|
||||||
model::UserColumn,
|
model::UserColumn,
|
||||||
schema::{
|
schema::PublicSchema,
|
||||||
PublicSchema, SchemaAttributeExtractor, SchemaGroupAttributeExtractor,
|
types::{AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, UserId},
|
||||||
SchemaUserAttributeExtractor,
|
|
||||||
},
|
|
||||||
types::{AttributeType, GroupDetails, GroupId, JpegPhoto, UserId},
|
|
||||||
},
|
},
|
||||||
infra::{
|
infra::{
|
||||||
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
|
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
|
||||||
@@ -143,11 +142,9 @@ impl<Handler: BackendHandler> Query<Handler> {
|
|||||||
&span,
|
&span,
|
||||||
"Unauthorized access to user data",
|
"Unauthorized access to user data",
|
||||||
))?;
|
))?;
|
||||||
Ok(handler
|
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
|
||||||
.get_user_details(&user_id)
|
let user = handler.get_user_details(&user_id).instrument(span).await?;
|
||||||
.instrument(span)
|
User::<Handler>::from_user(user, schema)
|
||||||
.await
|
|
||||||
.map(Into::into)?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn users(
|
async fn users(
|
||||||
@@ -164,8 +161,8 @@ impl<Handler: BackendHandler> Query<Handler> {
|
|||||||
&span,
|
&span,
|
||||||
"Unauthorized access to user list",
|
"Unauthorized access to user list",
|
||||||
))?;
|
))?;
|
||||||
let schema = self.get_schema(context, span.clone()).await?;
|
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
|
||||||
Ok(handler
|
let users = handler
|
||||||
.list_users(
|
.list_users(
|
||||||
filters
|
filters
|
||||||
.map(|f| f.try_into_domain_filter(&schema))
|
.map(|f| f.try_into_domain_filter(&schema))
|
||||||
@@ -173,8 +170,11 @@ impl<Handler: BackendHandler> Query<Handler> {
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await?;
|
||||||
.map(|v| v.into_iter().map(Into::into).collect())?)
|
users
|
||||||
|
.into_iter()
|
||||||
|
.map(|u| User::<Handler>::from_user_and_groups(u, schema.clone()))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
|
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
|
||||||
@@ -185,11 +185,12 @@ impl<Handler: BackendHandler> Query<Handler> {
|
|||||||
&span,
|
&span,
|
||||||
"Unauthorized access to group list",
|
"Unauthorized access to group list",
|
||||||
))?;
|
))?;
|
||||||
Ok(handler
|
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
|
||||||
.list_groups(None)
|
let domain_groups = handler.list_groups(None).instrument(span).await?;
|
||||||
.instrument(span)
|
domain_groups
|
||||||
.await
|
.into_iter()
|
||||||
.map(|v| v.into_iter().map(Into::into).collect())?)
|
.map(|g| Group::<Handler>::from_group(g, schema.clone()))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn group(context: &Context<Handler>, group_id: i32) -> FieldResult<Group<Handler>> {
|
async fn group(context: &Context<Handler>, group_id: i32) -> FieldResult<Group<Handler>> {
|
||||||
@@ -203,11 +204,12 @@ impl<Handler: BackendHandler> Query<Handler> {
|
|||||||
&span,
|
&span,
|
||||||
"Unauthorized access to group data",
|
"Unauthorized access to group data",
|
||||||
))?;
|
))?;
|
||||||
Ok(handler
|
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
|
||||||
|
let group_details = handler
|
||||||
.get_group_details(GroupId(group_id))
|
.get_group_details(GroupId(group_id))
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await?;
|
||||||
.map(Into::into)?)
|
Group::<Handler>::from_group_details(group_details, schema.clone())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn schema(context: &Context<Handler>) -> FieldResult<Schema<Handler>> {
|
async fn schema(context: &Context<Handler>) -> FieldResult<Schema<Handler>> {
|
||||||
@@ -237,16 +239,45 @@ impl<Handler: BackendHandler> Query<Handler> {
|
|||||||
/// Represents a single user.
|
/// Represents a single user.
|
||||||
pub struct User<Handler: BackendHandler> {
|
pub struct User<Handler: BackendHandler> {
|
||||||
user: DomainUser,
|
user: DomainUser,
|
||||||
|
attributes: Vec<AttributeValue<Handler>>,
|
||||||
|
schema: Arc<PublicSchema>,
|
||||||
|
groups: Option<Vec<Group<Handler>>>,
|
||||||
_phantom: std::marker::PhantomData<Box<Handler>>,
|
_phantom: std::marker::PhantomData<Box<Handler>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
impl<Handler: BackendHandler> User<Handler> {
|
||||||
impl<Handler: BackendHandler> Default for User<Handler> {
|
pub fn from_user(mut user: DomainUser, schema: Arc<PublicSchema>) -> FieldResult<Self> {
|
||||||
fn default() -> Self {
|
let attributes = std::mem::take(&mut user.attributes);
|
||||||
Self {
|
Ok(Self {
|
||||||
user: DomainUser::default(),
|
user,
|
||||||
|
attributes: attributes
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| {
|
||||||
|
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().user_attributes)
|
||||||
|
})
|
||||||
|
.collect::<FieldResult<Vec<_>>>()?,
|
||||||
|
schema,
|
||||||
|
groups: None,
|
||||||
_phantom: std::marker::PhantomData,
|
_phantom: std::marker::PhantomData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Handler: BackendHandler> User<Handler> {
|
||||||
|
pub fn from_user_and_groups(
|
||||||
|
DomainUserAndGroups { user, groups }: DomainUserAndGroups,
|
||||||
|
schema: Arc<PublicSchema>,
|
||||||
|
) -> FieldResult<Self> {
|
||||||
|
let mut user = Self::from_user(user, schema.clone())?;
|
||||||
|
if let Some(groups) = groups {
|
||||||
|
user.groups = Some(
|
||||||
|
groups
|
||||||
|
.into_iter()
|
||||||
|
.map(|g| Group::<Handler>::from_group_details(g, schema.clone()))
|
||||||
|
.collect::<FieldResult<Vec<_>>>()?,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
Ok(user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,17 +330,15 @@ impl<Handler: BackendHandler> User<Handler> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// User-defined attributes.
|
/// User-defined attributes.
|
||||||
fn attributes(&self) -> Vec<AttributeValue<Handler, SchemaUserAttributeExtractor>> {
|
fn attributes(&self) -> &[AttributeValue<Handler>] {
|
||||||
self.user
|
&self.attributes
|
||||||
.attributes
|
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.map(Into::into)
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The groups to which this user belongs.
|
/// The groups to which this user belongs.
|
||||||
async fn groups(&self, context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
|
async fn groups(&self, context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
|
||||||
|
if let Some(groups) = &self.groups {
|
||||||
|
return Ok(groups.clone());
|
||||||
|
}
|
||||||
let span = debug_span!("[GraphQL query] user::groups");
|
let span = debug_span!("[GraphQL query] user::groups");
|
||||||
span.in_scope(|| {
|
span.in_scope(|| {
|
||||||
debug!(user_id = ?self.user.user_id);
|
debug!(user_id = ?self.user.user_id);
|
||||||
@@ -317,36 +346,16 @@ impl<Handler: BackendHandler> User<Handler> {
|
|||||||
let handler = context
|
let handler = context
|
||||||
.get_readable_handler(&self.user.user_id)
|
.get_readable_handler(&self.user.user_id)
|
||||||
.expect("We shouldn't be able to get there without readable permission");
|
.expect("We shouldn't be able to get there without readable permission");
|
||||||
Ok(handler
|
let domain_groups = handler
|
||||||
.get_user_groups(&self.user.user_id)
|
.get_user_groups(&self.user.user_id)
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await?;
|
||||||
.map(|set| {
|
let mut groups = domain_groups
|
||||||
let mut groups = set
|
.into_iter()
|
||||||
.into_iter()
|
.map(|g| Group::<Handler>::from_group_details(g, self.schema.clone()))
|
||||||
.map(Into::into)
|
.collect::<FieldResult<Vec<Group<Handler>>>>()?;
|
||||||
.collect::<Vec<Group<Handler>>>();
|
groups.sort_by(|g1, g2| g1.display_name.cmp(&g2.display_name));
|
||||||
groups.sort_by(|g1, g2| g1.display_name.cmp(&g2.display_name));
|
Ok(groups)
|
||||||
groups
|
|
||||||
})?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Handler: BackendHandler> From<DomainUser> for User<Handler> {
|
|
||||||
fn from(user: DomainUser) -> Self {
|
|
||||||
Self {
|
|
||||||
user,
|
|
||||||
_phantom: std::marker::PhantomData,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Handler: BackendHandler> From<DomainUserAndGroups> for User<Handler> {
|
|
||||||
fn from(user: DomainUserAndGroups) -> Self {
|
|
||||||
Self {
|
|
||||||
user: user.user,
|
|
||||||
_phantom: std::marker::PhantomData,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,11 +366,69 @@ pub struct Group<Handler: BackendHandler> {
|
|||||||
display_name: String,
|
display_name: String,
|
||||||
creation_date: chrono::NaiveDateTime,
|
creation_date: chrono::NaiveDateTime,
|
||||||
uuid: String,
|
uuid: String,
|
||||||
attributes: Vec<DomainAttributeValue>,
|
attributes: Vec<AttributeValue<Handler>>,
|
||||||
members: Option<Vec<String>>,
|
schema: Arc<PublicSchema>,
|
||||||
_phantom: std::marker::PhantomData<Box<Handler>>,
|
_phantom: std::marker::PhantomData<Box<Handler>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<Handler: BackendHandler> Group<Handler> {
|
||||||
|
pub fn from_group(
|
||||||
|
group: DomainGroup,
|
||||||
|
schema: Arc<PublicSchema>,
|
||||||
|
) -> FieldResult<Group<Handler>> {
|
||||||
|
Ok(Self {
|
||||||
|
group_id: group.id.0,
|
||||||
|
display_name: group.display_name.to_string(),
|
||||||
|
creation_date: group.creation_date,
|
||||||
|
uuid: group.uuid.into_string(),
|
||||||
|
attributes: group
|
||||||
|
.attributes
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| {
|
||||||
|
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
|
||||||
|
})
|
||||||
|
.collect::<FieldResult<Vec<_>>>()?,
|
||||||
|
schema,
|
||||||
|
_phantom: std::marker::PhantomData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_group_details(
|
||||||
|
group_details: GroupDetails,
|
||||||
|
schema: Arc<PublicSchema>,
|
||||||
|
) -> FieldResult<Group<Handler>> {
|
||||||
|
Ok(Self {
|
||||||
|
group_id: group_details.group_id.0,
|
||||||
|
display_name: group_details.display_name.to_string(),
|
||||||
|
creation_date: group_details.creation_date,
|
||||||
|
uuid: group_details.uuid.into_string(),
|
||||||
|
attributes: group_details
|
||||||
|
.attributes
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| {
|
||||||
|
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().group_attributes)
|
||||||
|
})
|
||||||
|
.collect::<FieldResult<Vec<_>>>()?,
|
||||||
|
schema,
|
||||||
|
_phantom: std::marker::PhantomData,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Handler: BackendHandler> Clone for Group<Handler> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
group_id: self.group_id,
|
||||||
|
display_name: self.display_name.clone(),
|
||||||
|
creation_date: self.creation_date,
|
||||||
|
uuid: self.uuid.clone(),
|
||||||
|
attributes: self.attributes.clone(),
|
||||||
|
schema: self.schema.clone(),
|
||||||
|
_phantom: std::marker::PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[graphql_object(context = Context<Handler>)]
|
#[graphql_object(context = Context<Handler>)]
|
||||||
impl<Handler: BackendHandler> Group<Handler> {
|
impl<Handler: BackendHandler> Group<Handler> {
|
||||||
fn id(&self) -> i32 {
|
fn id(&self) -> i32 {
|
||||||
@@ -378,12 +445,8 @@ impl<Handler: BackendHandler> Group<Handler> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// User-defined attributes.
|
/// User-defined attributes.
|
||||||
fn attributes(&self) -> Vec<AttributeValue<Handler, SchemaGroupAttributeExtractor>> {
|
fn attributes(&self) -> &[AttributeValue<Handler>] {
|
||||||
self.attributes
|
&self.attributes
|
||||||
.clone()
|
|
||||||
.into_iter()
|
|
||||||
.map(Into::into)
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The groups to which this user belongs.
|
/// The groups to which this user belongs.
|
||||||
@@ -398,42 +461,17 @@ impl<Handler: BackendHandler> Group<Handler> {
|
|||||||
&span,
|
&span,
|
||||||
"Unauthorized access to group data",
|
"Unauthorized access to group data",
|
||||||
))?;
|
))?;
|
||||||
Ok(handler
|
let domain_users = handler
|
||||||
.list_users(
|
.list_users(
|
||||||
Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))),
|
Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))),
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.instrument(span)
|
.instrument(span)
|
||||||
.await
|
.await?;
|
||||||
.map(|v| v.into_iter().map(Into::into).collect())?)
|
domain_users
|
||||||
}
|
.into_iter()
|
||||||
}
|
.map(|u| User::<Handler>::from_user_and_groups(u, self.schema.clone()))
|
||||||
|
.collect()
|
||||||
impl<Handler: BackendHandler> From<GroupDetails> for Group<Handler> {
|
|
||||||
fn from(group_details: GroupDetails) -> Self {
|
|
||||||
Self {
|
|
||||||
group_id: group_details.group_id.0,
|
|
||||||
display_name: group_details.display_name.to_string(),
|
|
||||||
creation_date: group_details.creation_date,
|
|
||||||
uuid: group_details.uuid.into_string(),
|
|
||||||
attributes: group_details.attributes,
|
|
||||||
members: None,
|
|
||||||
_phantom: std::marker::PhantomData,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl<Handler: BackendHandler> From<DomainGroup> for Group<Handler> {
|
|
||||||
fn from(group: DomainGroup) -> Self {
|
|
||||||
Self {
|
|
||||||
group_id: group.id.0,
|
|
||||||
display_name: group.display_name.to_string(),
|
|
||||||
creation_date: group.creation_date,
|
|
||||||
uuid: group.uuid.into_string(),
|
|
||||||
attributes: group.attributes,
|
|
||||||
members: Some(group.users.into_iter().map(UserId::into_string).collect()),
|
|
||||||
_phantom: std::marker::PhantomData,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -465,6 +503,15 @@ impl<Handler: BackendHandler> AttributeSchema<Handler> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<Handler: BackendHandler> Clone for AttributeSchema<Handler> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
schema: self.schema.clone(),
|
||||||
|
_phantom: std::marker::PhantomData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<Handler: BackendHandler> From<DomainAttributeSchema> for AttributeSchema<Handler> {
|
impl<Handler: BackendHandler> From<DomainAttributeSchema> for AttributeSchema<Handler> {
|
||||||
fn from(value: DomainAttributeSchema) -> Self {
|
fn from(value: DomainAttributeSchema) -> Self {
|
||||||
Self {
|
Self {
|
||||||
@@ -476,26 +523,32 @@ impl<Handler: BackendHandler> From<DomainAttributeSchema> for AttributeSchema<Ha
|
|||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
|
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||||
pub struct AttributeList<Handler: BackendHandler> {
|
pub struct AttributeList<Handler: BackendHandler> {
|
||||||
schema: DomainAttributeList,
|
attributes: DomainAttributeList,
|
||||||
|
extra_classes: Vec<LdapObjectClass>,
|
||||||
_phantom: std::marker::PhantomData<Box<Handler>>,
|
_phantom: std::marker::PhantomData<Box<Handler>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[graphql_object(context = Context<Handler>)]
|
#[graphql_object(context = Context<Handler>)]
|
||||||
impl<Handler: BackendHandler> AttributeList<Handler> {
|
impl<Handler: BackendHandler> AttributeList<Handler> {
|
||||||
fn attributes(&self) -> Vec<AttributeSchema<Handler>> {
|
fn attributes(&self) -> Vec<AttributeSchema<Handler>> {
|
||||||
self.schema
|
self.attributes
|
||||||
.attributes
|
.attributes
|
||||||
.clone()
|
.clone()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(Into::into)
|
.map(Into::into)
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn extra_ldap_object_classes(&self) -> Vec<String> {
|
||||||
|
self.extra_classes.iter().map(|c| c.to_string()).collect()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Handler: BackendHandler> From<DomainAttributeList> for AttributeList<Handler> {
|
impl<Handler: BackendHandler> AttributeList<Handler> {
|
||||||
fn from(value: DomainAttributeList) -> Self {
|
fn new(attributes: DomainAttributeList, extra_classes: Vec<LdapObjectClass>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema: value,
|
attributes,
|
||||||
|
extra_classes,
|
||||||
_phantom: std::marker::PhantomData,
|
_phantom: std::marker::PhantomData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,10 +563,16 @@ pub struct Schema<Handler: BackendHandler> {
|
|||||||
#[graphql_object(context = Context<Handler>)]
|
#[graphql_object(context = Context<Handler>)]
|
||||||
impl<Handler: BackendHandler> Schema<Handler> {
|
impl<Handler: BackendHandler> Schema<Handler> {
|
||||||
fn user_schema(&self) -> AttributeList<Handler> {
|
fn user_schema(&self) -> AttributeList<Handler> {
|
||||||
self.schema.get_schema().user_attributes.clone().into()
|
AttributeList::<Handler>::new(
|
||||||
|
self.schema.get_schema().user_attributes.clone(),
|
||||||
|
self.schema.get_schema().extra_user_object_classes.clone(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
fn group_schema(&self) -> AttributeList<Handler> {
|
fn group_schema(&self) -> AttributeList<Handler> {
|
||||||
self.schema.get_schema().group_attributes.clone().into()
|
AttributeList::<Handler>::new(
|
||||||
|
self.schema.get_schema().group_attributes.clone(),
|
||||||
|
self.schema.get_schema().extra_group_object_classes.clone(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,88 +586,92 @@ impl<Handler: BackendHandler> From<PublicSchema> for Schema<Handler> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
|
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
|
||||||
pub struct AttributeValue<Handler: BackendHandler, Extractor> {
|
pub struct AttributeValue<Handler: BackendHandler> {
|
||||||
attribute: DomainAttributeValue,
|
attribute: DomainAttributeValue,
|
||||||
|
schema: AttributeSchema<Handler>,
|
||||||
_phantom: std::marker::PhantomData<Box<Handler>>,
|
_phantom: std::marker::PhantomData<Box<Handler>>,
|
||||||
_phantom_extractor: std::marker::PhantomData<Extractor>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[graphql_object(context = Context<Handler>)]
|
#[graphql_object(context = Context<Handler>)]
|
||||||
impl<Handler: BackendHandler, Extractor: SchemaAttributeExtractor>
|
impl<Handler: BackendHandler> AttributeValue<Handler> {
|
||||||
AttributeValue<Handler, Extractor>
|
|
||||||
{
|
|
||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
self.attribute.name.as_str()
|
self.attribute.name.as_str()
|
||||||
}
|
}
|
||||||
async fn value(&self, context: &Context<Handler>) -> FieldResult<Vec<String>> {
|
|
||||||
let handler = context
|
fn value(&self) -> FieldResult<Vec<String>> {
|
||||||
.handler
|
Ok(serialize_attribute(&self.attribute, &self.schema.schema))
|
||||||
.get_user_restricted_lister_handler(&context.validation_result);
|
}
|
||||||
serialize_attribute(
|
|
||||||
&self.attribute,
|
fn schema(&self) -> &AttributeSchema<Handler> {
|
||||||
Extractor::get_attributes(&PublicSchema::from(handler.get_schema().await?)),
|
&self.schema
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<Handler: BackendHandler> Clone for AttributeValue<Handler> {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
attribute: self.attribute.clone(),
|
||||||
|
schema: self.schema.clone(),
|
||||||
|
_phantom: std::marker::PhantomData,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn serialize_attribute(
|
pub fn serialize_attribute(
|
||||||
attribute: &DomainAttributeValue,
|
attribute: &DomainAttributeValue,
|
||||||
attributes: &DomainAttributeList,
|
attribute_schema: &DomainAttributeSchema,
|
||||||
) -> FieldResult<Vec<String>> {
|
) -> Vec<String> {
|
||||||
let convert_date = |date| chrono::Utc.from_utc_datetime(&date).to_rfc3339();
|
let convert_date = |date| chrono::Utc.from_utc_datetime(&date).to_rfc3339();
|
||||||
attributes
|
match (attribute_schema.attribute_type, attribute_schema.is_list) {
|
||||||
.get_attribute_type(&attribute.name)
|
(AttributeType::String, false) => vec![attribute.value.unwrap::<String>()],
|
||||||
.map(|attribute_type| {
|
(AttributeType::Integer, false) => {
|
||||||
match attribute_type {
|
// LDAP integers are encoded as strings.
|
||||||
(AttributeType::String, false) => {
|
vec![attribute.value.unwrap::<i64>().to_string()]
|
||||||
vec![attribute.value.unwrap::<String>()]
|
}
|
||||||
}
|
(AttributeType::JpegPhoto, false) => {
|
||||||
(AttributeType::Integer, false) => {
|
vec![String::from(&attribute.value.unwrap::<JpegPhoto>())]
|
||||||
// LDAP integers are encoded as strings.
|
}
|
||||||
vec![attribute.value.unwrap::<i64>().to_string()]
|
(AttributeType::DateTime, false) => {
|
||||||
}
|
vec![convert_date(attribute.value.unwrap::<NaiveDateTime>())]
|
||||||
(AttributeType::JpegPhoto, false) => {
|
}
|
||||||
vec![String::from(&attribute.value.unwrap::<JpegPhoto>())]
|
(AttributeType::String, true) => attribute
|
||||||
}
|
.value
|
||||||
(AttributeType::DateTime, false) => {
|
.unwrap::<Vec<String>>()
|
||||||
vec![convert_date(attribute.value.unwrap::<NaiveDateTime>())]
|
.into_iter()
|
||||||
}
|
.collect(),
|
||||||
(AttributeType::String, true) => attribute
|
(AttributeType::Integer, true) => attribute
|
||||||
.value
|
.value
|
||||||
.unwrap::<Vec<String>>()
|
.unwrap::<Vec<i64>>()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect(),
|
.map(|i| i.to_string())
|
||||||
(AttributeType::Integer, true) => attribute
|
.collect(),
|
||||||
.value
|
(AttributeType::JpegPhoto, true) => attribute
|
||||||
.unwrap::<Vec<i64>>()
|
.value
|
||||||
.into_iter()
|
.unwrap::<Vec<JpegPhoto>>()
|
||||||
.map(|i| i.to_string())
|
.iter()
|
||||||
.collect(),
|
.map(String::from)
|
||||||
(AttributeType::JpegPhoto, true) => attribute
|
.collect(),
|
||||||
.value
|
(AttributeType::DateTime, true) => attribute
|
||||||
.unwrap::<Vec<JpegPhoto>>()
|
.value
|
||||||
.iter()
|
.unwrap::<Vec<NaiveDateTime>>()
|
||||||
.map(String::from)
|
.into_iter()
|
||||||
.collect(),
|
.map(convert_date)
|
||||||
(AttributeType::DateTime, true) => attribute
|
.collect(),
|
||||||
.value
|
}
|
||||||
.unwrap::<Vec<NaiveDateTime>>()
|
|
||||||
.into_iter()
|
|
||||||
.map(convert_date)
|
|
||||||
.collect(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.ok_or_else(|| FieldError::from(anyhow::anyhow!("Unknown attribute: {}", &attribute.name)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<Handler: BackendHandler, Extractor> From<DomainAttributeValue>
|
impl<Handler: BackendHandler> AttributeValue<Handler> {
|
||||||
for AttributeValue<Handler, Extractor>
|
fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> FieldResult<Self> {
|
||||||
{
|
match schema.get_attribute_schema(&a.name) {
|
||||||
fn from(value: DomainAttributeValue) -> Self {
|
Some(s) => Ok(AttributeValue::<Handler> {
|
||||||
Self {
|
attribute: a,
|
||||||
attribute: value,
|
schema: AttributeSchema::<Handler> {
|
||||||
_phantom: std::marker::PhantomData,
|
schema: s.clone(),
|
||||||
_phantom_extractor: std::marker::PhantomData,
|
_phantom: std::marker::PhantomData,
|
||||||
|
},
|
||||||
|
_phantom: std::marker::PhantomData,
|
||||||
|
}),
|
||||||
|
None => Err(FieldError::from(format!("Unknown attribute {}", &a.name))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -619,7 +682,7 @@ mod tests {
|
|||||||
use crate::{
|
use crate::{
|
||||||
domain::{
|
domain::{
|
||||||
handler::AttributeList,
|
handler::AttributeList,
|
||||||
types::{AttributeName, AttributeType, Serialized},
|
types::{AttributeName, AttributeType, LdapObjectClass, Serialized},
|
||||||
},
|
},
|
||||||
infra::{
|
infra::{
|
||||||
access_control::{Permission, ValidationResults},
|
access_control::{Permission, ValidationResults},
|
||||||
@@ -704,6 +767,11 @@ mod tests {
|
|||||||
is_hardcoded: false,
|
is_hardcoded: false,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
extra_user_object_classes: vec![
|
||||||
|
LdapObjectClass::from("customUserClass"),
|
||||||
|
LdapObjectClass::from("myUserClass"),
|
||||||
|
],
|
||||||
|
extra_group_object_classes: vec![LdapObjectClass::from("customGroupClass")],
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
mock.expect_get_user_details()
|
mock.expect_get_user_details()
|
||||||
@@ -895,6 +963,7 @@ mod tests {
|
|||||||
isEditable
|
isEditable
|
||||||
isHardcoded
|
isHardcoded
|
||||||
}
|
}
|
||||||
|
extraLdapObjectClasses
|
||||||
}
|
}
|
||||||
groupSchema {
|
groupSchema {
|
||||||
attributes {
|
attributes {
|
||||||
@@ -905,6 +974,7 @@ mod tests {
|
|||||||
isEditable
|
isEditable
|
||||||
isHardcoded
|
isHardcoded
|
||||||
}
|
}
|
||||||
|
extraLdapObjectClasses
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}"#;
|
}"#;
|
||||||
@@ -989,7 +1059,8 @@ mod tests {
|
|||||||
"isEditable": false,
|
"isEditable": false,
|
||||||
"isHardcoded": true,
|
"isHardcoded": true,
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
|
"extraLdapObjectClasses": ["customUserClass"],
|
||||||
},
|
},
|
||||||
"groupSchema": {
|
"groupSchema": {
|
||||||
"attributes": [
|
"attributes": [
|
||||||
@@ -1025,7 +1096,8 @@ mod tests {
|
|||||||
"isEditable": false,
|
"isEditable": false,
|
||||||
"isHardcoded": true,
|
"isHardcoded": true,
|
||||||
},
|
},
|
||||||
]
|
],
|
||||||
|
"extraLdapObjectClasses": [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -1042,6 +1114,7 @@ mod tests {
|
|||||||
attributes {
|
attributes {
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
|
extraLdapObjectClasses
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}"#;
|
}"#;
|
||||||
@@ -1063,6 +1136,8 @@ mod tests {
|
|||||||
group_attributes: AttributeList {
|
group_attributes: AttributeList {
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
},
|
},
|
||||||
|
extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")],
|
||||||
|
extra_group_object_classes: Vec::new(),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1088,7 +1163,8 @@ mod tests {
|
|||||||
{"name": "mail"},
|
{"name": "mail"},
|
||||||
{"name": "user_id"},
|
{"name": "user_id"},
|
||||||
{"name": "uuid"},
|
{"name": "uuid"},
|
||||||
]
|
],
|
||||||
|
"extraLdapObjectClasses": ["customUserClass"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} ),
|
} ),
|
||||||
|
|||||||
@@ -570,10 +570,27 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
|
|||||||
.await
|
.await
|
||||||
});
|
});
|
||||||
Ok(match scope {
|
Ok(match scope {
|
||||||
SearchScope::Global => InternalSearchResults::UsersAndGroups(
|
SearchScope::Global => {
|
||||||
get_user_list(&request.filter).await?,
|
let users = get_user_list(&request.filter).await;
|
||||||
get_group_list(&request.filter).await?,
|
let groups = get_group_list(&request.filter).await;
|
||||||
),
|
match (users, groups) {
|
||||||
|
(Ok(users), Err(e)) => {
|
||||||
|
warn!("Error while getting groups: {:#}", e);
|
||||||
|
InternalSearchResults::UsersAndGroups(users, Vec::new())
|
||||||
|
}
|
||||||
|
(Err(e), Ok(groups)) => {
|
||||||
|
warn!("Error while getting users: {:#}", e);
|
||||||
|
InternalSearchResults::UsersAndGroups(Vec::new(), groups)
|
||||||
|
}
|
||||||
|
(Err(user_error), Err(_)) => {
|
||||||
|
InternalSearchResults::Raw(vec![make_search_error(
|
||||||
|
user_error.code,
|
||||||
|
user_error.message,
|
||||||
|
)])
|
||||||
|
}
|
||||||
|
(Ok(users), Ok(groups)) => InternalSearchResults::UsersAndGroups(users, groups),
|
||||||
|
}
|
||||||
|
}
|
||||||
SearchScope::Users => InternalSearchResults::UsersAndGroups(
|
SearchScope::Users => InternalSearchResults::UsersAndGroups(
|
||||||
get_user_list(&request.filter).await?,
|
get_user_list(&request.filter).await?,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -1273,7 +1290,8 @@ mod tests {
|
|||||||
b"inetOrgPerson".to_vec(),
|
b"inetOrgPerson".to_vec(),
|
||||||
b"posixAccount".to_vec(),
|
b"posixAccount".to_vec(),
|
||||||
b"mailAccount".to_vec(),
|
b"mailAccount".to_vec(),
|
||||||
b"person".to_vec()
|
b"person".to_vec(),
|
||||||
|
b"customUserClass".to_vec(),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
LdapPartialAttribute {
|
LdapPartialAttribute {
|
||||||
@@ -1315,7 +1333,8 @@ mod tests {
|
|||||||
b"inetOrgPerson".to_vec(),
|
b"inetOrgPerson".to_vec(),
|
||||||
b"posixAccount".to_vec(),
|
b"posixAccount".to_vec(),
|
||||||
b"mailAccount".to_vec(),
|
b"mailAccount".to_vec(),
|
||||||
b"person".to_vec()
|
b"person".to_vec(),
|
||||||
|
b"customUserClass".to_vec(),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
LdapPartialAttribute {
|
LdapPartialAttribute {
|
||||||
@@ -1902,7 +1921,49 @@ mod tests {
|
|||||||
b"inetOrgPerson".to_vec(),
|
b"inetOrgPerson".to_vec(),
|
||||||
b"posixAccount".to_vec(),
|
b"posixAccount".to_vec(),
|
||||||
b"mailAccount".to_vec(),
|
b"mailAccount".to_vec(),
|
||||||
b"person".to_vec()
|
b"person".to_vec(),
|
||||||
|
b"customUserClass".to_vec(),
|
||||||
|
]
|
||||||
|
},]
|
||||||
|
}),
|
||||||
|
make_search_success()
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_search_filters_custom_object_class() {
|
||||||
|
let mut mock = MockTestBackendHandler::new();
|
||||||
|
mock.expect_list_users()
|
||||||
|
.with(eq(Some(UserRequestFilter::from(true))), eq(false))
|
||||||
|
.times(1)
|
||||||
|
.return_once(|_, _| {
|
||||||
|
Ok(vec![UserAndGroups {
|
||||||
|
user: User {
|
||||||
|
user_id: UserId::new("bob_1"),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
groups: None,
|
||||||
|
}])
|
||||||
|
});
|
||||||
|
let mut ldap_handler = setup_bound_admin_handler(mock).await;
|
||||||
|
let request = make_user_search_request(
|
||||||
|
LdapFilter::Equality("objectClass".to_owned(), "CUSTOMuserCLASS".to_owned()),
|
||||||
|
vec!["objectclass"],
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
ldap_handler.do_search_or_dse(&request).await,
|
||||||
|
Ok(vec![
|
||||||
|
LdapOp::SearchResultEntry(LdapSearchResultEntry {
|
||||||
|
dn: "uid=bob_1,ou=people,dc=example,dc=com".to_string(),
|
||||||
|
attributes: vec![LdapPartialAttribute {
|
||||||
|
atype: "objectclass".to_string(),
|
||||||
|
vals: vec![
|
||||||
|
b"inetOrgPerson".to_vec(),
|
||||||
|
b"posixAccount".to_vec(),
|
||||||
|
b"mailAccount".to_vec(),
|
||||||
|
b"person".to_vec(),
|
||||||
|
b"customUserClass".to_vec(),
|
||||||
]
|
]
|
||||||
},]
|
},]
|
||||||
}),
|
}),
|
||||||
@@ -1966,7 +2027,8 @@ mod tests {
|
|||||||
b"inetOrgPerson".to_vec(),
|
b"inetOrgPerson".to_vec(),
|
||||||
b"posixAccount".to_vec(),
|
b"posixAccount".to_vec(),
|
||||||
b"mailAccount".to_vec(),
|
b"mailAccount".to_vec(),
|
||||||
b"person".to_vec()
|
b"person".to_vec(),
|
||||||
|
b"customUserClass".to_vec(),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
LdapPartialAttribute {
|
LdapPartialAttribute {
|
||||||
@@ -2051,6 +2113,7 @@ mod tests {
|
|||||||
b"posixAccount".to_vec(),
|
b"posixAccount".to_vec(),
|
||||||
b"mailAccount".to_vec(),
|
b"mailAccount".to_vec(),
|
||||||
b"person".to_vec(),
|
b"person".to_vec(),
|
||||||
|
b"customUserClass".to_vec(),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
LdapPartialAttribute {
|
LdapPartialAttribute {
|
||||||
@@ -2832,6 +2895,11 @@ mod tests {
|
|||||||
is_hardcoded: false,
|
is_hardcoded: false,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
extra_user_object_classes: vec![
|
||||||
|
LdapObjectClass::from("customUserClass"),
|
||||||
|
LdapObjectClass::from("myUserClass"),
|
||||||
|
],
|
||||||
|
extra_group_object_classes: vec![LdapObjectClass::from("customGroupClass")],
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let mut ldap_handler = setup_bound_readonly_handler(mock).await;
|
let mut ldap_handler = setup_bound_readonly_handler(mock).await;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ pub mod access_control;
|
|||||||
pub mod auth_service;
|
pub mod auth_service;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod configuration;
|
pub mod configuration;
|
||||||
|
pub mod database_string;
|
||||||
pub mod db_cleaner;
|
pub mod db_cleaner;
|
||||||
pub mod graphql;
|
pub mod graphql;
|
||||||
pub mod healthcheck;
|
pub mod healthcheck;
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ mockall::mock! {
|
|||||||
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
|
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
|
||||||
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
|
async fn delete_user_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||||
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
|
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()>;
|
||||||
|
async fn add_user_object_class(&self, request: &LdapObjectClass) -> Result<()>;
|
||||||
|
async fn add_group_object_class(&self, request: &LdapObjectClass) -> Result<()>;
|
||||||
|
async fn delete_user_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||||
|
async fn delete_group_object_class(&self, name: &LdapObjectClass) -> Result<()>;
|
||||||
}
|
}
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl BackendHandler for TestBackendHandler {}
|
impl BackendHandler for TestBackendHandler {}
|
||||||
@@ -102,6 +106,8 @@ pub fn setup_default_schema(mock: &mut MockTestBackendHandler) {
|
|||||||
group_attributes: AttributeList {
|
group_attributes: AttributeList {
|
||||||
attributes: Vec::new(),
|
attributes: Vec::new(),
|
||||||
},
|
},
|
||||||
|
extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")],
|
||||||
|
extra_group_object_classes: Vec::new(),
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ use crate::{
|
|||||||
infra::{
|
infra::{
|
||||||
cli::*,
|
cli::*,
|
||||||
configuration::{compare_private_key_hashes, Configuration},
|
configuration::{compare_private_key_hashes, Configuration},
|
||||||
|
database_string::DatabaseUrl,
|
||||||
db_cleaner::Scheduler,
|
db_cleaner::Scheduler,
|
||||||
healthcheck, mail,
|
healthcheck, mail,
|
||||||
},
|
},
|
||||||
@@ -26,7 +27,7 @@ use actix::Actor;
|
|||||||
use actix_server::ServerBuilder;
|
use actix_server::ServerBuilder;
|
||||||
use anyhow::{anyhow, bail, Context, Result};
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
use futures_util::TryFutureExt;
|
use futures_util::TryFutureExt;
|
||||||
use sea_orm::Database;
|
use sea_orm::{Database, DatabaseConnection};
|
||||||
use tracing::*;
|
use tracing::*;
|
||||||
|
|
||||||
mod domain;
|
mod domain;
|
||||||
@@ -79,12 +80,9 @@ async fn ensure_group_exists(handler: &SqlBackendHandler, group_name: &str) -> R
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[instrument(skip_all)]
|
async fn setup_sql_tables(database_url: &DatabaseUrl) -> Result<DatabaseConnection> {
|
||||||
async fn set_up_server(config: Configuration) -> Result<ServerBuilder> {
|
|
||||||
info!("Starting LLDAP version {}", env!("CARGO_PKG_VERSION"));
|
|
||||||
|
|
||||||
let sql_pool = {
|
let sql_pool = {
|
||||||
let mut sql_opt = sea_orm::ConnectOptions::new(config.database_url.clone());
|
let mut sql_opt = sea_orm::ConnectOptions::new(database_url.to_string());
|
||||||
sql_opt
|
sql_opt
|
||||||
.max_connections(5)
|
.max_connections(5)
|
||||||
.sqlx_logging(true)
|
.sqlx_logging(true)
|
||||||
@@ -93,7 +91,18 @@ async fn set_up_server(config: Configuration) -> Result<ServerBuilder> {
|
|||||||
};
|
};
|
||||||
domain::sql_tables::init_table(&sql_pool)
|
domain::sql_tables::init_table(&sql_pool)
|
||||||
.await
|
.await
|
||||||
.context("while creating the tables")?;
|
.context("while creating base tables")?;
|
||||||
|
infra::jwt_sql_tables::init_table(&sql_pool)
|
||||||
|
.await
|
||||||
|
.context("while creating jwt tables")?;
|
||||||
|
Ok(sql_pool)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[instrument(skip_all)]
|
||||||
|
async fn set_up_server(config: Configuration) -> Result<ServerBuilder> {
|
||||||
|
info!("Starting LLDAP version {}", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
|
let sql_pool = setup_sql_tables(&config.database_url).await?;
|
||||||
let private_key_info = config.get_private_key_info();
|
let private_key_info = config.get_private_key_info();
|
||||||
let force_update_private_key = config.force_update_private_key;
|
let force_update_private_key = config.force_update_private_key;
|
||||||
match (
|
match (
|
||||||
@@ -157,7 +166,6 @@ async fn set_up_server(config: Configuration) -> Result<ServerBuilder> {
|
|||||||
actix_server::Server::build(),
|
actix_server::Server::build(),
|
||||||
)
|
)
|
||||||
.context("while binding the LDAP server")?;
|
.context("while binding the LDAP server")?;
|
||||||
infra::jwt_sql_tables::init_table(&sql_pool).await?;
|
|
||||||
let server_builder =
|
let server_builder =
|
||||||
infra::tcp_server::build_tcp_server(&config, backend_handler, server_builder)
|
infra::tcp_server::build_tcp_server(&config, backend_handler, server_builder)
|
||||||
.await
|
.await
|
||||||
@@ -168,70 +176,41 @@ async fn set_up_server(config: Configuration) -> Result<ServerBuilder> {
|
|||||||
Ok(server_builder)
|
Ok(server_builder)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_server(config: Configuration) -> Result<()> {
|
async fn run_server_command(opts: RunOpts) -> Result<()> {
|
||||||
set_up_server(config)
|
|
||||||
.await?
|
|
||||||
.workers(1)
|
|
||||||
.run()
|
|
||||||
.await
|
|
||||||
.context("while starting the server")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_server_command(opts: RunOpts) -> Result<()> {
|
|
||||||
debug!("CLI: {:#?}", &opts);
|
debug!("CLI: {:#?}", &opts);
|
||||||
|
|
||||||
let config = infra::configuration::init(opts)?;
|
let config = infra::configuration::init(opts)?;
|
||||||
infra::logging::init(&config)?;
|
infra::logging::init(&config)?;
|
||||||
|
|
||||||
use std::sync::{Arc, Mutex};
|
let server = set_up_server(config).await?.workers(1);
|
||||||
let result = Arc::new(Mutex::new(Ok(())));
|
|
||||||
let result_async = Arc::clone(&result);
|
|
||||||
actix::run(run_server(config).unwrap_or_else(move |e| *result_async.lock().unwrap() = Err(e)))?;
|
|
||||||
if let Err(e) = result.lock().unwrap().as_ref() {
|
|
||||||
anyhow::bail!(format!("Could not set up servers: {:#}", e));
|
|
||||||
}
|
|
||||||
|
|
||||||
info!("End.");
|
server.run().await.context("while starting the server")
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn send_test_email_command(opts: TestEmailOpts) -> Result<()> {
|
async fn send_test_email_command(opts: TestEmailOpts) -> Result<()> {
|
||||||
let to = opts.to.parse()?;
|
let to = opts.to.parse()?;
|
||||||
let config = infra::configuration::init(opts)?;
|
let config = infra::configuration::init(opts)?;
|
||||||
infra::logging::init(&config)?;
|
infra::logging::init(&config)?;
|
||||||
|
|
||||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
mail::send_test_email(to, &config.smtp_options)
|
||||||
.enable_all()
|
.await
|
||||||
.build()?;
|
.context("Could not send email: {:#}")
|
||||||
|
|
||||||
runtime.block_on(
|
|
||||||
mail::send_test_email(to, &config.smtp_options)
|
|
||||||
.unwrap_or_else(|e| error!("Could not send email: {:#}", e)),
|
|
||||||
);
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run_healthcheck(opts: RunOpts) -> Result<()> {
|
async fn run_healthcheck(opts: RunOpts) -> Result<()> {
|
||||||
debug!("CLI: {:#?}", &opts);
|
debug!("CLI: {:#?}", &opts);
|
||||||
let config = infra::configuration::init(opts)?;
|
let config = infra::configuration::init(opts)?;
|
||||||
infra::logging::init(&config)?;
|
infra::logging::init(&config)?;
|
||||||
|
|
||||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
|
||||||
.enable_all()
|
|
||||||
.build()?;
|
|
||||||
|
|
||||||
info!("Starting healthchecks");
|
info!("Starting healthchecks");
|
||||||
|
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
let delay = Duration::from_millis(3000);
|
let delay = Duration::from_millis(3000);
|
||||||
let (ldap, ldaps, api) = runtime.block_on(async {
|
let (ldap, ldaps, api) = tokio::join!(
|
||||||
tokio::join!(
|
timeout(delay, healthcheck::check_ldap(config.ldap_port)),
|
||||||
timeout(delay, healthcheck::check_ldap(config.ldap_port)),
|
timeout(delay, healthcheck::check_ldaps(&config.ldaps_options)),
|
||||||
timeout(delay, healthcheck::check_ldaps(&config.ldaps_options)),
|
timeout(delay, healthcheck::check_api(config.http_port)),
|
||||||
timeout(delay, healthcheck::check_api(config.http_port)),
|
);
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
let failure = [ldap, ldaps, api]
|
let failure = [ldap, ldaps, api]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -243,50 +222,29 @@ fn run_healthcheck(opts: RunOpts) -> Result<()> {
|
|||||||
})
|
})
|
||||||
.any(|r| r.is_err());
|
.any(|r| r.is_err());
|
||||||
if failure {
|
if failure {
|
||||||
error!("Healthcheck failed");
|
bail!("Healthcheck failed")
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
std::process::exit(i32::from(failure))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn create_schema(database_url: String) -> Result<()> {
|
async fn create_schema_command(opts: RunOpts) -> Result<()> {
|
||||||
let sql_pool = {
|
|
||||||
let mut sql_opt = sea_orm::ConnectOptions::new(database_url.clone());
|
|
||||||
sql_opt
|
|
||||||
.max_connections(1)
|
|
||||||
.sqlx_logging(true)
|
|
||||||
.sqlx_logging_level(log::LevelFilter::Debug);
|
|
||||||
Database::connect(sql_opt).await?
|
|
||||||
};
|
|
||||||
domain::sql_tables::init_table(&sql_pool)
|
|
||||||
.await
|
|
||||||
.context("while creating base tables")?;
|
|
||||||
infra::jwt_sql_tables::init_table(&sql_pool)
|
|
||||||
.await
|
|
||||||
.context("while creating jwt tables")?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn create_schema_command(opts: RunOpts) -> Result<()> {
|
|
||||||
debug!("CLI: {:#?}", &opts);
|
debug!("CLI: {:#?}", &opts);
|
||||||
let config = infra::configuration::init(opts)?;
|
let config = infra::configuration::init(opts)?;
|
||||||
infra::logging::init(&config)?;
|
infra::logging::init(&config)?;
|
||||||
let database_url = config.database_url;
|
setup_sql_tables(&config.database_url).await?;
|
||||||
|
|
||||||
actix::run(
|
|
||||||
create_schema(database_url).unwrap_or_else(|e| error!("Could not create schema: {:#}", e)),
|
|
||||||
)?;
|
|
||||||
|
|
||||||
info!("Schema created successfully.");
|
info!("Schema created successfully.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<()> {
|
#[actix::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
let cli_opts = infra::cli::init();
|
let cli_opts = infra::cli::init();
|
||||||
match cli_opts.command {
|
match cli_opts.command {
|
||||||
Command::ExportGraphQLSchema(opts) => infra::graphql::api::export_schema(opts),
|
Command::ExportGraphQLSchema(opts) => infra::graphql::api::export_schema(opts),
|
||||||
Command::Run(opts) => run_server_command(opts),
|
Command::Run(opts) => run_server_command(opts).await,
|
||||||
Command::HealthCheck(opts) => run_healthcheck(opts),
|
Command::HealthCheck(opts) => run_healthcheck(opts).await,
|
||||||
Command::SendTestEmail(opts) => send_test_email_command(opts),
|
Command::SendTestEmail(opts) => send_test_email_command(opts).await,
|
||||||
Command::CreateSchema(opts) => create_schema_command(opts),
|
Command::CreateSchema(opts) => create_schema_command(opts).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user