mirror of
https://github.com/kanidm/kanidm.git
synced 2025-02-23 04:27:02 +01:00
SSH Keys in Credentials Update (#3027)
This commit is contained in:
parent
ad3cf8828f
commit
0ce1bbeddc
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -3175,6 +3175,8 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_with",
|
"serde_with",
|
||||||
"sketching",
|
"sketching",
|
||||||
|
"sshkey-attest",
|
||||||
|
"sshkeys",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"time",
|
"time",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
|
|
@ -52,6 +52,8 @@ serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
serde_with = { workspace = true }
|
serde_with = { workspace = true }
|
||||||
sketching = { workspace = true }
|
sketching = { workspace = true }
|
||||||
|
sshkeys = { workspace = true }
|
||||||
|
sshkey-attest = { workspace = true }
|
||||||
time = { workspace = true, features = ["serde", "std", "local-offset"] }
|
time = { workspace = true, features = ["serde", "std", "local-offset"] }
|
||||||
tokio = { workspace = true, features = ["net", "sync", "io-util", "macros"] }
|
tokio = { workspace = true, features = ["net", "sync", "io-util", "macros"] }
|
||||||
tokio-openssl = { workspace = true }
|
tokio-openssl = { workspace = true }
|
||||||
|
|
|
@ -103,6 +103,10 @@ pub fn view_router() -> Router<ServerState> {
|
||||||
.route("/reset/change_password", post(reset::view_new_pwd))
|
.route("/reset/change_password", post(reset::view_new_pwd))
|
||||||
.route("/reset/add_passkey", post(reset::view_new_passkey))
|
.route("/reset/add_passkey", post(reset::view_new_passkey))
|
||||||
.route("/reset/set_unixcred", post(reset::view_set_unixcred))
|
.route("/reset/set_unixcred", post(reset::view_set_unixcred))
|
||||||
|
.route(
|
||||||
|
"/reset/add_ssh_publickey",
|
||||||
|
post(reset::view_add_ssh_publickey),
|
||||||
|
)
|
||||||
.route("/api/delete_alt_creds", post(reset::remove_alt_creds))
|
.route("/api/delete_alt_creds", post(reset::remove_alt_creds))
|
||||||
.route("/api/delete_unixcred", post(reset::remove_unixcred))
|
.route("/api/delete_unixcred", post(reset::remove_unixcred))
|
||||||
.route("/api/add_totp", post(reset::add_totp))
|
.route("/api/add_totp", post(reset::add_totp))
|
||||||
|
@ -110,6 +114,10 @@ pub fn view_router() -> Router<ServerState> {
|
||||||
.route("/api/remove_passkey", post(reset::remove_passkey))
|
.route("/api/remove_passkey", post(reset::remove_passkey))
|
||||||
.route("/api/finish_passkey", post(reset::finish_passkey))
|
.route("/api/finish_passkey", post(reset::finish_passkey))
|
||||||
.route("/api/cancel_mfareg", post(reset::cancel_mfareg))
|
.route("/api/cancel_mfareg", post(reset::cancel_mfareg))
|
||||||
|
.route(
|
||||||
|
"/api/remove_ssh_publickey",
|
||||||
|
post(reset::remove_ssh_publickey),
|
||||||
|
)
|
||||||
.route("/api/cu_cancel", post(reset::cancel_cred_update))
|
.route("/api/cu_cancel", post(reset::cancel_cred_update))
|
||||||
.route("/api/cu_commit", post(reset::commit))
|
.route("/api/cu_commit", post(reset::commit))
|
||||||
.layer(HxRequestGuardLayer::new("/ui"));
|
.layer(HxRequestGuardLayer::new("/ui"));
|
||||||
|
|
|
@ -14,11 +14,15 @@ use qrcode::render::svg;
|
||||||
use qrcode::QrCode;
|
use qrcode::QrCode;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_with::skip_serializing_none;
|
use serde_with::skip_serializing_none;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::fmt::{Display, Formatter};
|
use std::fmt::{Display, Formatter};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
pub use sshkey_attest::proto::PublicKey as SshPublicKey;
|
||||||
|
pub use sshkeys::KeyType;
|
||||||
|
|
||||||
use kanidm_proto::internal::{
|
use kanidm_proto::internal::{
|
||||||
CUCredState, CUExtPortal, CURegState, CURegWarning, CURequest, CUSessionToken, CUStatus,
|
CUCredState, CUExtPortal, CURegState, CURegWarning, CURequest, CUSessionToken, CUStatus,
|
||||||
CredentialDetail, OperationError, PasskeyDetail, PasswordFeedback, TotpAlgo, UserAuthToken,
|
CredentialDetail, OperationError, PasskeyDetail, PasswordFeedback, TotpAlgo, UserAuthToken,
|
||||||
|
@ -69,6 +73,12 @@ struct CredStatusView {
|
||||||
credentials_update_partial: CredResetPartialView,
|
credentials_update_partial: CredResetPartialView,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SshKey {
|
||||||
|
key_type: KeyType,
|
||||||
|
key: String,
|
||||||
|
comment: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "credentials_update_partial.html")]
|
#[template(path = "credentials_update_partial.html")]
|
||||||
struct CredResetPartialView {
|
struct CredResetPartialView {
|
||||||
|
@ -83,6 +93,8 @@ struct CredResetPartialView {
|
||||||
primary: Option<CredentialDetail>,
|
primary: Option<CredentialDetail>,
|
||||||
unixcred_state: CUCredState,
|
unixcred_state: CUCredState,
|
||||||
unixcred: Option<CredentialDetail>,
|
unixcred: Option<CredentialDetail>,
|
||||||
|
sshkeys_state: CUCredState,
|
||||||
|
sshkeys: BTreeMap<String, SshKey>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[skip_serializing_none]
|
#[skip_serializing_none]
|
||||||
|
@ -104,6 +116,13 @@ struct SetUnixCredPartial {
|
||||||
check_res: PwdCheckResult,
|
check_res: PwdCheckResult,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Template)]
|
||||||
|
#[template(path = "credential_update_add_ssh_publickey_partial.html")]
|
||||||
|
struct AddSshPublicKeyPartial {
|
||||||
|
title_error: Option<String>,
|
||||||
|
key_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Debug)]
|
#[derive(Serialize, Deserialize, Debug)]
|
||||||
enum PwdCheckResult {
|
enum PwdCheckResult {
|
||||||
Success,
|
Success,
|
||||||
|
@ -120,6 +139,17 @@ pub(crate) struct NewPassword {
|
||||||
new_password_check: String,
|
new_password_check: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub(crate) struct NewPublicKey {
|
||||||
|
title: String,
|
||||||
|
key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub(crate) struct PublicKeyRemoveData {
|
||||||
|
name: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub(crate) struct NewTotp {
|
pub(crate) struct NewTotp {
|
||||||
name: String,
|
name: String,
|
||||||
|
@ -341,6 +371,30 @@ pub(crate) async fn remove_unixcred(
|
||||||
Ok(get_cu_partial_response(cu_status))
|
Ok(get_cu_partial_response(cu_status))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn remove_ssh_publickey(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
Extension(kopid): Extension<KOpId>,
|
||||||
|
HxRequest(_hx_request): HxRequest,
|
||||||
|
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
|
||||||
|
DomainInfo(domain_info): DomainInfo,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(publickey): Form<PublicKeyRemoveData>,
|
||||||
|
) -> axum::response::Result<Response> {
|
||||||
|
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
|
||||||
|
|
||||||
|
let cu_status = state
|
||||||
|
.qe_r_ref
|
||||||
|
.handle_idmcredentialupdate(
|
||||||
|
cu_session_token,
|
||||||
|
CURequest::SshPublicKeyRemove(publickey.name),
|
||||||
|
kopid.eventid,
|
||||||
|
)
|
||||||
|
.map_err(|op_err| HtmxError::new(&kopid, op_err, domain_info))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(get_cu_partial_response(cu_status))
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn remove_totp(
|
pub(crate) async fn remove_totp(
|
||||||
State(state): State<ServerState>,
|
State(state): State<ServerState>,
|
||||||
Extension(kopid): Extension<KOpId>,
|
Extension(kopid): Extension<KOpId>,
|
||||||
|
@ -805,6 +859,95 @@ pub(crate) async fn view_set_unixcred(
|
||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct AddSshPublicKeyError {
|
||||||
|
key: Option<String>,
|
||||||
|
title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn view_add_ssh_publickey(
|
||||||
|
State(state): State<ServerState>,
|
||||||
|
Extension(kopid): Extension<KOpId>,
|
||||||
|
HxRequest(_hx_request): HxRequest,
|
||||||
|
VerifiedClientInformation(_client_auth_info): VerifiedClientInformation,
|
||||||
|
DomainInfo(domain_info): DomainInfo,
|
||||||
|
jar: CookieJar,
|
||||||
|
opt_form: Option<Form<NewPublicKey>>,
|
||||||
|
) -> axum::response::Result<Response> {
|
||||||
|
let cu_session_token: CUSessionToken = get_cu_session(&jar).await?;
|
||||||
|
|
||||||
|
let new_key = match opt_form {
|
||||||
|
None => {
|
||||||
|
return Ok((AddSshPublicKeyPartial {
|
||||||
|
title_error: None,
|
||||||
|
key_error: None,
|
||||||
|
},)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
Some(Form(new_key)) => new_key,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (
|
||||||
|
AddSshPublicKeyError {
|
||||||
|
key: key_error,
|
||||||
|
title: title_error,
|
||||||
|
},
|
||||||
|
status,
|
||||||
|
) = {
|
||||||
|
let publickey = match SshPublicKey::from_string(&new_key.key) {
|
||||||
|
Err(_) => {
|
||||||
|
return Ok((AddSshPublicKeyPartial {
|
||||||
|
title_error: None,
|
||||||
|
key_error: Some("Key cannot be parsed".to_string()),
|
||||||
|
},)
|
||||||
|
.into_response());
|
||||||
|
}
|
||||||
|
Ok(publickey) => publickey,
|
||||||
|
};
|
||||||
|
let res = state
|
||||||
|
.qe_r_ref
|
||||||
|
.handle_idmcredentialupdate(
|
||||||
|
cu_session_token,
|
||||||
|
CURequest::SshPublicKey(new_key.title, publickey),
|
||||||
|
kopid.eventid,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Ok(cu_status) => return Ok(get_cu_partial_response(cu_status)),
|
||||||
|
Err(e @ (OperationError::InvalidLabel | OperationError::DuplicateLabel)) => (
|
||||||
|
AddSshPublicKeyError {
|
||||||
|
title: Some(e.to_string()),
|
||||||
|
key: None,
|
||||||
|
},
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
),
|
||||||
|
Err(e @ OperationError::DuplicateKey) => (
|
||||||
|
AddSshPublicKeyError {
|
||||||
|
key: Some(e.to_string()),
|
||||||
|
title: None,
|
||||||
|
},
|
||||||
|
StatusCode::UNPROCESSABLE_ENTITY,
|
||||||
|
),
|
||||||
|
Err(operr) => {
|
||||||
|
return Err(ErrorResponse::from(HtmxError::new(
|
||||||
|
&kopid,
|
||||||
|
operr,
|
||||||
|
domain_info,
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
status,
|
||||||
|
HxPushUrl(Uri::from_static("/ui/reset/add_ssh_publickey")),
|
||||||
|
AddSshPublicKeyPartial {
|
||||||
|
title_error,
|
||||||
|
key_error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) async fn view_reset_get(
|
pub(crate) async fn view_reset_get(
|
||||||
State(state): State<ServerState>,
|
State(state): State<ServerState>,
|
||||||
Extension(kopid): Extension<KOpId>,
|
Extension(kopid): Extension<KOpId>,
|
||||||
|
@ -910,9 +1053,25 @@ fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView {
|
||||||
primary,
|
primary,
|
||||||
unixcred_state,
|
unixcred_state,
|
||||||
unixcred,
|
unixcred,
|
||||||
|
sshkeys_state,
|
||||||
|
sshkeys,
|
||||||
..
|
..
|
||||||
} = cu_status;
|
} = cu_status;
|
||||||
|
|
||||||
|
let sshkeyss: BTreeMap<String, SshKey> = sshkeys
|
||||||
|
.iter()
|
||||||
|
.map(|(k, v)| {
|
||||||
|
(
|
||||||
|
k.clone(),
|
||||||
|
SshKey {
|
||||||
|
key_type: v.clone().key_type,
|
||||||
|
key: v.fingerprint().hash,
|
||||||
|
comment: v.comment.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
CredResetPartialView {
|
CredResetPartialView {
|
||||||
ext_cred_portal,
|
ext_cred_portal,
|
||||||
can_commit,
|
can_commit,
|
||||||
|
@ -925,6 +1084,8 @@ fn get_cu_partial(cu_status: CUStatus) -> CredResetPartialView {
|
||||||
primary,
|
primary,
|
||||||
unixcred_state,
|
unixcred_state,
|
||||||
unixcred,
|
unixcred,
|
||||||
|
sshkeys_state,
|
||||||
|
sshkeys: sshkeyss,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -190,3 +190,10 @@ footer {
|
||||||
width: var(--icon-size);
|
width: var(--icon-size);
|
||||||
height: var(--icon-size);
|
height: var(--icon-size);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ssh-list-icon {
|
||||||
|
--icon-size: 32px;
|
||||||
|
width: var(--icon-size);
|
||||||
|
height: var(--icon-size);
|
||||||
|
transform: rotate(35deg);
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex flex-column row-gap-4">
|
||||||
|
<h4>Add new SSH Key</h4>
|
||||||
|
<form class="row-gap-3 d-flex flex-column needs-validation"
|
||||||
|
hx-target="#credentialUpdateDynamicSection"
|
||||||
|
hx-post="/ui/reset/add_ssh_publickey">
|
||||||
|
<div>
|
||||||
|
<label for="key-title" class="form-label">Title</label>
|
||||||
|
<input type="text" class="form-control(% if let Some(_) = title_error %) is-invalid(% endif %)" id="key-title" name="title" aria-describedby="title-validation-feedback">
|
||||||
|
<div id="title-validation-feedback" class="invalid-feedback">
|
||||||
|
(% if let Some(title_error) = title_error %)(( title_error ))(% endif %)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="key-content" class="form-label">Key</label>
|
||||||
|
<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>
|
||||||
|
<div id="key-validation-feedback" class="invalid-feedback">
|
||||||
|
(% if let Some(key_error) = key_error %)(( key_error ))(% endif %)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="column-gap-2 d-flex justify-content-end mt-2" hx-target="#credentialUpdateDynamicSection">
|
||||||
|
<button type="button" class="btn btn-danger" hx-get=((Urls::CredReset)) hx-target="body">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Submit</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
|
@ -85,12 +85,14 @@
|
||||||
(% when CUCredState::Modifiable %)
|
(% when CUCredState::Modifiable %)
|
||||||
(% include "credentials_update_passkeys.html" %)
|
(% include "credentials_update_passkeys.html" %)
|
||||||
<!-- Here we are modifiable so we can render the button to add passkeys -->
|
<!-- Here we are modifiable so we can render the button to add passkeys -->
|
||||||
<button type="button" class="btn btn-primary"
|
<div class="mt-3">
|
||||||
hx-post="/ui/reset/add_passkey"
|
<button type="button" class="btn btn-primary"
|
||||||
hx-vals='{"class": "Any"}'
|
hx-post="/ui/reset/add_passkey"
|
||||||
hx-target="#credentialUpdateDynamicSection">
|
hx-vals='{"class": "Any"}'
|
||||||
Add Passkey
|
hx-target="#credentialUpdateDynamicSection">
|
||||||
</button>
|
Add Passkey
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
(% when CUCredState::DeleteOnly %)
|
(% when CUCredState::DeleteOnly %)
|
||||||
(% if passkeys.len() > 0 %)
|
(% if passkeys.len() > 0 %)
|
||||||
|
@ -134,6 +136,55 @@
|
||||||
(% when CUCredState::PolicyDeny %)
|
(% when CUCredState::PolicyDeny %)
|
||||||
(% endmatch %)
|
(% endmatch %)
|
||||||
|
|
||||||
|
(% match sshkeys_state %)
|
||||||
|
(% when CUCredState::Modifiable %)
|
||||||
|
<hr class="my-4" />
|
||||||
|
<h4>SSH Keys</h4>
|
||||||
|
(% if sshkeys.len() > 0 %)
|
||||||
|
<p>This is a list of SSH keys associated with your account.</p>
|
||||||
|
<ul class="list-group">
|
||||||
|
(% for (keyname, sshkey) in sshkeys %)
|
||||||
|
<li class="list-group-item d-flex column-gap-3 py-3">
|
||||||
|
<div>
|
||||||
|
<img class="ssh-list-icon" src="/pkg/img/icons/key.svg" alt="" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-column row-gap-2 flex-grow-1">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div class="fw-bold column-gap-2">
|
||||||
|
(( keyname ))<span class="badge rounded-pill text-bg-dark ms-2">(( sshkey.key_type.short_name ))</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-tiny btn-danger"
|
||||||
|
hx-post="/ui/api/remove_ssh_publickey"
|
||||||
|
hx-vals='{"name": "(( keyname ))"}'
|
||||||
|
hx-target="#credentialUpdateDynamicSection">
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div><span class="font-monospace text-break">SHA256:(( sshkey.key ))</span></div>
|
||||||
|
(% if let Some(comment) = sshkey.comment %)
|
||||||
|
<div class="rounded bg-body-tertiary border border-light-subtle text-body-secondary px-2 py-1 align-self-stretch">Comment: (( comment ))</div>
|
||||||
|
(% endif %)
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
(% endfor %)
|
||||||
|
</ul>
|
||||||
|
(% else %)
|
||||||
|
<p>There are no SSH keys associated with your account.</p>
|
||||||
|
(% endif %)
|
||||||
|
<div class="mt-3">
|
||||||
|
<button class="btn btn-primary" type="button"
|
||||||
|
hx-post="/ui/reset/add_ssh_publickey"
|
||||||
|
hx-target="#credentialUpdateDynamicSection">
|
||||||
|
Add SSH Key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
(% when CUCredState::DeleteOnly %)
|
||||||
|
(% when CUCredState::AccessDeny %)
|
||||||
|
(% when CUCredState::PolicyDeny %)
|
||||||
|
(% endmatch %)
|
||||||
|
|
||||||
|
|
||||||
<hr class="my-4" />
|
<hr class="my-4" />
|
||||||
<div id="cred-update-commit-bar" class="toast bs-emphasis-color bs-secondary-bg">
|
<div id="cred-update-commit-bar" class="toast bs-emphasis-color bs-secondary-bg">
|
||||||
<div class="toast-body">
|
<div class="toast-body">
|
||||||
|
@ -148,7 +199,7 @@
|
||||||
</svg>
|
</svg>
|
||||||
<b>Careful</b> - Unsaved changes will be lost</div>
|
<b>Careful</b> - Unsaved changes will be lost</div>
|
||||||
</span>
|
</span>
|
||||||
<div class="mt-2 pt-2 border-top">
|
<div class="mt-3 d-flex column-gap-2">
|
||||||
<button class="btn btn-danger"
|
<button class="btn btn-danger"
|
||||||
hx-post="/ui/api/cu_cancel"
|
hx-post="/ui/api/cu_cancel"
|
||||||
hx-boost="false"
|
hx-boost="false"
|
||||||
|
|
|
@ -21,7 +21,7 @@
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="collapse navbar-collapse" id="navbarCollapse">
|
<div class="collapse navbar-collapse" id="navbarCollapse">
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-md-0">
|
<ul class="navbar-nav">
|
||||||
<li>
|
<li>
|
||||||
<a class="nav-link" href=((Urls::Apps))>
|
<a class="nav-link" href=((Urls::Apps))>
|
||||||
<span data-feather="file"></span>Applications</a>
|
<span data-feather="file"></span>Applications</a>
|
||||||
|
@ -31,7 +31,7 @@
|
||||||
<span data-feather="file"></span>Profile</a>
|
<span data-feather="file"></span>Profile</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-md-0 ms-md-auto">
|
<ul class="navbar-nav ms-md-auto">
|
||||||
<li>
|
<li>
|
||||||
<a class="nav-link" href="#" data-bs-toggle="modal"
|
<a class="nav-link" href="#" data-bs-toggle="modal"
|
||||||
data-bs-target="#signoutModal">Sign out</a>
|
data-bs-target="#signoutModal">Sign out</a>
|
||||||
|
|
Loading…
Reference in a new issue