1use crate::https::errors::WebError;
2use crate::https::extractors::{DomainInfo, VerifiedClientInformation};
3use crate::https::middleware::KOpId;
4use crate::https::views::errors::HtmxError;
5use crate::https::views::navbar::NavbarCtx;
6use crate::https::views::{ErrorToastPartial, Urls};
7use crate::https::ServerState;
8use askama::Template;
9use askama_web::WebTemplate;
10use axum::extract::{Path, State};
11use axum::response::{IntoResponse, Response};
12use axum::Extension;
13use axum_extra::extract::Form;
14use axum_htmx::{HxPushUrl, HxRequest};
15use futures_util::TryFutureExt;
16use kanidm_proto::attribute::Attribute;
17use kanidm_proto::internal::{OperationError, UserAuthToken};
18use kanidm_proto::scim_v1::server::{
19    ScimEffectiveAccess, ScimEntryKanidm, ScimGroup, ScimListResponse, ScimValueKanidm,
20};
21use kanidm_proto::scim_v1::ScimEntryGetQuery;
22use kanidm_proto::scim_v1::{client::ScimEntryPutKanidm, ScimFilter};
23use kanidmd_lib::constants::EntryClass;
24use kanidmd_lib::filter::{f_eq, Filter};
25use kanidmd_lib::idm::authentication::ClientAuthInfo;
26use serde::{Deserialize, Serialize};
27use std::collections::BTreeMap;
28use uuid::Uuid;
29
30pub const GROUP_ATTRIBUTES: [Attribute; 4] = [
31    Attribute::Uuid,
32    Attribute::Name,
33    Attribute::Description,
34    Attribute::Member,
35];
36
37#[derive(Template, WebTemplate)]
38#[template(path = "admin/admin_panel_template.html")]
39pub(crate) struct GroupsView {
40    navbar_ctx: NavbarCtx,
41    partial: GroupsPartialView,
42}
43
44#[derive(Template, WebTemplate)]
45#[template(path = "admin/admin_groups_partial.html")]
46struct GroupsPartialView {
47    groups: Vec<(ScimGroup, ScimEffectiveAccess)>,
48}
49
50#[derive(Template, WebTemplate)]
51#[template(path = "admin/admin_panel_template.html")]
52struct GroupView {
53    partial: GroupViewPartial,
54    navbar_ctx: NavbarCtx,
55}
56
57#[derive(Template, WebTemplate)]
58#[template(path = "admin/admin_group_view_partial.html")]
59struct GroupViewPartial {
60    group: ScimGroup,
61    can_rw: bool,
62    scim_effective_access: ScimEffectiveAccess,
63}
64
65#[derive(Template, WebTemplate)]
66#[template(
67    ext = "html",
68    source = "\
69(% include \"admin/admin_group_member_entry_partial.html\" %)\
70(% include \"admin/saved_toast.html\" %)\
71"
72)]
73struct GroupMemberEntryResponse {
74    group_uuid: Uuid,
75    member_name: String,
76    can_edit_member: bool,
77}
78
79#[derive(Template, WebTemplate)]
80#[template(path = "admin/saved_toast.html")]
81struct SavedToast {}
82
83pub(crate) async fn view_group_view_get(
84    State(state): State<ServerState>,
85    HxRequest(is_htmx): HxRequest,
86    Extension(kopid): Extension<KOpId>,
87    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
88    Path(uuid): Path<Uuid>,
89    DomainInfo(domain_info): DomainInfo,
90) -> axum::response::Result<Response> {
91    let (group, scim_effective_access) =
92        get_group_info(uuid, state.clone(), &kopid, client_auth_info.clone()).await?;
93    let uat: &UserAuthToken = client_auth_info
94        .pre_validated_uat()
95        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
96
97    let time = time::OffsetDateTime::now_utc() + time::Duration::new(60, 0);
98    let can_rw = uat.purpose_readwrite_active(time);
99    let group_partial = GroupViewPartial {
100        group,
101        can_rw,
102        scim_effective_access,
103    };
104
105    let path_string = format!("/ui/admin/group/{uuid}/view");
106    let push_url = HxPushUrl(path_string);
107    Ok(if is_htmx {
108        (push_url, group_partial).into_response()
109    } else {
110        (
111            push_url,
112            GroupView {
113                partial: group_partial,
114                navbar_ctx: NavbarCtx::new(domain_info, &uat.ui_hints),
115            },
116        )
117            .into_response()
118    })
119}
120
121pub(crate) async fn view_groups_get(
122    State(state): State<ServerState>,
123    HxRequest(is_htmx): HxRequest,
124    Extension(kopid): Extension<KOpId>,
125    DomainInfo(domain_info): DomainInfo,
126    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
127) -> axum::response::Result<Response> {
128    let groups = get_groups_info(state, &kopid, client_auth_info.clone()).await?;
129    let groups_partial = GroupsPartialView { groups };
130    let uat: &UserAuthToken = client_auth_info
131        .pre_validated_uat()
132        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))?;
133
134    let push_url = HxPushUrl("/ui/admin/groups".to_string());
135    Ok(if is_htmx {
136        (push_url, groups_partial).into_response()
137    } else {
138        (
139            push_url,
140            GroupsView {
141                navbar_ctx: NavbarCtx::new(domain_info, &uat.ui_hints),
142                partial: groups_partial,
143            },
144        )
145            .into_response()
146    })
147}
148
149pub async fn get_group_info(
150    uuid: Uuid,
151    state: ServerState,
152    kopid: &KOpId,
153    client_auth_info: ClientAuthInfo,
154) -> Result<(ScimGroup, ScimEffectiveAccess), WebError> {
155    let scim_entry: ScimEntryKanidm = state
156        .qe_r_ref
157        .scim_entry_id_get(
158            client_auth_info.clone(),
159            kopid.eventid,
160            uuid.to_string(),
161            EntryClass::Group,
162            ScimEntryGetQuery {
163                attributes: Some(Vec::from(GROUP_ATTRIBUTES)),
164                ext_access_check: true,
165                ..Default::default()
166            },
167        )
168        .await?;
169
170    if let Some(groupinfo_info) = scimentry_into_groupinfo(scim_entry) {
171        Ok(groupinfo_info)
172    } else {
173        Err(WebError::from(OperationError::InvalidState))
174    }
175}
176
177async fn get_groups_info(
178    state: ServerState,
179    kopid: &KOpId,
180    client_auth_info: ClientAuthInfo,
181) -> Result<Vec<(ScimGroup, ScimEffectiveAccess)>, WebError> {
182    let filter = ScimFilter::Equal(Attribute::Class.into(), EntryClass::Group.into());
183
184    let base: ScimListResponse = state
185        .qe_r_ref
186        .scim_entry_search(
187            client_auth_info.clone(),
188            kopid.eventid,
189            filter,
190            ScimEntryGetQuery {
191                attributes: Some(Vec::from(GROUP_ATTRIBUTES)),
192                ext_access_check: true,
193                sort_by: Some(Attribute::Name),
194                ..Default::default()
195            },
196        )
197        .await?;
198
199    let groups: Vec<_> = base
200        .resources
201        .into_iter()
202        .filter_map(scimentry_into_groupinfo)
204        .collect();
205
206    Ok(groups)
207}
208
209#[derive(Clone, Debug, Serialize, Deserialize)]
210pub(crate) struct SaveGroupForm {
211    #[serde(rename = "name")]
212    account_name: String,
213    description: Option<String>,
214}
215
216pub(crate) async fn edit_group(
217    State(state): State<ServerState>,
218    Extension(kopid): Extension<KOpId>,
219    DomainInfo(domain_info): DomainInfo,
220    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
221    Path(group_uuid): Path<Uuid>,
222    Form(query): Form<SaveGroupForm>,
224) -> axum::response::Result<Response> {
225    let mut attrs = BTreeMap::new();
226    attrs.insert(
227        Attribute::Name,
228        Some(ScimValueKanidm::String(query.account_name)),
229    );
230
231    let (group_info, _) =
232        get_group_info(group_uuid, state.clone(), &kopid, client_auth_info.clone()).await?;
233
234    if group_info.description != query.description {
238        attrs.insert(
239            Attribute::Description,
240            query.description.map(ScimValueKanidm::String),
241        );
242    }
243
244    let generic = ScimEntryPutKanidm {
245        id: group_uuid,
246        attrs,
247    }
248    .try_into()
249    .map_err(|_| HtmxError::new(&kopid, OperationError::Backend, domain_info.clone()))?;
250
251    state
252        .qe_w_ref
253        .handle_scim_entry_put(client_auth_info.clone(), kopid.eventid, generic)
254        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
255        .await?;
256
257    Ok((SavedToast {}).into_response())
259}
260
261#[derive(Clone, Debug, Serialize, Deserialize)]
262pub(crate) struct AddMemberForm {
263    member: String,
264}
265
266pub(crate) async fn add_member(
267    State(state): State<ServerState>,
268    Extension(kopid): Extension<KOpId>,
269    DomainInfo(domain_info): DomainInfo,
270    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
271    Path(group_uuid): Path<Uuid>,
272    Form(query): Form<AddMemberForm>,
274) -> axum::response::Result<Response> {
275    let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into()));
276
277    let get_query = ScimEntryGetQuery {
278        attributes: Some(vec![Attribute::Member]),
279        ext_access_check: false,
280        sort_by: None,
281        sort_order: None,
282        start_index: None,
283        count: None,
284        filter: None,
285    };
286    let get_member_query = ScimEntryGetQuery {
287        attributes: Some(vec![Attribute::Spn]),
288        ext_access_check: false,
289        sort_by: None,
290        sort_order: None,
291        start_index: None,
292        count: None,
293        filter: None,
294    };
295    let group_uuid_str = String::from(group_uuid);
296    let before = state
297        .qe_r_ref
298        .scim_entry_id_get(
299            client_auth_info.clone(),
300            kopid.eventid,
301            group_uuid_str.clone(),
302            EntryClass::Group,
303            get_query.clone(),
304        )
305        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
306        .await?;
307    state
308        .qe_w_ref
309        .handle_appendattribute(
310            client_auth_info.clone(),
311            group_uuid_str.clone(),
312            "member".to_string(),
313            vec![query.member.clone()],
314            filter,
315            kopid.eventid,
316        )
317        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
318        .await?;
319
320    let after = state
321        .qe_r_ref
322        .scim_entry_id_get(
323            client_auth_info.clone(),
324            kopid.eventid,
325            group_uuid_str.clone(),
326            EntryClass::Group,
327            get_query,
328        )
329        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
330        .await?;
331
332    let before_len = if let Some(ScimValueKanidm::EntryReferences(members_before)) =
333        before.attrs.get(&Attribute::Member)
334    {
335        members_before.len()
336    } else {
337        0
338    };
339    let after_len = if let Some(ScimValueKanidm::EntryReferences(members_after)) =
340        after.attrs.get(&Attribute::Member)
341    {
342        members_after.len()
343    } else {
344        0
345    };
346
347    if before_len + 1 == after_len {
348        let added_member_scim = state
349            .qe_r_ref
350            .scim_entry_id_get(
351                client_auth_info.clone(),
352                kopid.eventid,
353                query.member.clone(),
354                EntryClass::Object,
355                get_member_query,
356            )
357            .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
358            .await?;
359
360        let Some(ScimValueKanidm::String(added_member_spn)) =
361            added_member_scim.attrs.get(&Attribute::Spn)
362        else {
363            return Ok((ErrorToastPartial {
364                err_code: OperationError::UI0004MemberAlreadyExists,
365                operation_id: kopid.eventid,
366            })
367            .into_response());
368        };
369        Ok((GroupMemberEntryResponse {
371            group_uuid,
372            member_name: added_member_spn.to_string(),
373            can_edit_member: true,
374        })
375        .into_response())
376    } else {
377        Ok((ErrorToastPartial {
379            err_code: OperationError::UI0004MemberAlreadyExists,
380            operation_id: kopid.eventid,
381        })
382        .into_response())
383    }
384}
385
386pub(crate) async fn remove_member(
387    State(state): State<ServerState>,
388    Extension(kopid): Extension<KOpId>,
389    DomainInfo(domain_info): DomainInfo,
390    VerifiedClientInformation(client_auth_info): VerifiedClientInformation,
391    Path(group_uuid): Path<Uuid>,
392    Form(query): Form<AddMemberForm>,
394) -> axum::response::Result<Response> {
395    let filter = filter_all!(f_eq(Attribute::Class, EntryClass::Group.into()));
396
397    state
398        .qe_w_ref
399        .handle_removeattributevalues(
400            client_auth_info.clone(),
401            String::from(group_uuid),
402            "member".to_string(),
403            vec![query.member],
404            filter,
405            kopid.eventid,
406        )
407        .map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info.clone()))
408        .await?;
409
410    Ok((SavedToast {}).into_response())
412}
413
414fn scimentry_into_groupinfo(
415    scim_entry: ScimEntryKanidm,
416) -> Option<(ScimGroup, ScimEffectiveAccess)> {
417    let scim_effective_access = scim_entry.ext_access_check.clone()?; let group = ScimGroup::try_from(scim_entry).ok()?;
419
420    Some((group, scim_effective_access))
421}