From 4f261c906e777dc320d8e2af8ecc8fc757c29d4d Mon Sep 17 00:00:00 2001
From: keerthi <keerthi.sree2105@gmail.com>
Date: Tue, 22 Apr 2025 12:41:20 +0530
Subject: [PATCH 1/2] fix(web): Preserve SSH key content on form validation
 error

---
 server/core/src/https/views/reset.rs                          | 4 ++++
 .../credential_update_add_ssh_publickey_partial.html          | 2 +-
 2 files changed, 5 insertions(+), 1 deletion(-)

diff --git a/server/core/src/https/views/reset.rs b/server/core/src/https/views/reset.rs
index 195e042a6..c6450ff3a 100644
--- a/server/core/src/https/views/reset.rs
+++ b/server/core/src/https/views/reset.rs
@@ -121,6 +121,7 @@ struct SetUnixCredPartial {
 struct AddSshPublicKeyPartial {
     title_error: Option<String>,
     key_error: Option<String>,
+    key_value: Option<String>,
 }
 
 #[derive(Serialize, Deserialize, Debug)]
@@ -902,6 +903,7 @@ pub(crate) async fn view_add_ssh_publickey(
             return Ok((AddSshPublicKeyPartial {
                 title_error: None,
                 key_error: None,
+                key_value: None,
             },)
                 .into_response());
         }
@@ -920,6 +922,7 @@ pub(crate) async fn view_add_ssh_publickey(
                 return Ok((AddSshPublicKeyPartial {
                     title_error: None,
                     key_error: Some("Key cannot be parsed".to_string()),
+                    key_value: Some(new_key.key),
                 },)
                     .into_response());
             }
@@ -965,6 +968,7 @@ pub(crate) async fn view_add_ssh_publickey(
         AddSshPublicKeyPartial {
             title_error,
             key_error,
+            key_value: Some(new_key.key),
         },
     )
         .into_response())
diff --git a/server/core/templates/credential_update_add_ssh_publickey_partial.html b/server/core/templates/credential_update_add_ssh_publickey_partial.html
index 141e19554..31e7c71a9 100644
--- a/server/core/templates/credential_update_add_ssh_publickey_partial.html
+++ b/server/core/templates/credential_update_add_ssh_publickey_partial.html
@@ -16,7 +16,7 @@
             <textarea class="form-control(% if let Some(_) = key_error %) is-invalid(% endif %)" id="key-content" rows="5" name="key"
                 aria-describedby="key-validation-feedback"
                 placeholder="Begins with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'"
-            ></textarea>
+            >(% if let Some(key_value) = key_value %)(( key_value ))(% endif %)</textarea>
             <div id="key-validation-feedback" class="invalid-feedback">
                 (% if let Some(key_error) = key_error %)(( key_error ))(% endif %)
             </div>

From 70b49e6968c90a8e1d6cfd0542fab17a22f0bf29 Mon Sep 17 00:00:00 2001
From: keerthi <keerthi.sree2105@gmail.com>
Date: Thu, 24 Apr 2025 15:04:12 +0530
Subject: [PATCH 2/2] feat(cli): Allow clearing legalname field via empty
 string

---
 libs/client/src/person.rs   |  16 ++++++
 tools/cli/src/cli/person.rs | 101 +++++++++++++++++++++++++++++++++---
 tools/cli/src/opt/kanidm.rs |   3 +-
 3 files changed, 111 insertions(+), 9 deletions(-)

diff --git a/libs/client/src/person.rs b/libs/client/src/person.rs
index a5fdbf71b..5734ac174 100644
--- a/libs/client/src/person.rs
+++ b/libs/client/src/person.rs
@@ -6,6 +6,10 @@ use kanidm_proto::v1::{AccountUnixExtend, Entry, SingleStringRequest, UatStatus}
 use uuid::Uuid;
 
 use crate::{ClientError, KanidmClient};
+use kanidm_proto::scim_v1::PatchRequest;
+use serde_json::Value;
+
+use tracing::trace;
 
 impl KanidmClient {
     pub async fn idm_person_account_list(&self) -> Result<Vec<Entry>, ClientError> {
@@ -286,4 +290,16 @@ impl KanidmClient {
         self.perform_post_request(format!("/v1/person/{}/_certificate", id).as_str(), new_cert)
             .await
     }
+
+    pub async fn idm_person_account_patch(
+        &self,
+        id: &str,
+        patch_request: PatchRequest,
+    ) -> Result<(), ClientError> {
+        if patch_request.operations.is_empty() {
+            return Ok(());
+        }
+        self.perform_patch_request_scim(format!("/v1/person/{}", id).as_str(), patch_request)
+            .await
+    }
 }
diff --git a/tools/cli/src/cli/person.rs b/tools/cli/src/cli/person.rs
index 87c2ad765..1ec2fb358 100644
--- a/tools/cli/src/cli/person.rs
+++ b/tools/cli/src/cli/person.rs
@@ -30,6 +30,36 @@ use crate::{
     AccountSsh, AccountUserAuthToken, AccountValidity, OutputMode, PersonOpt, PersonPosix,
 };
 
+use kanidm_client::ClientError;
+use kanidm_client::OutputFormat;
+use kanidm_client::SSHKEY_ATTEST_FEATURE;
+use kanidm_proto::identity::v1::{
+    IdentityGetReply, KeyAttestationVerifyRequest, KeyType as SSHKeyAttestationType,
+};
+use kanidm_proto::identity::{self};
+use kanidm_proto::internal::{
+    CUCredState, CURegState, CURegWarning, CURequest, CUSessionToken, CUStatus, CredentialDetail,
+    PasskeyDetail, SshPublicKey, TotpSecret,
+};
+use kanidm_proto::scim_v1::{
+    client::{ScimSshPublicKeys, PATCHOP_SCHEMA_URI}, 
+    PatchOp, PatchOperation, PatchRequest, ScimEntryGetQuery,
+    ATTR_DISPLAYNAME, ATTR_GIDNUMBER, ATTR_LEGALNAME, ATTR_MAIL, ATTR_NAME, ATTR_PASSWORD, ATTR_POSIXACCOUNTS,
+    ATTR_SSH_PUBLICKEY,
+};
+use kanidm_proto::MessageError;
+use serde_json::json;
+use sshkey_attest::{Error as AttestationError, RegisterChallengeResponse};
+use sshkeys::PublicKey as ActualSshPublicKey;
+use std::collections::BTreeMap;
+use std::fmt;
+use std::path::PathBuf;
+use std::str::FromStr;
+
+use dialoguer::Input;
+use tracing::{debug, error, info, trace, warn};
+use uuid::Uuid;
+
 impl PersonOpt {
     pub fn debug(&self) -> bool {
         match self {
@@ -321,14 +351,71 @@ impl PersonOpt {
             }
             PersonOpt::Update(aopt) => {
                 let client = aopt.copt.to_client(OpType::Write).await;
+
+                let mut patch_ops = Vec::new();
+
+                // Helper to create a replace operation
+                let replace_op = |path: &str, value: &str| -> PatchOperation {
+                    PatchOperation {
+                        op: PatchOp::Replace,
+                        path: Some(path.to_string()),
+                        value: Some(json!(value)),
+                    }
+                };
+
+                // Helper to create a replace operation for Vec<String>
+                let replace_op_vec = |path: &str, value: &[String]| -> PatchOperation {
+                    PatchOperation {
+                        op: PatchOp::Replace,
+                        path: Some(path.to_string()),
+                        value: Some(json!(value)),
+                    }
+                };
+
+                // Helper to create a remove operation
+                let remove_op = |path: &str| -> PatchOperation {
+                    PatchOperation {
+                        op: PatchOp::Remove,
+                        path: Some(path.to_string()),
+                        value: None,
+                    }
+                };
+
+                if let Some(newname) = &aopt.newname {
+                    patch_ops.push(replace_op(ATTR_NAME, newname));
+                }
+                if let Some(displayname) = &aopt.displayname {
+                    patch_ops.push(replace_op(ATTR_DISPLAYNAME, displayname));
+                }
+                if let Some(legalname) = &aopt.legalname {
+                    if legalname.is_empty() {
+                        // Empty string means clear the attribute
+                        patch_ops.push(remove_op(ATTR_LEGALNAME));
+                    } else {
+                        patch_ops.push(replace_op(ATTR_LEGALNAME, legalname));
+                    }
+                }
+                if let Some(mail) = &aopt.mail {
+                    if mail.is_empty() {
+                         // Empty list means clear the attribute
+                        patch_ops.push(remove_op(ATTR_MAIL));
+                    } else {
+                        patch_ops.push(replace_op_vec(ATTR_MAIL, mail));
+                    }
+                }
+
+                if patch_ops.is_empty() {
+                    println!("No changes specified.");
+                    return;
+                }
+
+                let patch_request = PatchRequest {
+                    schemas: vec![PATCHOP_SCHEMA_URI.to_string()],
+                    operations: patch_ops,
+                };
+
                 match client
-                    .idm_person_account_update(
-                        aopt.aopts.account_id.as_str(),
-                        aopt.newname.as_deref(),
-                        aopt.displayname.as_deref(),
-                        aopt.legalname.as_deref(),
-                        aopt.mail.as_deref(),
-                    )
+                    .idm_person_account_patch(aopt.aopts.account_id.as_str(), patch_request)
                     .await
                 {
                     Ok(()) => println!("Success"),
diff --git a/tools/cli/src/opt/kanidm.rs b/tools/cli/src/opt/kanidm.rs
index 5e02a10ba..9b197504e 100644
--- a/tools/cli/src/opt/kanidm.rs
+++ b/tools/cli/src/opt/kanidm.rs
@@ -594,8 +594,7 @@ pub enum ServiceAccountPosix {
 pub struct PersonUpdateOpt {
     #[clap(flatten)]
     aopts: AccountCommonOpt,
-    #[clap(long, short, help = "Set the legal name for the person.",
-    value_parser = clap::builder::NonEmptyStringValueParser::new())]
+    #[clap(long, short, help = "Set the legal name for the person. Use '' to clear.")]
     legalname: Option<String>,
     #[clap(long, short, help = "Set the account name for the person.",
     value_parser = clap::builder::NonEmptyStringValueParser::new())]