1 Commits

Author SHA1 Message Date
Austin Alvarado
a627e69e46 putting a pin in it 2024-01-19 01:37:54 +00:00
71 changed files with 1061 additions and 3168 deletions

View File

@@ -1,4 +1,4 @@
FROM rust:1.74
FROM rust:1.72
ARG USERNAME=lldapdev
# We need to keep the user as 1001 to match the GitHub runner's UID.

11
.github/codecov.yml vendored
View File

@@ -1,19 +1,10 @@
codecov:
require_ci_to_pass: yes
comment:
layout: "header,diff,files"
layout: "diff,flags"
require_changes: true
require_base: true
require_head: true
coverage:
status:
project:
default:
target: "75%"
threshold: "0.1%"
removed_code_behavior: adjust_base
github_checks:
annotations: true
ignore:
- "app"
- "docs"

View File

@@ -641,7 +641,7 @@ jobs:
- name: Update repo description
if: github.event_name != 'pull_request'
uses: peter-evans/dockerhub-description@v4
uses: peter-evans/dockerhub-description@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
@@ -649,7 +649,7 @@ jobs:
- name: Update lldap repo description
if: github.event_name != 'pull_request'
uses: peter-evans/dockerhub-description@v4
uses: peter-evans/dockerhub-description@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}

View File

@@ -374,7 +374,6 @@ folder for help with:
- [Kasm](example_configs/kasm.md)
- [KeyCloak](example_configs/keycloak.md)
- [LibreNMS](example_configs/librenms.md)
- [Maddy](example_configs/maddy.md)
- [Mastodon](example_configs/mastodon.env.example)
- [Matrix](example_configs/matrix_synapse.yml)
- [Mealie](example_configs/mealie.md)
@@ -385,7 +384,6 @@ folder for help with:
- [Portainer](example_configs/portainer.md)
- [PowerDNS Admin](example_configs/powerdns_admin.md)
- [Proxmox VE](example_configs/proxmox.md)
- [Radicale](example_configs/radicale.md)
- [Rancher](example_configs/rancher.md)
- [Seafile](example_configs/seafile.md)
- [Shaarli](example_configs/shaarli.md)

View File

@@ -37,16 +37,12 @@ version = "0.3"
features = [
"Document",
"Element",
"Event",
"FileReader",
"FormData",
"HtmlDocument",
"HtmlFormElement",
"HtmlInputElement",
"HtmlOptionElement",
"HtmlOptionsCollection",
"HtmlSelectElement",
"SubmitEvent",
"console",
]

View File

@@ -1,5 +0,0 @@
mutation CreateGroupAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!) {
addGroupAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: false) {
ok
}
}

View File

@@ -1,5 +0,0 @@
mutation CreateUserAttribute($name: String!, $attributeType: AttributeType!, $isList: Boolean!, $isVisible: Boolean!, $isEditable: Boolean!) {
addUserAttribute(name: $name, attributeType: $attributeType, isList: $isList, isVisible: $isVisible, isEditable: $isEditable) {
ok
}
}

View File

@@ -1,5 +0,0 @@
mutation DeleteGroupAttributeQuery($name: String!) {
deleteGroupAttribute(name: $name) {
ok
}
}

View File

@@ -1,5 +0,0 @@
mutation DeleteUserAttributeQuery($name: String!) {
deleteUserAttribute(name: $name) {
ok
}
}

View File

@@ -1,13 +0,0 @@
query GetGroupAttributesSchema {
schema {
groupSchema {
attributes {
name
attributeType
isList
isVisible
isHardcoded
}
}
}
}

View File

@@ -1,14 +0,0 @@
query GetUserAttributesSchema {
schema {
userSchema {
attributes {
name
attributeType
isList
isVisible
isEditable
isHardcoded
}
}
}
}

View File

@@ -12,17 +12,5 @@ query GetUserDetails($id: String!) {
id
displayName
}
attributes {
name
value
schema {
name
attributeType
isList
isVisible
isEditable
isHardcoded
}
}
}
}

View File

@@ -1,26 +1,23 @@
use crate::{
components::{
banner::Banner,
change_password::ChangePasswordForm,
create_group::CreateGroupForm,
create_group_attribute::CreateGroupAttributeForm,
create_user::CreateUserForm,
create_user_attribute::CreateUserAttributeForm,
group_details::GroupDetails,
group_schema_table::ListGroupSchema,
group_table::GroupTable,
login::LoginForm,
logout::LogoutButton,
reset_password_step1::ResetPasswordStep1Form,
reset_password_step2::ResetPasswordStep2Form,
router::{AppRoute, Link, Redirect},
user_details::UserDetails,
user_schema_table::ListUserSchema,
user_table::UserTable,
},
infra::{api::HostService, cookies::get_cookie},
};
use gloo_console::error;
use wasm_bindgen::prelude::*;
use yew::{
function_component,
html::Scope,
@@ -33,6 +30,25 @@ use yew_router::{
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)]
pub fn app_container() -> Html {
html! {
@@ -119,11 +135,10 @@ impl Component for App {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link().clone();
let is_admin = self.is_admin();
let username = self.user_info.clone().map(|(username, _)| username);
let password_reset_enabled = self.password_reset_enabled;
html! {
<div>
<Banner is_admin={is_admin} username={username} on_logged_out={link.callback(|_| Msg::Logout)} />
{self.view_banner(ctx)}
<div class="container py-3 bg-kug">
<div class="row justify-content-center" style="padding-bottom: 80px;">
<main class="py-3" style="max-width: 1000px">
@@ -212,12 +227,6 @@ impl App {
AppRoute::CreateGroup => html! {
<CreateGroupForm/>
},
AppRoute::CreateUserAttribute => html! {
<CreateUserAttributeForm/>
},
AppRoute::CreateGroupAttribute => html! {
<CreateGroupAttributeForm/>
},
AppRoute::ListGroups => html! {
<div>
<GroupTable />
@@ -227,12 +236,6 @@ impl App {
</Link>
</div>
},
AppRoute::ListUserSchema => html! {
<ListUserSchema />
},
AppRoute::ListGroupSchema => html! {
<ListGroupSchema />
},
AppRoute::GroupDetails { group_id } => html! {
<GroupDetails group_id={*group_id} />
},
@@ -260,6 +263,91 @@ 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 {
html! {
<footer class="text-center fixed-bottom text-muted bg-light py-2">

View File

@@ -1,87 +0,0 @@
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>
}
}

View File

@@ -1,132 +0,0 @@
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>
}
}

View File

@@ -1,8 +1,5 @@
use crate::{
components::{
form::{field::Field, submit::Submit},
router::{AppRoute, Link},
},
components::router::{AppRoute, Link},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
@@ -210,6 +207,7 @@ impl Component for ChangePasswordForm {
fn view(&self, ctx: &Context<Self>) -> Html {
let is_admin = ctx.props().is_admin;
let link = ctx.link();
type Field = yew_form::Field<FormModel>;
html! {
<>
<div class="mb-2 mt-2">
@@ -226,44 +224,90 @@ impl Component for ChangePasswordForm {
}
} else { html! {} }
}
<form class="form">
<form
class="form">
{if !is_admin { html! {
<Field<FormModel>
form={&self.form}
required=true
label="Current password"
field_name="old_password"
input_type="password"
autocomplete="current-password"
oninput={link.callback(|_| Msg::FormUpdate)} />
<div class="form-group row">
<label for="old_password"
class="form-label col-sm-2 col-form-label">
{"Current password*:"}
</label>
<div class="col-sm-10">
<Field
form={&self.form}
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! {} }}
<Field<FormModel>
form={&self.form}
required=true
label="New password"
field_name="password"
input_type="password"
autocomplete="new-password"
oninput={link.callback(|_| Msg::FormUpdate)} />
<Field<FormModel>
form={&self.form}
required=true
label="Confirm password"
field_name="confirm_password"
input_type="password"
autocomplete="new-password"
oninput={link.callback(|_| Msg::FormUpdate)} />
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})}
text="Save changes" >
<div class="form-group row mb-3">
<label for="new_password"
class="form-label col-sm-2 col-form-label">
{"New Password"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-sm-10">
<Field
form={&self.form}
field_name="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("password")}
</div>
</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
classes="btn btn-secondary ms-2 col-auto col-form-label"
to={AppRoute::UserDetails{user_id: ctx.props().username.clone()}}>
<i class="bi-arrow-return-left me-2"></i>
{"Back"}
</Link>
</Submit>
</div>
</form>
</>
}

View File

@@ -1,8 +1,5 @@
use crate::{
components::{
form::{field::Field, submit::Submit},
router::AppRoute,
},
components::router::AppRoute,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Result};
@@ -96,21 +93,44 @@ impl Component for CreateGroupForm {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = ctx.link();
type Field = yew_form::Field<CreateGroupModel>;
html! {
<div class="row justify-content-center">
<form class="form py-3" style="max-width: 636px">
<div class="row mb-3">
<h5 class="fw-bold">{"Create a group"}</h5>
</div>
<Field<CreateGroupModel>
form={&self.form}
required=true
label="Group name"
field_name="groupname"
oninput={link.callback(|_| Msg::Update)} />
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
<div class="form-group row mb-3">
<label for="groupname"
class="form-label col-4 col-form-label">
{"Group name"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-8">
<Field
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>
{ if let Some(e) = &self.common.error {
html! {

View File

@@ -1,168 +0,0 @@
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>
}
}
}

View File

@@ -1,8 +1,5 @@
use crate::{
components::{
form::{field::Field, submit::Submit},
router::AppRoute,
},
components::router::AppRoute,
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
@@ -190,57 +187,163 @@ impl Component for CreateUserForm {
fn view(&self, ctx: &Context<Self>) -> Html {
let link = &ctx.link();
type Field = yew_form::Field<CreateUserModel>;
html! {
<div class="row justify-content-center">
<form class="form py-3" style="max-width: 636px">
<Field<CreateUserModel>
form={&self.form}
required=true
label="User name"
field_name="username"
oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel>
form={&self.form}
required=true
label="Email"
field_name="email"
input_type="email"
oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel>
form={&self.form}
label="Display name"
field_name="display_name"
autocomplete="name"
oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel>
form={&self.form}
label="First name"
field_name="first_name"
autocomplete="given-name"
oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel>
form={&self.form}
label="Last name"
field_name="last_name"
autocomplete="family-name"
oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel>
form={&self.form}
label="Password"
field_name="password"
input_type="password"
autocomplete="new-password"
oninput={link.callback(|_| Msg::Update)} />
<Field<CreateUserModel>
form={&self.form}
label="Confirm password"
field_name="confirm_password"
input_type="password"
autocomplete="new-password"
oninput={link.callback(|_| Msg::Update)} />
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitForm})} />
<div class="row mb-3">
<h5 class="fw-bold">{"Create a user"}</h5>
</div>
<div class="form-group row mb-3">
<label for="username"
class="form-label col-4 col-form-label">
{"User name"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-8">
<Field
form={&self.form}
field_name="username"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
autocomplete="username"
oninput={link.callback(|_| Msg::Update)} />
<div class="invalid-feedback">
{&self.form.field_message("username")}
</div>
</div>
</div>
<div class="form-group row mb-3">
<label for="email"
class="form-label col-4 col-form-label">
{"Email"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<div class="col-8">
<Field
form={&self.form}
input_type="email"
field_name="email"
class="form-control"
class_invalid="is-invalid has-error"
class_valid="has-success"
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
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>
{
if let Some(e) = &self.common.error {

View File

@@ -1,175 +0,0 @@
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>
}
}
}

View File

@@ -1,172 +0,0 @@
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>
}
}
}

View File

@@ -1,172 +0,0 @@
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>
}
}
}

View File

@@ -1,70 +0,0 @@
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>
}
}

View File

@@ -1,35 +0,0 @@
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>
}
}

View File

@@ -1,48 +0,0 @@
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>
}
}

View File

@@ -1,6 +0,0 @@
pub mod attribute_input;
pub mod checkbox;
pub mod field;
pub mod select;
pub mod static_value;
pub mod submit;

View File

@@ -1,46 +0,0 @@
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>
}
}

View File

@@ -1,26 +0,0 @@
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>
}
}

View File

@@ -1,30 +0,0 @@
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>
}
}

View File

@@ -1,198 +0,0 @@
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>
}
}

View File

@@ -1,8 +1,5 @@
use crate::{
components::{
form::submit::Submit,
router::{AppRoute, Link},
},
components::router::{AppRoute, Link},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
@@ -158,62 +155,68 @@ impl Component for LoginForm {
}
} else {
html! {
<form class="form center-block col-sm-4 col-offset-4">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-person-fill"/>
</span>
<form
class="form center-block col-sm-4 col-offset-4">
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-person-fill"/>
</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>
<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 class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-lock-fill"/>
</span>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text">
<i class="bi-lock-fill"/>
</span>
</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>
<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>
<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>
}
}

View File

@@ -1,20 +1,12 @@
pub mod add_group_member;
pub mod add_user_to_group;
pub mod app;
pub mod avatar;
pub mod banner;
pub mod change_password;
pub mod create_group;
pub mod create_group_attribute;
pub mod create_user;
pub mod create_user_attribute;
pub mod delete_group;
pub mod delete_group_attribute;
pub mod delete_user;
pub mod delete_user_attribute;
pub mod form;
pub mod group_details;
pub mod group_schema_table;
pub mod group_table;
pub mod login;
pub mod logout;
@@ -25,5 +17,4 @@ pub mod router;
pub mod select;
pub mod user_details;
pub mod user_details_form;
pub mod user_schema_table;
pub mod user_table;

View File

@@ -1,8 +1,5 @@
use crate::{
components::{
form::{field::Field, submit::Submit},
router::{AppRoute, Link},
},
components::router::{AppRoute, Link},
infra::{
api::HostService,
common_component::{CommonComponent, CommonComponentParts},
@@ -167,29 +164,61 @@ impl Component for ResetPasswordStep2Form {
}
_ => (),
};
type Field = yew_form::Field<FormModel>;
html! {
<>
<h2>{"Reset your password"}</h2>
<form class="form">
<Field<FormModel>
label="New password"
required=true
form={&self.form}
field_name="password"
autocomplete="new-password"
input_type="password"
oninput={link.callback(|_| Msg::FormUpdate)} />
<Field<FormModel>
label="Confirm password"
required=true
form={&self.form}
field_name="confirm_password"
autocomplete="new-password"
input_type="password"
oninput={link.callback(|_| Msg::FormUpdate)} />
<Submit
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::Submit})} />
<form
class="form">
<div class="form-group row">
<label for="new_password"
class="form-label col-sm-2 col-form-label">
{"New password*:"}
</label>
<div class="col-sm-10">
<Field
form={&self.form}
field_name="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("password")}
</div>
</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>
{ if let Some(e) = &self.common.error {
html! {

View File

@@ -22,14 +22,6 @@ pub enum AppRoute {
ListGroups,
#[at("/group/:group_id")]
GroupDetails { group_id: i64 },
#[at("/user-attributes")]
ListUserSchema,
#[at("/user-attributes/create")]
CreateUserAttribute,
#[at("/group-attributes")]
ListGroupSchema,
#[at("/group-attributes/create")]
CreateGroupAttribute,
#[at("/")]
Index,
}

View File

@@ -4,8 +4,8 @@ use crate::{
remove_user_from_group::RemoveUserFromGroupComponent,
router::{AppRoute, Link},
user_details_form::UserDetailsForm,
}, infra::{schema::AttributeType, common_component::{CommonComponent, CommonComponentParts}},
convert_attribute_type
},
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{bail, Error, Result};
use graphql_client::GraphQLQuery;
@@ -22,10 +22,6 @@ pub struct GetUserDetails;
pub type User = get_user_details::GetUserDetailsUser;
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 {
common: CommonComponentParts<Self>,

View File

@@ -1,26 +1,20 @@
use std::str::FromStr;
use crate::{
components::{
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}
components::user_details::User,
infra::common_component::{CommonComponent, CommonComponentParts},
};
use anyhow::{anyhow, bail, Error, Ok, Result};
use gloo_console::log;
use anyhow::{bail, Error, Result};
use gloo_file::{
callbacks::{read_as_bytes, FileReader},
File,
};
use graphql_client::GraphQLQuery;
use validator::HasLen;
use validator_derive::Validate;
use web_sys::{FileList, FormData, HtmlFormElement, HtmlInputElement, InputEvent};
use web_sys::{FileList, HtmlInputElement, InputEvent};
use yew::prelude::*;
use yew_form_derive::Model;
use super::user_details::Attribute;
#[derive(Default)]
struct JsFile {
file: Option<File>,
@@ -76,7 +70,6 @@ pub struct UserDetailsForm {
/// True if we just successfully updated the user, to display a success message.
just_updated: bool,
user: User,
form_ref: NodeRef,
}
pub enum Msg {
@@ -154,14 +147,7 @@ impl CommonComponent<UserDetailsForm> for UserDetailsForm {
}
self.reader = None;
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)
// }
}
}
}
@@ -188,7 +174,6 @@ impl Component for UserDetailsForm {
just_updated: false,
reader: None,
user: ctx.props().user.clone(),
form_ref: NodeRef::default(),
}
}
@@ -198,6 +183,7 @@ impl Component for UserDetailsForm {
}
fn view(&self, ctx: &Context<Self>) -> Html {
type Field = yew_form::Field<UserModel>;
let link = &ctx.link();
let avatar_string = match &self.avatar {
@@ -210,40 +196,107 @@ impl Component for UserDetailsForm {
html! {
<div class="py-3">
<form class="form">
<StaticValue label="User ID" id="userId">
<i>{&self.user.id}</i>
</StaticValue>
<StaticValue label="Creation date" id="creationDate">
{&self.user.creation_date.naive_local().date()}
</StaticValue>
<StaticValue label="UUID" id="uuid">
{&self.user.uuid}
</StaticValue>
<Field<UserModel>
form={&self.form}
required=true
label="Email"
field_name="email"
input_type="email"
oninput={link.callback(|_| Msg::Update)} />
<Field<UserModel>
form={&self.form}
label="Display name"
field_name="display_name"
autocomplete="name"
oninput={link.callback(|_| Msg::Update)} />
<Field<UserModel>
form={&self.form}
label="First name"
field_name="first_name"
autocomplete="given-name"
oninput={link.callback(|_| Msg::Update)} />
<Field<UserModel>
form={&self.form}
label="Last name"
field_name="last_name"
autocomplete="family-name"
oninput={link.callback(|_| Msg::Update)} />
<div class="form-group row mb-3">
<label for="userId"
class="form-label col-4 col-form-label">
{"User ID: "}
</label>
<div class="col-8">
<span id="userId" class="form-control-static"><i>{&self.user.id}</i></span>
</div>
</div>
<div class="form-group row mb-3">
<label for="creationDate"
class="form-label col-4 col-form-label">
{"Creation date: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-control-static">{&self.user.creation_date.naive_local().date()}</span>
</div>
</div>
<div class="form-group row mb-3">
<label for="uuid"
class="form-label col-4 col-form-label">
{"UUID: "}
</label>
<div class="col-8">
<span id="creationDate" class="form-control-static">{&self.user.uuid}</span>
</div>
</div>
<div class="form-group row mb-3">
<label for="email"
class="form-label col-4 col-form-label">
{"Email"}
<span class="text-danger">{"*"}</span>
{":"}
</label>
<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">
<label for="avatar"
class="form-label col-4 col-form-label">
@@ -287,11 +340,16 @@ impl Component for UserDetailsForm {
</div>
</div>
</div>
{self.user.attributes.iter().map(get_custom_attribute_input).collect::<Vec<_>>()}
<Submit
text="Save changes"
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {Msg::SubmitClicked})} />
<div class="form-group row justify-content-center mt-3">
<button
type="submit"
class="btn btn-primary col-auto col-form-label"
disabled={self.common.is_task_running()}
onclick={link.callback(|e: MouseEvent| {e.prevent_default(); Msg::SubmitClicked})}>
<i class="bi-save me-2"></i>
{"Save changes"}
</button>
</div>
</form>
{
if let Some(e) = &self.common.error {
@@ -310,45 +368,6 @@ 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 {
fn submit_user_update_form(&mut self, ctx: &Context<Self>) -> Result<bool> {
if !self.form.validate() {
@@ -361,40 +380,7 @@ impl UserDetailsForm {
{
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_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 {
id: self.user.id.clone(),
email: None,
@@ -402,8 +388,8 @@ impl UserDetailsForm {
firstName: None,
lastName: None,
avatar: None,
removeAttributes: remove_names,
insertAttributes: insert_attrs,
removeAttributes: None,
insertAttributes: None,
};
let default_user_input = user_input.clone();
let model = self.form.model();

View File

@@ -1,198 +0,0 @@
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>
}
}

View File

@@ -16,26 +16,21 @@ fn get_claims_from_jwt(jwt: &str) -> Result<JWTClaims> {
Ok(token.claims().clone())
}
enum RequestType<Body: Serialize> {
Get,
Post(Body),
}
const GET_REQUEST: RequestType<()> = RequestType::Get;
const NO_BODY: Option<()> = None;
fn base_url() -> String {
yew_router::utils::base_url().unwrap_or_default()
}
async fn call_server<Body: Serialize>(
async fn call_server(
url: &str,
body: RequestType<Body>,
body: Option<impl Serialize>,
error_message: &'static str,
) -> Result<String> {
let mut request = Request::new(url)
.header("Content-Type", "application/json")
.credentials(RequestCredentials::SameOrigin);
if let RequestType::Post(b) = body {
if let Some(b) = body {
request = request
.body(serde_json::to_string(&b)?)
.method(Method::POST);
@@ -56,7 +51,7 @@ async fn call_server<Body: Serialize>(
async fn call_server_json_with_error_message<CallbackResult, Body: Serialize>(
url: &str,
request: RequestType<Body>,
request: Option<Body>,
error_message: &'static str,
) -> Result<CallbackResult>
where
@@ -68,7 +63,7 @@ where
async fn call_server_empty_response_with_error_message<Body: Serialize>(
url: &str,
request: RequestType<Body>,
request: Option<Body>,
error_message: &'static str,
) -> Result<()> {
call_server(url, request, error_message).await.map(|_| ())
@@ -107,7 +102,7 @@ impl HostService {
let request_body = QueryType::build_query(variables);
call_server_json_with_error_message::<graphql_client::Response<_>, _>(
&(base_url() + "/api/graphql"),
RequestType::Post(request_body),
Some(request_body),
error_message,
)
.await
@@ -119,7 +114,7 @@ impl HostService {
) -> Result<Box<login::ServerLoginStartResponse>> {
call_server_json_with_error_message(
&(base_url() + "/auth/opaque/login/start"),
RequestType::Post(request),
Some(request),
"Could not start authentication: ",
)
.await
@@ -128,7 +123,7 @@ impl HostService {
pub async fn login_finish(request: login::ClientLoginFinishRequest) -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
&(base_url() + "/auth/opaque/login/finish"),
RequestType::Post(request),
Some(request),
"Could not finish authentication",
)
.await
@@ -140,7 +135,7 @@ impl HostService {
) -> Result<Box<registration::ServerRegistrationStartResponse>> {
call_server_json_with_error_message(
&(base_url() + "/auth/opaque/register/start"),
RequestType::Post(request),
Some(request),
"Could not start registration: ",
)
.await
@@ -151,7 +146,7 @@ impl HostService {
) -> Result<()> {
call_server_empty_response_with_error_message(
&(base_url() + "/auth/opaque/register/finish"),
RequestType::Post(request),
Some(request),
"Could not finish registration",
)
.await
@@ -160,7 +155,7 @@ impl HostService {
pub async fn refresh() -> Result<(String, bool)> {
call_server_json_with_error_message::<login::ServerLoginResponse, _>(
&(base_url() + "/auth/refresh"),
GET_REQUEST,
NO_BODY,
"Could not start authentication: ",
)
.await
@@ -171,7 +166,7 @@ impl HostService {
pub async fn logout() -> Result<()> {
call_server_empty_response_with_error_message(
&(base_url() + "/auth/logout"),
GET_REQUEST,
NO_BODY,
"Could not logout",
)
.await
@@ -184,7 +179,7 @@ impl HostService {
base_url(),
url_escape::encode_query(&username)
),
RequestType::Post(""),
NO_BODY,
"Could not initiate password reset",
)
.await
@@ -195,7 +190,7 @@ impl HostService {
) -> Result<lldap_auth::password_reset::ServerPasswordResetResponse> {
call_server_json_with_error_message(
&format!("{}/auth/reset/step2/{}", base_url(), token),
GET_REQUEST,
NO_BODY,
"Could not validate token",
)
.await

View File

@@ -1,52 +0,0 @@
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()
}

View File

@@ -1,7 +1,5 @@
pub mod api;
pub mod common_component;
pub mod cookies;
pub mod functional;
pub mod graphql;
pub mod modal;
pub mod schema;

View File

@@ -1,66 +0,0 @@
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(())
}

View File

@@ -1,83 +0,0 @@
# Configuration for Maddy Mail Server
Documentation for maddy LDAP can be found [here](https://maddy.email/reference/auth/ldap/).
Maddy will automatically create an imap-acct if a new user connects via LDAP.
Replace `dc=example,dc=com` with your LLDAP configured domain.
## Simple Setup
Depending on the mail client(s) the simple setup can work for you. However, if this does not work for you, follow the instructions in the `Advanced Setup` section.
### DN Template
You only have to specify the dn template:
```
dn_template "cn={username},ou=people,dc=example,dc=com"
```
### Config Example with Docker
Example maddy configuration with LLDAP running in docker.
You can replace `local_authdb` with another name if you want to use multiple auth backends.
If you only want to use one storage backend make sure to disable `auth.pass_table local_authdb` in your config if it is still active.
```
auth.ldap local_authdb {
urls ldap://lldap:3890
dn_template "cn={username},ou=people,dc=example,dc=com"
starttls off
debug off
connect_timeout 1m
}
```
## Advanced Setup
If the simple setup does not work for you, you can use a proper lookup.
### Bind Credentials
If you have a service account in LLDAP with restricted rights (e.g. `lldap_strict_readonly`), replace `admin` with your LLDAP service account.
Replace `admin_password` with the password of either the admin or service account.
```
bind plain "cn=admin,ou=people,dc=example,dc=com" "admin_password"
```
If you do not want to use plain auth check the [maddy LDAP page](https://maddy.email/reference/auth/ldap/) for other options.
### Base DN
```
base_dn "dc=example,dc=com"
```
### Filter
Depending on the mail client, maddy receives and sends either the username or the full E-Mail address as username (even if the username is not an E-Mail).
For the username use:
```
filter "(&(objectClass=person)(uid={username}))"
```
For mapping the username (as E-Mail):
```
filter "(&(objectClass=person)(mail={username}))"
```
For allowing both, username and username as E-Mail use:
```
filter "(&(|(uid={username})(mail={username}))(objectClass=person))"
```
### Config Example with Docker
Example maddy configuration with LLDAP running in docker.
You can replace `local_authdb` with another name if you want to use multiple auth backends.
If you only want to use one storage backend make sure to disable `auth.pass_table local_authdb` in your config if it is still active.
```
auth.ldap local_authdb {
urls ldap://lldap:3890
bind plain "cn=admin,ou=people,dc=example,dc=com" "admin_password"
base_dn "dc=example,dc=com"
filter "(&(|(uid={username})(mail={username}))(objectClass=person))"
starttls off
debug off
connect_timeout 1m
}
```

View File

@@ -55,13 +55,13 @@ services:
# >>> Postfix LDAP Integration
- ACCOUNT_PROVISIONER=LDAP
- LDAP_SERVER_HOST=lldap:3890
- LDAP_SEARCH_BASE=ou=people,dc=example,dc=com
- LDAP_SEARCH_BASE=dc=example,dc=com
- LDAP_BIND_DN=uid=admin,ou=people,dc=example,dc=com
- LDAP_BIND_PW=adminpassword
- LDAP_QUERY_FILTER_USER=(&(objectClass=inetOrgPerson)(|(uid=%u)(mail=%u)))
- LDAP_QUERY_FILTER_GROUP=(&(objectClass=groupOfUniqueNames)(uid=%s))
- 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
# >>> Dovecot LDAP Integration
- DOVECOT_AUTH_BIND=yes

View File

@@ -1,20 +0,0 @@
# 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
View File

@@ -1,7 +1,6 @@
type AttributeValue {
name: String!
value: [String!]!
schema: AttributeSchema!
}
type Mutation {
@@ -18,10 +17,6 @@ type Mutation {
addGroupAttribute(name: String!, attributeType: AttributeType!, isList: Boolean!, isVisible: Boolean!, isEditable: Boolean!): Success!
deleteUserAttribute(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 {
@@ -157,6 +152,10 @@ type User {
groups: [Group!]!
}
type AttributeList {
attributes: [AttributeSchema!]!
}
enum AttributeType {
STRING
INTEGER
@@ -164,11 +163,6 @@ enum AttributeType {
DATE_TIME
}
type AttributeList {
attributes: [AttributeSchema!]!
extraLdapObjectClasses: [String!]!
}
type Success {
ok: Boolean!
}

View File

@@ -2,8 +2,7 @@ use crate::domain::{
error::Result,
types::{
AttributeName, AttributeType, AttributeValue, Email, Group, GroupDetails, GroupId,
GroupName, JpegPhoto, LdapObjectClass, Serialized, User, UserAndGroups, UserColumn, UserId,
Uuid,
GroupName, JpegPhoto, Serialized, User, UserAndGroups, UserColumn, UserId, Uuid,
},
};
use async_trait::async_trait;
@@ -176,8 +175,6 @@ impl AttributeList {
pub struct Schema {
pub user_attributes: AttributeList,
pub group_attributes: AttributeList,
pub extra_user_object_classes: Vec<LdapObjectClass>,
pub extra_group_object_classes: Vec<LdapObjectClass>,
}
#[async_trait]
@@ -230,11 +227,6 @@ pub trait SchemaBackendHandler: ReadSchemaBackendHandler {
// 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_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]

View File

@@ -9,7 +9,7 @@ use crate::domain::{
handler::{GroupListerBackendHandler, GroupRequestFilter},
ldap::error::LdapError,
schema::{PublicSchema, SchemaGroupAttributeExtractor},
types::{AttributeName, AttributeType, Group, LdapObjectClass, UserId, Uuid},
types::{AttributeName, AttributeType, Group, UserId, Uuid},
};
use super::{
@@ -30,17 +30,7 @@ pub fn get_group_attribute(
) -> Option<Vec<Vec<u8>>> {
let attribute = AttributeName::from(attribute);
let attribute_values = match map_group_field(&attribute, schema) {
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
}
GroupFieldType::ObjectClass => vec![b"groupOfUniqueNames".to_vec()],
// Always returned as part of the base response.
GroupFieldType::Dn => return None,
GroupFieldType::EntryDn => {
@@ -110,11 +100,13 @@ fn expand_group_attribute_wildcards(attributes: &[String]) -> Vec<&str> {
fn make_ldap_search_group_result_entry(
group: Group,
base_dn_str: &str,
expanded_attributes: &[&str],
attributes: &[String],
user_filter: &Option<UserId>,
ignored_group_attributes: &[AttributeName],
schema: &PublicSchema,
) -> LdapSearchResultEntry {
let expanded_attributes = expand_group_attribute_wildcards(attributes);
LdapSearchResultEntry {
dn: format!("cn={},ou=groups,{}", group.display_name, base_dn_str),
attributes: expanded_attributes
@@ -177,13 +169,10 @@ fn convert_group_filter(
)?;
Ok(GroupRequestFilter::Member(user_name))
}
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(
matches!(value.as_str(), "groupofuniquenames" | "groupofnames")
|| schema
.get_schema()
.extra_group_object_classes
.contains(&LdapObjectClass::from(value)),
)),
GroupFieldType::ObjectClass => Ok(GroupRequestFilter::from(matches!(
value.as_str(),
"groupofuniquenames" | "groupofnames"
))),
GroupFieldType::Dn | GroupFieldType::EntryDn => {
Ok(get_group_id_from_distinguished_name(
value.as_str(),
@@ -278,17 +267,11 @@ pub fn convert_groups_to_ldap_op<'a>(
user_filter: &'a Option<UserId>,
schema: &'a PublicSchema,
) -> 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| {
LdapOp::SearchResultEntry(make_ldap_search_group_result_entry(
g,
&ldap_info.base_dn_str,
expanded_attributes.as_ref().unwrap(),
attributes,
user_filter,
&ldap_info.ignored_group_attributes,
schema,

View File

@@ -15,10 +15,7 @@ use crate::domain::{
},
},
schema::{PublicSchema, SchemaUserAttributeExtractor},
types::{
AttributeName, AttributeType, GroupDetails, LdapObjectClass, User, UserAndGroups,
UserColumn, UserId,
},
types::{AttributeName, AttributeType, GroupDetails, User, UserAndGroups, UserColumn, UserId},
};
pub fn get_user_attribute(
@@ -31,22 +28,12 @@ pub fn get_user_attribute(
) -> Option<Vec<Vec<u8>>> {
let attribute = AttributeName::from(attribute);
let attribute_values = match map_user_field(&attribute, schema) {
UserFieldType::ObjectClass => {
let mut classes = vec![
b"inetOrgPerson".to_vec(),
b"posixAccount".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
}
UserFieldType::ObjectClass => vec![
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
],
// dn is always returned as part of the base response.
UserFieldType::Dn => return None,
UserFieldType::EntryDn => {
@@ -132,11 +119,12 @@ const ALL_USER_ATTRIBUTE_KEYS: &[&str] = &[
fn make_ldap_search_user_result_entry(
user: User,
base_dn_str: &str,
expanded_attributes: &[&str],
attributes: &[String],
groups: Option<&[GroupDetails]>,
ignored_user_attributes: &[AttributeName],
schema: &PublicSchema,
) -> LdapSearchResultEntry {
let expanded_attributes = expand_user_attribute_wildcards(attributes);
let dn = format!("uid={},ou=people,{}", user.user_id.as_str(), base_dn_str);
LdapSearchResultEntry {
dn,
@@ -209,15 +197,10 @@ fn convert_user_filter(
}
Ok(UserRequestFilter::from(false))
}
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(
matches!(
value.as_str(),
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
) || schema
.get_schema()
.extra_user_object_classes
.contains(&LdapObjectClass::from(value)),
)),
UserFieldType::ObjectClass => Ok(UserRequestFilter::from(matches!(
value.as_str(),
"person" | "inetorgperson" | "posixaccount" | "mailaccount"
))),
UserFieldType::MemberOf => Ok(UserRequestFilter::MemberOf(
get_group_id_from_distinguished_name(
&value,
@@ -312,16 +295,11 @@ pub fn convert_users_to_ldap_op<'a>(
ldap_info: &'a LdapInfo,
schema: &'a PublicSchema,
) -> 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| {
LdapOp::SearchResultEntry(make_ldap_search_user_result_entry(
u.user,
&ldap_info.base_dn_str,
expanded_attributes.as_ref().unwrap(),
attributes,
u.groups.as_deref(),
&ldap_info.ignored_user_attributes,
schema,

View File

@@ -114,21 +114,21 @@ pub fn expand_attribute_wildcards<'a>(
ldap_attributes: &'a [String],
all_attribute_keys: &'a [&'static str],
) -> Vec<&'a str> {
let extra_attributes =
if ldap_attributes.iter().any(|x| x == "*") || ldap_attributes.is_empty() {
all_attribute_keys
} else {
&[]
}
let mut attributes_out = ldap_attributes
.iter()
.copied();
let attributes_out = ldap_attributes
.iter()
.map(|s| s.as_str())
.filter(|&s| s != "*" && s != "+" && s != "1.1");
.map(String::as_str)
.collect::<Vec<_>>();
if attributes_out.iter().any(|&x| x == "*") || attributes_out.is_empty() {
// Remove occurrences of '*'
attributes_out.retain(|&x| x != "*");
// Splice in all non-operational attributes
attributes_out.extend(all_attribute_keys.iter());
}
// Deduplicate, preserving order
let resolved_attributes = itertools::chain(attributes_out, extra_attributes)
let resolved_attributes = attributes_out
.into_iter()
.unique_by(|a| a.to_ascii_lowercase())
.collect_vec();
debug!(?resolved_attributes);

View File

@@ -1,23 +0,0 @@
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
}
}

View File

@@ -1,3 +1,5 @@
//! `SeaORM` Entity. Generated by sea-orm-codegen 0.10.3
pub mod prelude;
pub mod groups;
@@ -9,10 +11,8 @@ pub mod users;
pub mod user_attribute_schema;
pub mod user_attributes;
pub mod user_object_classes;
pub mod group_attribute_schema;
pub mod group_attributes;
pub mod group_object_classes;
pub use prelude::*;

View File

@@ -4,8 +4,6 @@ pub use super::group_attribute_schema::Column as GroupAttributeSchemaColumn;
pub use super::group_attribute_schema::Entity as GroupAttributeSchema;
pub use super::group_attributes::Column as GroupAttributesColumn;
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::Entity as Group;
pub use super::jwt_refresh_storage::Column as JwtRefreshStorageColumn;
@@ -20,7 +18,5 @@ pub use super::user_attribute_schema::Column as UserAttributeSchemaColumn;
pub use super::user_attribute_schema::Entity as UserAttributeSchema;
pub use super::user_attributes::Column as UserAttributesColumn;
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::Entity as User;

View File

@@ -1,23 +0,0 @@
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
}
}

View File

@@ -4,7 +4,7 @@ use crate::domain::{
};
use serde::{Deserialize, Serialize};
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct PublicSchema(Schema);
impl PublicSchema {

View File

@@ -79,24 +79,25 @@ fn get_group_filter_expr(filter: GroupRequestFilter) -> Cond {
impl GroupListerBackendHandler for SqlBackendHandler {
#[instrument(skip(self), level = "debug", ret, err)]
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()
.order_by_asc(GroupColumn::GroupId)
.find_with_related(model::Membership)
.filter(filters.clone())
.filter(
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)
.await?;
let mut groups: Vec<_> = results
@@ -109,16 +110,9 @@ impl GroupListerBackendHandler for SqlBackendHandler {
}
})
.collect();
let group_ids = groups.iter().map(|u| &u.id);
let attributes = model::GroupAttributes::find()
.filter(
model::GroupAttributesColumn::GroupId.in_subquery(
model::Group::find()
.filter(filters)
.select_only()
.column(model::groups::Column::GroupId)
.into_query(),
),
)
.filter(model::GroupAttributesColumn::GroupId.is_in(group_ids))
.order_by_asc(model::GroupAttributesColumn::GroupId)
.order_by_asc(model::GroupAttributesColumn::AttributeName)
.all(&self.sql_pool)
@@ -126,6 +120,12 @@ impl GroupListerBackendHandler for SqlBackendHandler {
let mut attributes_iter = attributes.into_iter().peekable();
use itertools::Itertools; // For take_while_ref
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
.take_while_ref(|u| u.group_id == group.id)
.map(AttributeValue::from)

View File

@@ -88,20 +88,6 @@ pub enum GroupAttributes {
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.
#[derive(DeriveIden)]
pub enum Metadata {
@@ -1045,51 +1031,6 @@ async fn migrate_to_v8(transaction: DatabaseTransaction) -> Result<DatabaseTrans
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.
macro_rules! to_sync {
($l:ident) => {
@@ -1118,7 +1059,6 @@ pub async fn migrate_from_version(
to_sync!(migrate_to_v6),
to_sync!(migrate_to_v7),
to_sync!(migrate_to_v8),
to_sync!(migrate_to_v9),
];
assert_eq!(migrations.len(), (LAST_SCHEMA_VERSION.0 - 1) as usize);
for migration in 2..=last_version.0 {

View File

@@ -6,7 +6,7 @@ use crate::domain::{
},
model,
sql_backend_handler::SqlBackendHandler,
types::{AttributeName, LdapObjectClass},
types::AttributeName,
};
use async_trait::async_trait;
use sea_orm::{
@@ -66,44 +66,6 @@ impl SchemaBackendHandler for SqlBackendHandler {
.await?;
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 {
@@ -117,8 +79,6 @@ impl SqlBackendHandler {
group_attributes: AttributeList {
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?,
})
}
@@ -145,30 +105,6 @@ impl SqlBackendHandler {
.map(|m| m.into())
.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)]
@@ -215,9 +151,7 @@ mod tests {
},
group_attributes: AttributeList {
attributes: Vec::new()
},
extra_user_object_classes: Vec::new(),
extra_group_object_classes: Vec::new(),
}
}
);
}
@@ -313,50 +247,4 @@ mod tests {
.attributes
.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());
}
}

View File

@@ -11,7 +11,7 @@ pub type DbConnection = sea_orm::DatabaseConnection;
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord, DeriveValueType)]
pub struct SchemaVersion(pub i16);
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(9);
pub const LAST_SCHEMA_VERSION: SchemaVersion = SchemaVersion(8);
#[derive(Copy, PartialEq, Eq, Debug, Clone, PartialOrd, Ord)]
pub struct PrivateKeyHash(pub [u8; 32]);

View File

@@ -104,22 +104,23 @@ impl UserListerBackendHandler for SqlBackendHandler {
// To simplify the query, we always fetch groups. TODO: cleanup.
_get_groups: bool,
) -> 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()
.filter(filters.clone())
.filter(
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)
.find_with_linked(model::memberships::UserToGroup)
.order_by_asc(SimpleExpr::Column(
@@ -133,18 +134,10 @@ impl UserListerBackendHandler for SqlBackendHandler {
groups: Some(groups.into_iter().map(Into::<GroupDetails>::into).collect()),
})
.collect();
// 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()
.filter(
model::UserAttributesColumn::UserId.in_subquery(
model::User::find()
.filter(filters)
.select_only()
.column(model::users::Column::UserId)
.into_query(),
),
)
.filter(model::UserAttributesColumn::UserId.is_in(user_ids))
.order_by_asc(model::UserAttributesColumn::UserId)
.order_by_asc(model::UserAttributesColumn::AttributeName)
.all(&self.sql_pool)
@@ -152,6 +145,12 @@ impl UserListerBackendHandler for SqlBackendHandler {
let mut attributes_iter = attributes.into_iter().peekable();
use itertools::Itertools; // For take_while_ref
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
.take_while_ref(|u| u.user_id == user.user.user_id)
.map(AttributeValue::from)

View File

@@ -11,6 +11,7 @@ use sea_orm::{
use serde::{Deserialize, Serialize};
use strum::{EnumString, IntoStaticStr};
use super::handler::AttributeSchema;
pub use super::model::UserColumn;
pub use lldap_auth::types::UserId;
@@ -271,8 +272,6 @@ impl TryFromU64 for AttributeName {
))
}
}
make_case_insensitive_comparable_string!(LdapObjectClass);
make_case_insensitive_comparable_string!(Email);
make_case_insensitive_comparable_string!(GroupName);
@@ -535,6 +534,38 @@ pub struct UserAndGroups {
pub groups: Option<Vec<GroupDetails>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AttributeValueAndSchema {
pub value: AttributeValue,
pub schema: AttributeSchema,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserAndSchema {
pub user: User,
pub schema: Vec<AttributeSchema>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupAndSchema {
pub group: Group,
pub schema: Vec<AttributeSchema>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupDetailsAndSchema {
pub group: GroupDetails,
pub schema: Vec<AttributeSchema>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserAndGroupsAndSchema {
pub user: User,
pub user_schema: Vec<AttributeSchema>,
pub group: Option<Vec<GroupDetails>>,
pub group_schema: Vec<AttributeSchema>,
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -12,10 +12,7 @@ use crate::domain::{
UpdateUserRequest, UserBackendHandler, UserListerBackendHandler, UserRequestFilter,
},
schema::PublicSchema,
types::{
AttributeName, Group, GroupDetails, GroupId, GroupName, LdapObjectClass, User,
UserAndGroups, UserId,
},
types::{AttributeName, Group, GroupDetails, GroupId, GroupName, User, UserAndGroups, UserId},
};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -115,10 +112,6 @@ pub trait AdminBackendHandler:
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
async fn delete_user_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]
@@ -194,18 +187,6 @@ impl<Handler: BackendHandler> AdminBackendHandler for Handler {
async fn delete_group_attribute(&self, name: &AttributeName) -> Result<()> {
<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> {

View File

@@ -677,7 +677,7 @@ where
if enable_password_reset {
cfg.service(
web::resource("/reset/step1/{user_id}")
.route(web::post().to(get_password_reset_step1_handler::<Backend>)),
.route(web::get().to(get_password_reset_step1_handler::<Backend>)),
)
.service(
web::resource("/reset/step2/{token}")

View File

@@ -3,8 +3,6 @@ use lettre::message::Mailbox;
use serde::{Deserialize, Serialize};
use url::Url;
use crate::infra::database_string::DatabaseUrl;
/// lldap is a lightweight LDAP server
#[derive(Debug, Parser, Clone)]
#[clap(version, author)]
@@ -89,7 +87,7 @@ pub struct RunOpts {
/// Database connection URL
#[clap(short, long, env = "LLDAP_DATABASE_URL")]
pub database_url: Option<DatabaseUrl>,
pub database_url: Option<String>,
/// Force admin password reset to the config value.
#[clap(long, env = "LLDAP_FORCE_LADP_USER_PASS_RESET")]

View File

@@ -3,10 +3,7 @@ use crate::{
sql_tables::{ConfigLocation, PrivateKeyHash, PrivateKeyInfo, PrivateKeyLocation},
types::{AttributeName, UserId},
},
infra::{
cli::{GeneralConfigOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, TestEmailOpts},
database_string::DatabaseUrl,
},
infra::cli::{GeneralConfigOpts, LdapsOpts, RunOpts, SmtpEncryption, SmtpOpts, TestEmailOpts},
};
use anyhow::{bail, Context, Result};
use figment::{
@@ -94,8 +91,8 @@ pub struct Configuration {
pub force_ldap_user_pass_reset: bool,
#[builder(default = "false")]
pub force_update_private_key: bool,
#[builder(default = r#"DatabaseUrl::from("sqlite://users.db?mode=rwc")"#)]
pub database_url: DatabaseUrl,
#[builder(default = r#"String::from("sqlite://users.db?mode=rwc")"#)]
pub database_url: String,
#[builder(default)]
pub ignored_user_attributes: Vec<AttributeName>,
#[builder(default)]
@@ -414,7 +411,7 @@ impl ConfigOverrider for RunOpts {
}
if let Some(database_url) = self.database_url.as_ref() {
config.database_url = database_url.clone();
config.database_url = database_url.to_string();
}
if let Some(force_ldap_user_pass_reset) = self.force_ldap_user_pass_reset {

View File

@@ -1,54 +0,0 @@
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"
);
}
}

View File

@@ -1,5 +1,3 @@
use std::sync::Arc;
use crate::{
domain::{
deserialize::deserialize_attribute_value,
@@ -9,7 +7,7 @@ use crate::{
},
types::{
AttributeName, AttributeType, AttributeValue as DomainAttributeValue, GroupId,
JpegPhoto, LdapObjectClass, UserId,
JpegPhoto, UserId,
},
},
infra::{
@@ -161,8 +159,11 @@ impl<Handler: BackendHandler> Mutation<Handler> {
})
.instrument(span.clone())
.await?;
let user_details = handler.get_user_details(&user_id).instrument(span).await?;
super::query::User::<Handler>::from_user(user_details, Arc::new(schema))
Ok(handler
.get_user_details(&user_id)
.instrument(span)
.await
.map(Into::into)?)
}
async fn create_group(
@@ -490,90 +491,6 @@ impl<Handler: BackendHandler> Mutation<Handler> {
.await?;
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>(
@@ -596,8 +513,11 @@ async fn create_group_with_details<Handler: BackendHandler>(
attributes,
};
let group_id = handler.create_group(request).await?;
let group_details = handler.get_group_details(group_id).instrument(span).await?;
super::query::Group::<Handler>::from_group_details(group_details, Arc::new(schema))
Ok(handler
.get_group_details(group_id)
.instrument(span)
.await
.map(Into::into)?)
}
fn deserialize_attribute(

View File

@@ -1,13 +1,14 @@
use std::sync::Arc;
use crate::{
domain::{
deserialize::deserialize_attribute_value,
handler::{BackendHandler, ReadSchemaBackendHandler},
ldap::utils::{map_user_field, UserFieldType},
model::UserColumn,
schema::PublicSchema,
types::{AttributeType, GroupDetails, GroupId, JpegPhoto, LdapObjectClass, UserId},
schema::{
PublicSchema, SchemaAttributeExtractor, SchemaGroupAttributeExtractor,
SchemaUserAttributeExtractor,
},
types::{AttributeType, GroupDetails, GroupId, JpegPhoto, UserId},
},
infra::{
access_control::{ReadonlyBackendHandler, UserReadableBackendHandler},
@@ -24,9 +25,14 @@ type DomainRequestFilter = crate::domain::handler::UserRequestFilter;
type DomainUser = crate::domain::types::User;
type DomainGroup = crate::domain::types::Group;
type DomainUserAndGroups = crate::domain::types::UserAndGroups;
type DomainUserAndSchema = crate::domain::types::UserAndSchema;
type DomainGroupAndSchema = crate::domain::types::GroupAndSchema;
type DomainGroupDetailsAndSchema = crate::domain::types::GroupDetailsAndSchema;
type DomainUserAndGroupsAndSchema = crate::domain::types::UserAndGroupsAndSchema;
type DomainAttributeList = crate::domain::handler::AttributeList;
type DomainAttributeSchema = crate::domain::handler::AttributeSchema;
type DomainAttributeValue = crate::domain::types::AttributeValue;
type DomainAttributeValueAndSchema = crate::domain::types::AttributeValueAndSchema;
#[derive(PartialEq, Eq, Debug, GraphQLInputObject)]
/// A filter for requests, specifying a boolean expression based on field constraints. Only one of
@@ -142,9 +148,15 @@ impl<Handler: BackendHandler> Query<Handler> {
&span,
"Unauthorized access to user data",
))?;
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let user = handler.get_user_details(&user_id).instrument(span).await?;
User::<Handler>::from_user(user, schema)
let user = handler
.get_user_details(&user_id)
.instrument(span)
.await?;
let schema = self.get_schema(context, span).await?;
return Ok(DomainUserAndSchema {
user,
schema: schema.get_schema().user_attributes.attributes,
}.into())
}
async fn users(
@@ -161,8 +173,8 @@ impl<Handler: BackendHandler> Query<Handler> {
&span,
"Unauthorized access to user list",
))?;
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let users = handler
let schema = self.get_schema(context, span.clone()).await?;
Ok(handler
.list_users(
filters
.map(|f| f.try_into_domain_filter(&schema))
@@ -170,11 +182,8 @@ impl<Handler: BackendHandler> Query<Handler> {
false,
)
.instrument(span)
.await?;
users
.into_iter()
.map(|u| User::<Handler>::from_user_and_groups(u, schema.clone()))
.collect()
.await
.map(|v| v.into_iter().map(Into::into).collect())?)
}
async fn groups(context: &Context<Handler>) -> FieldResult<Vec<Group<Handler>>> {
@@ -185,12 +194,11 @@ impl<Handler: BackendHandler> Query<Handler> {
&span,
"Unauthorized access to group list",
))?;
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let domain_groups = handler.list_groups(None).instrument(span).await?;
domain_groups
.into_iter()
.map(|g| Group::<Handler>::from_group(g, schema.clone()))
.collect()
Ok(handler
.list_groups(None)
.instrument(span)
.await
.map(|v| v.into_iter().map(Into::into).collect())?)
}
async fn group(context: &Context<Handler>, group_id: i32) -> FieldResult<Group<Handler>> {
@@ -204,12 +212,11 @@ impl<Handler: BackendHandler> Query<Handler> {
&span,
"Unauthorized access to group data",
))?;
let schema = Arc::new(self.get_schema(context, span.clone()).await?);
let group_details = handler
Ok(handler
.get_group_details(GroupId(group_id))
.instrument(span)
.await?;
Group::<Handler>::from_group_details(group_details, schema.clone())
.await
.map(Into::into)?)
}
async fn schema(context: &Context<Handler>) -> FieldResult<Schema<Handler>> {
@@ -239,45 +246,18 @@ impl<Handler: BackendHandler> Query<Handler> {
/// Represents a single user.
pub struct User<Handler: BackendHandler> {
user: DomainUser,
attributes: Vec<AttributeValue<Handler>>,
schema: Arc<PublicSchema>,
groups: Option<Vec<Group<Handler>>>,
schema: Vec<DomainAttributeSchema>,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
impl<Handler: BackendHandler> User<Handler> {
pub fn from_user(mut user: DomainUser, schema: Arc<PublicSchema>) -> FieldResult<Self> {
let attributes = std::mem::take(&mut user.attributes);
Ok(Self {
user,
attributes: attributes
.into_iter()
.map(|a| {
AttributeValue::<Handler>::from_schema(a, &schema.get_schema().user_attributes)
})
.collect::<FieldResult<Vec<_>>>()?,
schema,
groups: None,
#[cfg(test)]
impl<Handler: BackendHandler> Default for User<Handler> {
fn default() -> Self {
Self {
user: DomainUser::default(),
schema: Vec::default(),
_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)
}
}
@@ -330,15 +310,17 @@ impl<Handler: BackendHandler> User<Handler> {
}
/// User-defined attributes.
fn attributes(&self) -> &[AttributeValue<Handler>] {
&self.attributes
fn attributes(&self) -> Vec<AttributeValue<Handler, SchemaUserAttributeExtractor>> {
self.user
.attributes
.clone()
.into_iter()
.map(Into::into)
.collect()
}
/// The groups to which this user belongs.
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");
span.in_scope(|| {
debug!(user_id = ?self.user.user_id);
@@ -346,16 +328,38 @@ impl<Handler: BackendHandler> User<Handler> {
let handler = context
.get_readable_handler(&self.user.user_id)
.expect("We shouldn't be able to get there without readable permission");
let domain_groups = handler
Ok(handler
.get_user_groups(&self.user.user_id)
.instrument(span)
.await?;
let mut groups = domain_groups
.into_iter()
.map(|g| Group::<Handler>::from_group_details(g, self.schema.clone()))
.collect::<FieldResult<Vec<Group<Handler>>>>()?;
groups.sort_by(|g1, g2| g1.display_name.cmp(&g2.display_name));
Ok(groups)
.await
.map(|set| {
let mut groups = set
.into_iter()
.map(Into::into)
.collect::<Vec<Group<Handler>>>();
groups.sort_by(|g1, g2| g1.display_name.cmp(&g2.display_name));
groups
})?)
}
}
impl<Handler: BackendHandler> From<DomainUserAndSchema> for User<Handler> {
fn from(user: DomainUserAndSchema) -> Self {
Self {
user: user.user,
schema: user.schema,
_phantom: std::marker::PhantomData,
}
}
}
impl<Handler: BackendHandler> From<DomainUserAndGroupsAndSchema> for User<Handler> {
fn from(user: DomainUserAndGroupsAndSchema) -> Self {
Self {
user: user.user,
schema: user.user_schema,
_phantom: std::marker::PhantomData,
}
}
}
@@ -366,69 +370,12 @@ pub struct Group<Handler: BackendHandler> {
display_name: String,
creation_date: chrono::NaiveDateTime,
uuid: String,
attributes: Vec<AttributeValue<Handler>>,
schema: Arc<PublicSchema>,
attributes: Vec<DomainAttributeValue>,
schema: Vec<DomainAttributeSchema>,
members: Option<Vec<String>>,
_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>)]
impl<Handler: BackendHandler> Group<Handler> {
fn id(&self) -> i32 {
@@ -445,8 +392,12 @@ impl<Handler: BackendHandler> Group<Handler> {
}
/// User-defined attributes.
fn attributes(&self) -> &[AttributeValue<Handler>] {
&self.attributes
fn attributes(&self) -> Vec<AttributeValue<Handler, SchemaGroupAttributeExtractor>> {
self.attributes
.clone()
.into_iter()
.map(Into::into)
.collect()
}
/// The groups to which this user belongs.
@@ -461,17 +412,44 @@ impl<Handler: BackendHandler> Group<Handler> {
&span,
"Unauthorized access to group data",
))?;
let domain_users = handler
Ok(handler
.list_users(
Some(DomainRequestFilter::MemberOfId(GroupId(self.group_id))),
false,
)
.instrument(span)
.await?;
domain_users
.into_iter()
.map(|u| User::<Handler>::from_user_and_groups(u, self.schema.clone()))
.collect()
.await
.map(|v| v.into_iter().map(Into::into).collect())?)
}
}
impl<Handler: BackendHandler> From<DomainGroupDetailsAndSchema> for Group<Handler> {
fn from(group_details: DomainGroupDetailsAndSchema) -> Self {
Self {
group_id: group_details.group.group_id.0,
display_name: group_details.group.display_name.to_string(),
creation_date: group_details.group.creation_date,
uuid: group_details.group.uuid.into_string(),
attributes: group_details.group.attributes,
members: None,
schema: group_details.schema,
_phantom: std::marker::PhantomData,
}
}
}
impl<Handler: BackendHandler> From<DomainGroupAndSchema> for Group<Handler> {
fn from(group: DomainGroupAndSchema) -> Self {
Self {
group_id: group.group.id.0,
display_name: group.group.display_name.to_string(),
creation_date: group.group.creation_date,
uuid: group.group.uuid.into_string(),
attributes: group.group.attributes,
members: Some(group.group.users.into_iter().map(UserId::into_string).collect()),
schema: group.schema,
_phantom: std::marker::PhantomData,
}
}
}
@@ -503,15 +481,6 @@ 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> {
fn from(value: DomainAttributeSchema) -> Self {
Self {
@@ -523,32 +492,26 @@ impl<Handler: BackendHandler> From<DomainAttributeSchema> for AttributeSchema<Ha
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct AttributeList<Handler: BackendHandler> {
attributes: DomainAttributeList,
extra_classes: Vec<LdapObjectClass>,
schema: DomainAttributeList,
_phantom: std::marker::PhantomData<Box<Handler>>,
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> AttributeList<Handler> {
fn attributes(&self) -> Vec<AttributeSchema<Handler>> {
self.attributes
self.schema
.attributes
.clone()
.into_iter()
.map(Into::into)
.collect()
}
fn extra_ldap_object_classes(&self) -> Vec<String> {
self.extra_classes.iter().map(|c| c.to_string()).collect()
}
}
impl<Handler: BackendHandler> AttributeList<Handler> {
fn new(attributes: DomainAttributeList, extra_classes: Vec<LdapObjectClass>) -> Self {
impl<Handler: BackendHandler> From<DomainAttributeList> for AttributeList<Handler> {
fn from(value: DomainAttributeList) -> Self {
Self {
attributes,
extra_classes,
schema: value,
_phantom: std::marker::PhantomData,
}
}
@@ -563,16 +526,10 @@ pub struct Schema<Handler: BackendHandler> {
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> Schema<Handler> {
fn user_schema(&self) -> AttributeList<Handler> {
AttributeList::<Handler>::new(
self.schema.get_schema().user_attributes.clone(),
self.schema.get_schema().extra_user_object_classes.clone(),
)
self.schema.get_schema().user_attributes.clone().into()
}
fn group_schema(&self) -> AttributeList<Handler> {
AttributeList::<Handler>::new(
self.schema.get_schema().group_attributes.clone(),
self.schema.get_schema().extra_group_object_classes.clone(),
)
self.schema.get_schema().group_attributes.clone().into()
}
}
@@ -586,92 +543,90 @@ impl<Handler: BackendHandler> From<PublicSchema> for Schema<Handler> {
}
#[derive(PartialEq, Eq, Debug, Serialize, Deserialize)]
pub struct AttributeValue<Handler: BackendHandler> {
pub struct AttributeValue<Handler: BackendHandler, Extractor> {
attribute: DomainAttributeValue,
schema: AttributeSchema<Handler>,
schema: DomainAttributeSchema,
_phantom: std::marker::PhantomData<Box<Handler>>,
_phantom_extractor: std::marker::PhantomData<Extractor>,
}
#[graphql_object(context = Context<Handler>)]
impl<Handler: BackendHandler> AttributeValue<Handler> {
impl<Handler: BackendHandler, Extractor: SchemaAttributeExtractor>
AttributeValue<Handler, Extractor>
{
fn name(&self) -> &str {
self.attribute.name.as_str()
}
fn value(&self) -> FieldResult<Vec<String>> {
Ok(serialize_attribute(&self.attribute, &self.schema.schema))
}
fn schema(&self) -> &AttributeSchema<Handler> {
&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,
}
async fn value(&self, context: &Context<Handler>) -> FieldResult<Vec<String>> {
let handler = context
.handler
.get_user_restricted_lister_handler(&context.validation_result);
serialize_attribute(
&self.attribute,
Extractor::get_attributes(&PublicSchema::from(handler.get_schema().await?)),
)
}
}
pub fn serialize_attribute(
attribute: &DomainAttributeValue,
attribute_schema: &DomainAttributeSchema,
) -> Vec<String> {
attributes: &DomainAttributeList,
) -> FieldResult<Vec<String>> {
let convert_date = |date| chrono::Utc.from_utc_datetime(&date).to_rfc3339();
match (attribute_schema.attribute_type, attribute_schema.is_list) {
(AttributeType::String, false) => vec![attribute.value.unwrap::<String>()],
(AttributeType::Integer, false) => {
// LDAP integers are encoded as strings.
vec![attribute.value.unwrap::<i64>().to_string()]
}
(AttributeType::JpegPhoto, false) => {
vec![String::from(&attribute.value.unwrap::<JpegPhoto>())]
}
(AttributeType::DateTime, false) => {
vec![convert_date(attribute.value.unwrap::<NaiveDateTime>())]
}
(AttributeType::String, true) => attribute
.value
.unwrap::<Vec<String>>()
.into_iter()
.collect(),
(AttributeType::Integer, true) => attribute
.value
.unwrap::<Vec<i64>>()
.into_iter()
.map(|i| i.to_string())
.collect(),
(AttributeType::JpegPhoto, true) => attribute
.value
.unwrap::<Vec<JpegPhoto>>()
.iter()
.map(String::from)
.collect(),
(AttributeType::DateTime, true) => attribute
.value
.unwrap::<Vec<NaiveDateTime>>()
.into_iter()
.map(convert_date)
.collect(),
}
attributes
.get_attribute_type(&attribute.name)
.map(|attribute_type| {
match attribute_type {
(AttributeType::String, false) => {
vec![attribute.value.unwrap::<String>()]
}
(AttributeType::Integer, false) => {
// LDAP integers are encoded as strings.
vec![attribute.value.unwrap::<i64>().to_string()]
}
(AttributeType::JpegPhoto, false) => {
vec![String::from(&attribute.value.unwrap::<JpegPhoto>())]
}
(AttributeType::DateTime, false) => {
vec![convert_date(attribute.value.unwrap::<NaiveDateTime>())]
}
(AttributeType::String, true) => attribute
.value
.unwrap::<Vec<String>>()
.into_iter()
.collect(),
(AttributeType::Integer, true) => attribute
.value
.unwrap::<Vec<i64>>()
.into_iter()
.map(|i| i.to_string())
.collect(),
(AttributeType::JpegPhoto, true) => attribute
.value
.unwrap::<Vec<JpegPhoto>>()
.iter()
.map(String::from)
.collect(),
(AttributeType::DateTime, true) => attribute
.value
.unwrap::<Vec<NaiveDateTime>>()
.into_iter()
.map(convert_date)
.collect(),
}
})
.ok_or_else(|| FieldError::from(anyhow::anyhow!("Unknown attribute: {}", &attribute.name)))
}
impl<Handler: BackendHandler> AttributeValue<Handler> {
fn from_schema(a: DomainAttributeValue, schema: &DomainAttributeList) -> FieldResult<Self> {
match schema.get_attribute_schema(&a.name) {
Some(s) => Ok(AttributeValue::<Handler> {
attribute: a,
schema: AttributeSchema::<Handler> {
schema: s.clone(),
_phantom: std::marker::PhantomData,
},
_phantom: std::marker::PhantomData,
}),
None => Err(FieldError::from(format!("Unknown attribute {}", &a.name))),
impl<Handler: BackendHandler, Extractor> From<DomainAttributeValueAndSchema>
for AttributeValue<Handler, Extractor>
{
fn from(value: DomainAttributeValueAndSchema) -> Self {
Self {
attribute: value.value,
schema: value.schema,
_phantom: std::marker::PhantomData,
_phantom_extractor: std::marker::PhantomData,
}
}
}
@@ -682,7 +637,7 @@ mod tests {
use crate::{
domain::{
handler::AttributeList,
types::{AttributeName, AttributeType, LdapObjectClass, Serialized},
types::{AttributeName, AttributeType, Serialized},
},
infra::{
access_control::{Permission, ValidationResults},
@@ -767,11 +722,6 @@ mod tests {
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()
@@ -963,7 +913,6 @@ mod tests {
isEditable
isHardcoded
}
extraLdapObjectClasses
}
groupSchema {
attributes {
@@ -974,7 +923,6 @@ mod tests {
isEditable
isHardcoded
}
extraLdapObjectClasses
}
}
}"#;
@@ -1059,8 +1007,7 @@ mod tests {
"isEditable": false,
"isHardcoded": true,
},
],
"extraLdapObjectClasses": ["customUserClass"],
]
},
"groupSchema": {
"attributes": [
@@ -1096,8 +1043,7 @@ mod tests {
"isEditable": false,
"isHardcoded": true,
},
],
"extraLdapObjectClasses": [],
]
}
}
}),
@@ -1114,7 +1060,6 @@ mod tests {
attributes {
name
}
extraLdapObjectClasses
}
}
}"#;
@@ -1136,8 +1081,6 @@ mod tests {
group_attributes: AttributeList {
attributes: Vec::new(),
},
extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")],
extra_group_object_classes: Vec::new(),
})
});
@@ -1163,8 +1106,7 @@ mod tests {
{"name": "mail"},
{"name": "user_id"},
{"name": "uuid"},
],
"extraLdapObjectClasses": ["customUserClass"],
]
}
}
} ),

View File

@@ -570,27 +570,10 @@ impl<Backend: BackendHandler + LoginHandler + OpaqueHandler> LdapHandler<Backend
.await
});
Ok(match scope {
SearchScope::Global => {
let users = get_user_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::Global => InternalSearchResults::UsersAndGroups(
get_user_list(&request.filter).await?,
get_group_list(&request.filter).await?,
),
SearchScope::Users => InternalSearchResults::UsersAndGroups(
get_user_list(&request.filter).await?,
Vec::new(),
@@ -1290,8 +1273,7 @@ mod tests {
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
b"customUserClass".to_vec(),
b"person".to_vec()
]
},
LdapPartialAttribute {
@@ -1333,8 +1315,7 @@ mod tests {
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
b"customUserClass".to_vec(),
b"person".to_vec()
]
},
LdapPartialAttribute {
@@ -1921,49 +1902,7 @@ mod tests {
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".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(),
b"person".to_vec()
]
},]
}),
@@ -2027,8 +1966,7 @@ mod tests {
b"inetOrgPerson".to_vec(),
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
b"customUserClass".to_vec(),
b"person".to_vec()
]
},
LdapPartialAttribute {
@@ -2113,7 +2051,6 @@ mod tests {
b"posixAccount".to_vec(),
b"mailAccount".to_vec(),
b"person".to_vec(),
b"customUserClass".to_vec(),
],
},
LdapPartialAttribute {
@@ -2895,11 +2832,6 @@ mod tests {
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;

View File

@@ -2,7 +2,6 @@ pub mod access_control;
pub mod auth_service;
pub mod cli;
pub mod configuration;
pub mod database_string;
pub mod db_cleaner;
pub mod graphql;
pub mod healthcheck;

View File

@@ -47,10 +47,6 @@ mockall::mock! {
async fn add_group_attribute(&self, request: CreateAttributeRequest) -> Result<()>;
async fn delete_user_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]
impl BackendHandler for TestBackendHandler {}
@@ -106,8 +102,6 @@ pub fn setup_default_schema(mock: &mut MockTestBackendHandler) {
group_attributes: AttributeList {
attributes: Vec::new(),
},
extra_user_object_classes: vec![LdapObjectClass::from("customUserClass")],
extra_group_object_classes: Vec::new(),
})
});
}

View File

@@ -18,7 +18,6 @@ use crate::{
infra::{
cli::*,
configuration::{compare_private_key_hashes, Configuration},
database_string::DatabaseUrl,
db_cleaner::Scheduler,
healthcheck, mail,
},
@@ -27,7 +26,7 @@ use actix::Actor;
use actix_server::ServerBuilder;
use anyhow::{anyhow, bail, Context, Result};
use futures_util::TryFutureExt;
use sea_orm::{Database, DatabaseConnection};
use sea_orm::Database;
use tracing::*;
mod domain;
@@ -80,9 +79,12 @@ async fn ensure_group_exists(handler: &SqlBackendHandler, group_name: &str) -> R
Ok(())
}
async fn setup_sql_tables(database_url: &DatabaseUrl) -> Result<DatabaseConnection> {
#[instrument(skip_all)]
async fn set_up_server(config: Configuration) -> Result<ServerBuilder> {
info!("Starting LLDAP version {}", env!("CARGO_PKG_VERSION"));
let sql_pool = {
let mut sql_opt = sea_orm::ConnectOptions::new(database_url.to_string());
let mut sql_opt = sea_orm::ConnectOptions::new(config.database_url.clone());
sql_opt
.max_connections(5)
.sqlx_logging(true)
@@ -91,18 +93,7 @@ async fn setup_sql_tables(database_url: &DatabaseUrl) -> Result<DatabaseConnecti
};
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(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?;
.context("while creating the tables")?;
let private_key_info = config.get_private_key_info();
let force_update_private_key = config.force_update_private_key;
match (
@@ -166,6 +157,7 @@ async fn set_up_server(config: Configuration) -> Result<ServerBuilder> {
actix_server::Server::build(),
)
.context("while binding the LDAP server")?;
infra::jwt_sql_tables::init_table(&sql_pool).await?;
let server_builder =
infra::tcp_server::build_tcp_server(&config, backend_handler, server_builder)
.await
@@ -176,41 +168,70 @@ async fn set_up_server(config: Configuration) -> Result<ServerBuilder> {
Ok(server_builder)
}
async fn run_server_command(opts: RunOpts) -> Result<()> {
async fn run_server(config: Configuration) -> 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);
let config = infra::configuration::init(opts)?;
infra::logging::init(&config)?;
let server = set_up_server(config).await?.workers(1);
use std::sync::{Arc, Mutex};
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));
}
server.run().await.context("while starting the server")
info!("End.");
Ok(())
}
async fn send_test_email_command(opts: TestEmailOpts) -> Result<()> {
fn send_test_email_command(opts: TestEmailOpts) -> Result<()> {
let to = opts.to.parse()?;
let config = infra::configuration::init(opts)?;
infra::logging::init(&config)?;
mail::send_test_email(to, &config.smtp_options)
.await
.context("Could not send email: {:#}")
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
runtime.block_on(
mail::send_test_email(to, &config.smtp_options)
.unwrap_or_else(|e| error!("Could not send email: {:#}", e)),
);
Ok(())
}
async fn run_healthcheck(opts: RunOpts) -> Result<()> {
fn run_healthcheck(opts: RunOpts) -> Result<()> {
debug!("CLI: {:#?}", &opts);
let config = infra::configuration::init(opts)?;
infra::logging::init(&config)?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
info!("Starting healthchecks");
use tokio::time::timeout;
let delay = Duration::from_millis(3000);
let (ldap, ldaps, api) = tokio::join!(
timeout(delay, healthcheck::check_ldap(config.ldap_port)),
timeout(delay, healthcheck::check_ldaps(&config.ldaps_options)),
timeout(delay, healthcheck::check_api(config.http_port)),
);
let (ldap, ldaps, api) = runtime.block_on(async {
tokio::join!(
timeout(delay, healthcheck::check_ldap(config.ldap_port)),
timeout(delay, healthcheck::check_ldaps(&config.ldaps_options)),
timeout(delay, healthcheck::check_api(config.http_port)),
)
});
let failure = [ldap, ldaps, api]
.into_iter()
@@ -222,29 +243,50 @@ async fn run_healthcheck(opts: RunOpts) -> Result<()> {
})
.any(|r| r.is_err());
if failure {
bail!("Healthcheck failed")
} else {
Ok(())
error!("Healthcheck failed");
}
std::process::exit(i32::from(failure))
}
async fn create_schema_command(opts: RunOpts) -> Result<()> {
async fn create_schema(database_url: String) -> 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);
let config = infra::configuration::init(opts)?;
infra::logging::init(&config)?;
setup_sql_tables(&config.database_url).await?;
let database_url = config.database_url;
actix::run(
create_schema(database_url).unwrap_or_else(|e| error!("Could not create schema: {:#}", e)),
)?;
info!("Schema created successfully.");
Ok(())
}
#[actix::main]
async fn main() -> Result<()> {
fn main() -> Result<()> {
let cli_opts = infra::cli::init();
match cli_opts.command {
Command::ExportGraphQLSchema(opts) => infra::graphql::api::export_schema(opts),
Command::Run(opts) => run_server_command(opts).await,
Command::HealthCheck(opts) => run_healthcheck(opts).await,
Command::SendTestEmail(opts) => send_test_email_command(opts).await,
Command::CreateSchema(opts) => create_schema_command(opts).await,
Command::Run(opts) => run_server_command(opts),
Command::HealthCheck(opts) => run_healthcheck(opts),
Command::SendTestEmail(opts) => send_test_email_command(opts),
Command::CreateSchema(opts) => create_schema_command(opts),
}
}