diff --git a/Cargo.lock b/Cargo.lock index 2fc3b589f..2341d1be4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1014,6 +1014,7 @@ dependencies = [ "serde_urlencoded", "sha-1", "sqlx", + "ssh-key", "struct-patch", "tera", "thiserror", @@ -2794,6 +2795,20 @@ dependencies = [ "sha2", ] +[[package]] +name = "p521" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fc9e2161f1f215afdfce23677034ae137bbd45016a880c2eb3ba8eb95f085b2" +dependencies = [ + "base16ct", + "ecdsa", + "elliptic-curve", + "primeorder", + "rand_core", + "sha2", +] + [[package]] name = "parity-scale-codec" version = "3.6.9" @@ -3523,6 +3538,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core", + "sha2", "signature", "spki", "subtle", @@ -4260,6 +4276,47 @@ dependencies = [ "uuid", ] +[[package]] +name = "ssh-cipher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" +dependencies = [ + "cipher", + "ssh-encoding", +] + +[[package]] +name = "ssh-encoding" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9242b9ef4108a78e8cd1a2c98e193ef372437f8c22be363075233321dd4a15" +dependencies = [ + "base64ct", + "pem-rfc7468", + "sha2", +] + +[[package]] +name = "ssh-key" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01f8f4ea73476c0aa5d5e6a75ce1e8634e2c3f82005ef3bbed21547ac57f2bf7" +dependencies = [ + "p256", + "p384", + "p521", + "rand_core", + "rsa", + "sec1", + "sha2", + "signature", + "ssh-cipher", + "ssh-encoding", + "subtle", + "zeroize", +] + [[package]] name = "stacker" version = "0.1.15" diff --git a/Cargo.toml b/Cargo.toml index 112b97668..be95dd00c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,7 @@ webauthn-rs = { version = "0.4", features = [ ] } webauthn-rs-proto = "0.4" x25519-dalek = { version = "2.0", features = ["static_secrets"] } +ssh-key = "0.6.4" [dev-dependencies] bytes = "1.5" diff --git a/migrations/20240123195802_authentication_key.down.sql b/migrations/20240123195802_authentication_key.down.sql new file mode 100644 index 000000000..b21e0e0f9 --- /dev/null +++ b/migrations/20240123195802_authentication_key.down.sql @@ -0,0 +1 @@ +DROP TABLE authentication_key; diff --git a/migrations/20240123195802_authentication_key.up.sql b/migrations/20240123195802_authentication_key.up.sql new file mode 100644 index 000000000..c2e0123b8 --- /dev/null +++ b/migrations/20240123195802_authentication_key.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE authentication_key ( + id bigserial PRIMARY KEY NOT NULL, + user_id bigint NOT NULL, + key text NOT NULL, + key_type text NOT NULL, + name text NOT NULL, + created timestamp without time zone NOT NULL, + FOREIGN KEY(user_id) REFERENCES "user"(id) ON DELETE CASCADE + -- TODO: Hardware key relation +); diff --git a/src/db/models/authentication_key.rs b/src/db/models/authentication_key.rs new file mode 100644 index 000000000..9585872f3 --- /dev/null +++ b/src/db/models/authentication_key.rs @@ -0,0 +1,99 @@ +use std::fmt::{self, Display, Formatter}; + +use chrono::{NaiveDateTime, Utc}; +use model_derive::Model; +use sqlx::{query_as, Error as SqlxError}; + +use crate::db::DbPool; + +#[derive(Clone, Deserialize, Model, Serialize, Debug)] +#[table(authentication_key)] +pub struct AuthenticationKey { + id: Option, + pub user_id: i64, + pub key: String, + pub name: String, + pub key_type: String, + pub created: NaiveDateTime, +} + +impl Display for AuthenticationKey { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self.id { + Some(id) => write!(f, "[ID {}] {}", id, self.name), + None => write!(f, "{}", self.name), + } + } +} + +impl AuthenticationKey { + #[must_use] + pub fn new(user_id: i64, key: String, name: String, key_type: String) -> Self { + Self { + id: None, + user_id, + key, + name, + key_type, + created: Utc::now().naive_utc(), + } + } + + pub async fn fetch_user_authentication_keys( + pool: &DbPool, + user_id: i64, + ) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", user_id, key, name, key_type, created + FROM authentication_key WHERE user_id = $1", + user_id, + ) + .fetch_all(pool) + .await + } + + pub async fn fetch_user_authentication_keys_by_type( + pool: &DbPool, + user_id: i64, + key_type: &str, + ) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", user_id, key, name, key_type, created + FROM authentication_key WHERE user_id = $1 AND key_type = $2", + user_id, + key_type, + ) + .fetch_all(pool) + .await + } + + pub async fn find_by_user( + pool: &DbPool, + user_id: i64, + key: String, + ) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", user_id, key, name, key_type, created + FROM authentication_key WHERE user_id = $1 AND key = $2", + user_id, + key, + ) + .fetch_optional(pool) + .await + } + + pub async fn find_authentication_key(&self, pool: &DbPool) -> Result, SqlxError> { + query_as!( + Self, + "SELECT id \"id?\", user_id, key, name, key_type, created + FROM authentication_key WHERE user_id = $1 AND key = $2", + self.user_id, + self.key, + ) + .fetch_optional(pool) + .await + } +} diff --git a/src/db/models/mod.rs b/src/db/models/mod.rs index 6e46502d9..6d43d5364 100644 --- a/src/db/models/mod.rs +++ b/src/db/models/mod.rs @@ -1,5 +1,6 @@ #[cfg(feature = "openid")] pub mod auth_code; +pub mod authentication_key; pub mod device; pub mod device_login; pub mod enrollment; diff --git a/src/handlers/ssh_authorized_keys.rs b/src/handlers/ssh_authorized_keys.rs index 9fecb8c57..6c097bf01 100644 --- a/src/handlers/ssh_authorized_keys.rs +++ b/src/handlers/ssh_authorized_keys.rs @@ -1,9 +1,21 @@ use crate::{ appstate::AppState, - db::{Group, User}, + auth::SessionInfo, + db::{models::authentication_key::AuthenticationKey, DbPool, Group, User}, error::WebError, }; -use axum::extract::{Query, State}; +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use serde_json::json; +use ssh_key::PublicKey; + +use super::{ApiResponse, ApiResult}; + +static SSH_KEY_TYPE: &str = "SSH"; +static GPG_KEY_TYPE: &str = "GPG"; /// Trim optional newline fn trim_newline(s: &mut String) { @@ -15,6 +27,22 @@ fn trim_newline(s: &mut String) { } } +async fn add_user_ssh_keys_to_list(pool: &DbPool, user: &User, ssh_keys: &mut Vec) { + if let Some(user_id) = user.id { + let keys_result = + AuthenticationKey::fetch_user_authentication_keys_by_type(pool, user_id, SSH_KEY_TYPE) + .await; + + if let Ok(authentication_keys) = keys_result { + let mut keys: Vec = authentication_keys + .into_iter() + .map(|item| item.key) + .collect(); + ssh_keys.append(&mut keys); + } + } +} + #[derive(Deserialize, Debug)] pub struct SshKeysRequestParams { username: Option, @@ -32,9 +60,10 @@ pub async fn get_authorized_keys( State(appstate): State, ) -> Result { info!("Fetching public SSH keys for {:?}", params); - let mut ssh_keys = Vec::new(); + let mut ssh_keys: Vec = Vec::new(); - let mut add_user_keys_to_list = |user: User| { + // TODO: should be obsolete once YubiKeys are moved to a new table + let add_user_keys_to_list = |user: User, ssh_keys: &mut Vec| { // add key to list if user has an assigned SSH key if let Some(mut key) = user.ssh_key { trim_newline(&mut key); @@ -57,7 +86,9 @@ pub async fn get_authorized_keys( // check if user belongs to specified group let members = group.member_usernames(&appstate.pool).await?; if members.contains(&user.username) { - add_user_keys_to_list(user); + add_user_keys_to_list(user.clone(), &mut ssh_keys); + add_user_ssh_keys_to_list(&appstate.pool, &user, &mut ssh_keys) + .await; } else { debug!("User {username} is not a member of group {group_name}",); } @@ -70,7 +101,8 @@ pub async fn get_authorized_keys( // fetch all users in group let users = group.members(&appstate.pool).await?; for user in users { - add_user_keys_to_list(user); + add_user_keys_to_list(user.clone(), &mut ssh_keys); + add_user_ssh_keys_to_list(&appstate.pool, &user, &mut ssh_keys).await; } } } @@ -84,7 +116,8 @@ pub async fn get_authorized_keys( debug!("Fetching SSH keys for user {username}"); // fetch user if let Some(user) = User::find_by_username(&appstate.pool, username).await? { - add_user_keys_to_list(user); + add_user_keys_to_list(user.clone(), &mut ssh_keys); + add_user_ssh_keys_to_list(&appstate.pool, &user, &mut ssh_keys).await; } else { debug!("Specified user does not exist"); } @@ -95,3 +128,119 @@ pub async fn get_authorized_keys( // concatenate all keys into a response Ok(ssh_keys.join("\n")) } + +#[derive(Deserialize, Serialize, Debug)] +pub struct AddAuthenticationKeyData { + pub key: String, + pub name: String, + pub key_type: String, +} + +pub async fn add_authentication_key( + State(appstate): State, + session: SessionInfo, + Json(data): Json, +) -> Result<(), WebError> { + let user = session.user; + + info!( + "Adding an authentication key {data:?} to user {}", + user.email + ); + + if ![SSH_KEY_TYPE, GPG_KEY_TYPE].contains(&&data.key_type.as_str()) { + return Err(WebError::BadRequest( + "unsupported authentication key type".into(), + )); + } + + let public_key = data.key.parse::(); + + if data.key_type == "SSH" && public_key.is_err() { + return Err(WebError::BadRequest("invalid key format".into())); + } + + // TODO: verify GPG key + + let user_id = if let Some(user_id) = user.id { + user_id + } else { + return Err(WebError::BadRequest("invalid user".into())); + }; + + let existing_key = + AuthenticationKey::find_by_user(&appstate.pool, user_id, data.key.clone()).await?; + + if existing_key.is_some() { + return Err(WebError::BadRequest("key already exists".into())); + } + + let key = data.key.clone(); + + AuthenticationKey::new(user_id, data.key, data.name, data.key_type) + .save(&appstate.pool) + .await?; + + info!("Added authentication key {key} to user {}", user.email); + + Ok(()) +} + +pub async fn fetch_authentication_keys( + State(appstate): State, + session: SessionInfo, +) -> ApiResult { + let user = session.user; + + let user_id = if let Some(user_id) = user.id { + user_id + } else { + return Err(WebError::BadRequest("invalid user".into())); + }; + + let authentication_keys = + AuthenticationKey::fetch_user_authentication_keys(&appstate.pool, user_id).await?; + + Ok(ApiResponse { + json: json!(authentication_keys), + status: StatusCode::OK, + }) +} + +pub async fn delete_authentication_key( + State(appstate): State, + session: SessionInfo, + Path(id): Path, +) -> Result<(), WebError> { + let user = session.user; + + info!( + "Attempting to delete authentication key with ID of {id} by user {}", + user.email + ); + + let user_id = if let Some(user_id) = user.id { + user_id + } else { + return Err(WebError::BadRequest("invalid user".into())); + }; + + let exisiting_key = AuthenticationKey::find_by_id(&appstate.pool, id).await?; + + if let Some(authentication_key) = exisiting_key { + // Check whether key belongs to authenticated user + if authentication_key.user_id != user_id { + return Err(WebError::Forbidden("access denied".into())); + } + + let key = authentication_key.clone().key; + authentication_key.delete(&appstate.pool).await?; + info!("Authentication key {} deleted by {}", key, user.email); + } else { + return Err(WebError::ObjectNotFound( + "authentication key not found".into(), + )); + } + + Ok(()) +} diff --git a/src/lib.rs b/src/lib.rs index 51c902410..a7a572b0f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12,6 +12,9 @@ use axum::{ serve, Extension, Router, }; +use handlers::ssh_authorized_keys::{ + add_authentication_key, delete_authentication_key, fetch_authentication_keys, +}; use ipnetwork::IpNetwork; use secrecy::ExposeSecret; use tokio::{ @@ -158,6 +161,12 @@ pub fn build_webapp( .route("/health", get(health_check)) .route("/info", get(get_app_info)) .route("/ssh_authorized_keys", get(get_authorized_keys)) + .route("/authentication_keys", get(fetch_authentication_keys)) + .route("/authentication_keys", post(add_authentication_key)) + .route( + "/authentication_keys/:id", + delete(delete_authentication_key), + ) // /auth .route("/auth", post(authenticate)) .route("/auth/logout", post(logout)) diff --git a/web/src/i18n/en/index.ts b/web/src/i18n/en/index.ts index 6d6acb4e8..34de3fd20 100644 --- a/web/src/i18n/en/index.ts +++ b/web/src/i18n/en/index.ts @@ -20,6 +20,7 @@ const en: BaseTranslation = { saveChanges: 'Save changes', save: 'Save', RestoreDefault: 'Restore default', + delete: 'Delete', }, }, messages: { @@ -635,6 +636,32 @@ const en: BaseTranslation = { line2: 'management and provisioning.', }, }, + authenticationKeys: { + header: 'User Authentication Keys', + addKey: 'Add new Key', + keyCard: { + keyLabel: 'Key', + copyToClipboard: 'Copy to Clipboard', + downloadKey: 'Download Key File', + deleteKey: 'Delete Key', + keyDeleted: 'Authentication key deleted.', + confirmDelete: 'Are you sure you want to delete this key?', + }, + addModal: { + header: 'Add new Authentication Key', + keyNameLabel: 'Title', + keyLabel: 'Key', + keyNamePlaceholder: 'Key title', + keyPlaceholder: 'Begins with ‘ssh-rsa’, ‘ecdsa-sha2-nistp256’, ...', + addKey: 'Add key', + messages: { + keyAdded: 'Key added.', + keyExists: 'Key has already been added.', + unsupportedKeyFormat: 'Unsupported key format.', + genericError: 'Could not add the key. Please try again later.', + }, + }, + }, }, usersOverview: { pageTitle: 'Users', diff --git a/web/src/i18n/i18n-types.ts b/web/src/i18n/i18n-types.ts index c86f4eb14..9fe5b45d8 100644 --- a/web/src/i18n/i18n-types.ts +++ b/web/src/i18n/i18n-types.ts @@ -74,6 +74,10 @@ type RootTranslation = { * R​e​s​t​o​r​e​ ​d​e​f​a​u​l​t */ RestoreDefault: string + /** + * D​e​l​e​t​e + */ + 'delete': string } } messages: { @@ -1438,6 +1442,86 @@ type RootTranslation = { line2: string } } + authenticationKeys: { + /** + * U​s​e​r​ ​A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​K​e​y​s + */ + header: string + /** + * A​d​d​ ​n​e​w​ ​K​e​y + */ + addKey: string + keyCard: { + /** + * K​e​y + */ + keyLabel: string + /** + * C​o​p​y​ ​t​o​ ​C​l​i​p​b​o​a​r​d + */ + copyToClipboard: string + /** + * D​o​w​n​l​o​a​d​ ​K​e​y​ ​F​i​l​e + */ + downloadKey: string + /** + * D​e​l​e​t​e​ ​K​e​y + */ + deleteKey: string + /** + * A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​k​e​y​ ​d​e​l​e​t​e​d​. + */ + keyDeleted: string + /** + * A​r​e​ ​y​o​u​ ​s​u​r​e​ ​y​o​u​ ​w​a​n​t​ ​t​o​ ​d​e​l​e​t​e​ ​t​h​i​s​ ​k​e​y​? + */ + confirmDelete: string + } + addModal: { + /** + * A​d​d​ ​n​e​w​ ​A​u​t​h​e​n​t​i​c​a​t​i​o​n​ ​K​e​y + */ + header: string + /** + * T​i​t​l​e + */ + keyNameLabel: string + /** + * K​e​y + */ + keyLabel: string + /** + * K​e​y​ ​t​i​t​l​e + */ + keyNamePlaceholder: string + /** + * B​e​g​i​n​s​ ​w​i​t​h​ ​‘​s​s​h​-​r​s​a​’​,​ ​‘​e​c​d​s​a​-​s​h​a​2​-​n​i​s​t​p​2​5​6​’​,​ ​.​.​. + */ + keyPlaceholder: string + /** + * A​d​d​ ​k​e​y + */ + addKey: string + messages: { + /** + * K​e​y​ ​a​d​d​e​d​. + */ + keyAdded: string + /** + * K​e​y​ ​h​a​s​ ​a​l​r​e​a​d​y​ ​b​e​e​n​ ​a​d​d​e​d​. + */ + keyExists: string + /** + * U​n​s​u​p​p​o​r​t​e​d​ ​k​e​y​ ​f​o​r​m​a​t​. + */ + unsupportedKeyFormat: string + /** + * C​o​u​l​d​ ​n​o​t​ ​a​d​d​ ​t​h​e​ ​k​e​y​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​ ​l​a​t​e​r​. + */ + genericError: string + } + } + } } usersOverview: { /** @@ -3625,6 +3709,10 @@ export type TranslationFunctions = { * Restore default */ RestoreDefault: () => LocalizedString + /** + * Delete + */ + 'delete': () => LocalizedString } } messages: { @@ -4980,6 +5068,86 @@ export type TranslationFunctions = { line2: () => LocalizedString } } + authenticationKeys: { + /** + * User Authentication Keys + */ + header: () => LocalizedString + /** + * Add new Key + */ + addKey: () => LocalizedString + keyCard: { + /** + * Key + */ + keyLabel: () => LocalizedString + /** + * Copy to Clipboard + */ + copyToClipboard: () => LocalizedString + /** + * Download Key File + */ + downloadKey: () => LocalizedString + /** + * Delete Key + */ + deleteKey: () => LocalizedString + /** + * Authentication key deleted. + */ + keyDeleted: () => LocalizedString + /** + * Are you sure you want to delete this key? + */ + confirmDelete: () => LocalizedString + } + addModal: { + /** + * Add new Authentication Key + */ + header: () => LocalizedString + /** + * Title + */ + keyNameLabel: () => LocalizedString + /** + * Key + */ + keyLabel: () => LocalizedString + /** + * Key title + */ + keyNamePlaceholder: () => LocalizedString + /** + * Begins with ‘ssh-rsa’, ‘ecdsa-sha2-nistp256’, ... + */ + keyPlaceholder: () => LocalizedString + /** + * Add key + */ + addKey: () => LocalizedString + messages: { + /** + * Key added. + */ + keyAdded: () => LocalizedString + /** + * Key has already been added. + */ + keyExists: () => LocalizedString + /** + * Unsupported key format. + */ + unsupportedKeyFormat: () => LocalizedString + /** + * Could not add the key. Please try again later. + */ + genericError: () => LocalizedString + } + } + } } usersOverview: { /** diff --git a/web/src/i18n/pl/index.ts b/web/src/i18n/pl/index.ts index e3c3d3a55..dc54c9135 100644 --- a/web/src/i18n/pl/index.ts +++ b/web/src/i18n/pl/index.ts @@ -15,6 +15,7 @@ const pl: Translation = { save: 'Zapisz', saveChanges: 'Zapisz zmiany', RestoreDefault: 'Przywróć domyślne', + delete: 'Usuń', }, conditions: { and: 'I', @@ -620,6 +621,32 @@ Uwaga, konfiguracje tutaj podane, nie posiadają twojego klucza prywatnego. Musi line2: 'zarządzanie i provisioning.', }, }, + authenticationKeys: { + header: 'Klucze autoryzacyjne użytkownika', + addKey: 'Dodaj nowy klucz', + keyCard: { + keyLabel: 'Klucz', + copyToClipboard: 'Skopiuj do schowka', + downloadKey: 'Pobierz klucz', + deleteKey: 'Usuń klucz', + keyDeleted: 'Klucz usunięty.', + confirmDelete: 'Czy na pewno chcesz usunąć ten klucz?', + }, + addModal: { + header: 'Dodaj nowy klucz autoryzacyjny', + keyNameLabel: 'Tytuł', + keyLabel: 'Klucz', + keyNamePlaceholder: 'Tytuł klucza', + keyPlaceholder: 'Rozpoczyna się z ‘ssh-rsa’, ‘ecdsa-sha2-nistp256’, ...', + addKey: 'Dodaj klucz', + messages: { + keyAdded: 'Klucz dodany.', + keyExists: 'Klucz już został dodany.', + unsupportedKeyFormat: 'Format klucza nie jest wspierany.', + genericError: 'Nie udało się dodać klucza. Proszę spróbować ponownie później.', + }, + }, + }, }, usersOverview: { pageTitle: 'Użytkownicy', diff --git a/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyCard/AuthenticationKeyCard.tsx b/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyCard/AuthenticationKeyCard.tsx new file mode 100644 index 000000000..6542c820a --- /dev/null +++ b/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyCard/AuthenticationKeyCard.tsx @@ -0,0 +1,137 @@ +import './style.scss'; + +import classNames from 'classnames'; +import saveAs from 'file-saver'; +import { TargetAndTransition } from 'framer-motion'; +import { useMemo, useState } from 'react'; +import { shallow } from 'zustand/shallow'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +// import SvgIconCollapse from '../../../../../shared/components/svg/IconCollapse'; +// import SvgIconExpand from '../../../../../shared/components/svg/IconExpand'; +import { ColorsRGB } from '../../../../../shared/constants'; +import { Card } from '../../../../../shared/defguard-ui/components/Layout/Card/Card'; +import { DeviceAvatar } from '../../../../../shared/defguard-ui/components/Layout/DeviceAvatar/DeviceAvatar'; +import { EditButton } from '../../../../../shared/defguard-ui/components/Layout/EditButton/EditButton'; +import { EditButtonOption } from '../../../../../shared/defguard-ui/components/Layout/EditButton/EditButtonOption'; +import { EditButtonOptionStyleVariant } from '../../../../../shared/defguard-ui/components/Layout/EditButton/types'; +import { Label } from '../../../../../shared/defguard-ui/components/Layout/Label/Label'; +import { useClipboard } from '../../../../../shared/hooks/useClipboard'; +import { AuthenticationKey } from '../../../../../shared/types'; +import { useDeleteAuthenticationKeyModal } from '../../../shared/modals/DeleteAuthenticationKeyModal/useDeleteAuthenticationKeyModal'; + +interface Props { + authentication_key: AuthenticationKey; +} + +export const AuthenticationKeyCard = ({ authentication_key }: Props) => { + const [hovered, setHovered] = useState(false); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [expanded, setExpanded] = useState(false); + + const { LL } = useI18nContext(); + // const toaster = useToaster(); + // const queryClient = useQueryClient(); + const { writeToClipboard } = useClipboard(); + + const cn = useMemo( + () => + classNames('device-card', { + expanded, + }), + [expanded], + ); + + const openModal = useDeleteAuthenticationKeyModal((state) => state.open, shallow); + + const getContainerAnimate = useMemo((): TargetAndTransition => { + const res: TargetAndTransition = { + borderColor: ColorsRGB.White, + }; + if (expanded || hovered) { + res.borderColor = ColorsRGB.GrayBorder; + } + return res; + }, [expanded, hovered]); + + return ( + setHovered(true)} + onMouseOut={() => setHovered(false)} + > +
+
+ +

{authentication_key.name}

+
+
+
+ +

+ {authentication_key.key} +

+
+
+
+ +
+ {/* {orderedLocations.map((n) => ( + + ))} */} +
+
+ + { + writeToClipboard(authentication_key.key); + }} + /> + { + const blob = new Blob([authentication_key.key], { + type: 'text/plain;charset=utf-8', + }); + saveAs( + blob, + `${authentication_key.name.replace(' ', '_').toLocaleLowerCase()}.txt`, + ); + }} + /> + { + openModal({ visible: true, authenticationKey: authentication_key }); + }} + /> + + {/* setExpanded((state) => !state)} + /> */} +
+
+ ); +}; + +// type ExpandButtonProps = { +// expanded: boolean; +// onClick: () => void; +// }; + +// const ExpandButton = ({ expanded, onClick }: ExpandButtonProps) => { +// return ( +// +// ); +// }; diff --git a/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyCard/style.scss b/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyCard/style.scss new file mode 100644 index 000000000..c89e42d24 --- /dev/null +++ b/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyCard/style.scss @@ -0,0 +1,166 @@ +@use '@scssutils' as *; + +.card { + &.device-card { + overflow: hidden; + display: block; + position: relative; + display: grid; + grid-template-rows: auto 0; + grid-template-columns: 1fr; + grid-template-areas: + 'main' + 'locations'; + border: 1px solid var(--white); + + h3 { + @include small-header; + @include text-overflow-dots; + user-select: none; + } + + header { + display: grid; + align-items: center; + justify-items: start; + column-gap: 10px; + margin-bottom: 18px; + max-width: 100%; + } + + .main-info { + & > header { + grid-template-rows: 40px; + grid-template-columns: 40px 1fr 55px; + + & > .avatar-icon { + grid-row: 1; + grid-column: 1 / 2; + height: 100%; + width: 100%; + + svg { + width: 30px; + height: 30px; + } + } + + & > h3 { + grid-row: 1; + grid-column: 2 / 3; + } + } + } + + .location { + max-width: 100%; + overflow: hidden; + & > header { + grid-template-rows: 40px; + grid-template-columns: 22px 1fr; + + & > svg { + grid-row: 1; + grid-column: 1 / 2; + } + + & > .info-wrapper { + grid-row: 1; + grid-column: 2 / 3; + display: grid; + grid-template-rows: 40px; + grid-template-columns: auto 83px; + column-gap: 10px; + align-items: center; + justify-items: start; + } + } + } + + .main-info, + .location { + box-sizing: border-box; + padding: 20px 25px; + } + + & > .main-info { + grid-area: main; + } + + .section-content { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + align-items: flex-start; + + label { + display: block; + margin-bottom: 8px; + } + + p { + @include typography-legacy(15px, 18px, medium); + + &.no-data { + color: var(--text-main); + font-size: 11px; + text-align: left; + } + } + } + + & > .locations { + grid-area: locations; + display: grid; + grid-template-rows: auto; + grid-template-columns: 1fr; + grid-auto-flow: row; + + & > .location { + display: block; + box-sizing: border-box; + padding: 19px 24px; + border: 1px solid transparent; + border-top-color: var(--gray-lighter); + + &:last-child { + border-bottom-right-radius: 15px; + border-bottom-left-radius: 15px; + } + } + } + + & > .card-controls { + display: flex; + flex-flow: row nowrap; + position: absolute; + top: 10px; + right: 15px; + + .device-card-expand { + border: 0 solid transparent; + background-color: transparent; + cursor: pointer; + + svg { + width: 22px !important; + height: 22px; + } + } + } + + &.expanded { + grid-template-rows: auto auto; + } + } +} + +.authentication-key-value { + text-overflow: ellipsis; + overflow: hidden; + // display: block; + // max-width: 438px; + max-width: 380px; + // height: 1.2em; + white-space: nowrap; +} \ No newline at end of file diff --git a/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyList/AuthenticationKeyList.tsx b/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyList/AuthenticationKeyList.tsx new file mode 100644 index 000000000..99505bbc8 --- /dev/null +++ b/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyList/AuthenticationKeyList.tsx @@ -0,0 +1,28 @@ +import './style.scss'; + +import { useQuery } from '@tanstack/react-query'; + +import useApi from '../../../../../shared/hooks/useApi'; +import { QueryKeys } from '../../../../../shared/queries'; +import { AuthenticationKeyCard } from '../AuthenticationKeyCard/AuthenticationKeyCard'; + +export const AuthenticationKeyList = () => { + const { + user: { fetchAuthenticationKeys }, + } = useApi(); + + const { data: authenticationKeys } = useQuery({ + queryFn: fetchAuthenticationKeys, + queryKey: [QueryKeys.FETCH_AUTHENTICATION_KEYS], + refetchOnMount: true, + refetchOnWindowFocus: false, + }); + + return ( +
+ {authenticationKeys?.map((item) => { + return ; + })} +
+ ); +}; diff --git a/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyList/style.scss b/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyList/style.scss new file mode 100644 index 000000000..d7de82660 --- /dev/null +++ b/web/src/pages/users/UserProfile/UserAuthenticationKeys/AuthenticationKeyList/style.scss @@ -0,0 +1,17 @@ +@use '@scssutils' as *; + +.authentication-key-list { + margin-bottom: 1rem; + display: flex; + flex-flow: column nowrap; + row-gap: 1rem; + + @include media-breakpoint-up(md) { + row-gap: 1.5rem; + margin-bottom: 1.5rem; + } + + & > .device-card { + width: 100%; + } +} \ No newline at end of file diff --git a/web/src/pages/users/UserProfile/UserAuthenticationKeys/UserAuthenticationKeys.tsx b/web/src/pages/users/UserProfile/UserAuthenticationKeys/UserAuthenticationKeys.tsx new file mode 100644 index 000000000..0444da4e0 --- /dev/null +++ b/web/src/pages/users/UserProfile/UserAuthenticationKeys/UserAuthenticationKeys.tsx @@ -0,0 +1,40 @@ +import './style.scss'; + +import { useI18nContext } from '../../../../i18n/i18n-react'; +import { useAuthStore } from '../../../../shared/hooks/store/useAuthStore'; +import { useModalStore } from '../../../../shared/hooks/store/useModalStore'; +import { useUserProfileStore } from '../../../../shared/hooks/store/useUserProfileStore'; +import { AddComponentBox } from '../../shared/components/AddComponentBox/AddComponentBox'; +import { AddAuthenticationKeyModal } from '../../shared/modals/AddAuthenticationKeyModal/AddAuthenticationKeyModal'; +import { DeleteAuthenticationKeyModal } from '../../shared/modals/DeleteAuthenticationKeyModal/DeleteAuthenticationKeyModal'; +import { AuthenticationKeyList } from './AuthenticationKeyList/AuthenticationKeyList'; + +export const UserAuthenticationKeys = () => { + const { LL } = useI18nContext(); + const user = useUserProfileStore((state) => state.userProfile?.user); + const isAdmin = useAuthStore((state) => state.isAdmin); + const setAddAuthenticationKeyModal = useModalStore( + (state) => state.setAddAuthenticationKeyModal, + ); + + return ( +
+
+

{LL.userPage.authenticationKeys.header()}

+
+ + {user && isAdmin && ( + { + if (user) { + setAddAuthenticationKeyModal({ visible: true, user: user }); + } + }} + text={LL.userPage.authenticationKeys.addKey()} + /> + )} + + +
+ ); +}; diff --git a/web/src/pages/users/UserProfile/UserAuthenticationKeys/style.scss b/web/src/pages/users/UserProfile/UserAuthenticationKeys/style.scss new file mode 100644 index 000000000..007442690 --- /dev/null +++ b/web/src/pages/users/UserProfile/UserAuthenticationKeys/style.scss @@ -0,0 +1 @@ +@use '@scssutils' as *; diff --git a/web/src/pages/users/UserProfile/UserProfile.tsx b/web/src/pages/users/UserProfile/UserProfile.tsx index 2f0d48172..ef6074e5e 100644 --- a/web/src/pages/users/UserProfile/UserProfile.tsx +++ b/web/src/pages/users/UserProfile/UserProfile.tsx @@ -27,6 +27,7 @@ import useApi from '../../../shared/hooks/useApi'; import { useToaster } from '../../../shared/hooks/useToaster'; import { QueryKeys } from '../../../shared/queries'; import { ProfileDetails } from './ProfileDetails/ProfileDetails'; +import { UserAuthenticationKeys } from './UserAuthenticationKeys/UserAuthenticationKeys'; import { UserAuthInfo } from './UserAuthInfo/UserAuthInfo'; import { UserDevices } from './UserDevices/UserDevices'; import { UserWallets } from './UserWallets/UserWallets'; @@ -94,10 +95,11 @@ export const UserProfile = () => {
+
- {appSettings?.worker_enabled && } +
diff --git a/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/AddAuthenticationKeyModal.tsx b/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/AddAuthenticationKeyModal.tsx new file mode 100644 index 000000000..3557e7f7e --- /dev/null +++ b/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/AddAuthenticationKeyModal.tsx @@ -0,0 +1,220 @@ +import './style.scss'; + +import { yupResolver } from '@hookform/resolvers/yup'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import { SubmitHandler, useForm, useWatch } from 'react-hook-form'; +import * as yup from 'yup'; +import { shallow } from 'zustand/shallow'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import IconAuthenticationKey from '../../../../../shared/components/svg/IconAuthenticationKey'; +import SvgIconCheckmark from '../../../../../shared/components/svg/IconCheckmark'; +import { ColorsRGB } from '../../../../../shared/constants'; +import { FormInput } from '../../../../../shared/defguard-ui/components/Form/FormInput/FormInput'; +import { Button } from '../../../../../shared/defguard-ui/components/Layout/Button/Button'; +import { + ButtonSize, + ButtonStyleVariant, +} from '../../../../../shared/defguard-ui/components/Layout/Button/types'; +import { Label } from '../../../../../shared/defguard-ui/components/Layout/Label/Label'; +import { ModalWithTitle } from '../../../../../shared/defguard-ui/components/Layout/modals/ModalWithTitle/ModalWithTitle'; +import { useTheme } from '../../../../../shared/defguard-ui/hooks/theme/useTheme'; +import { useModalStore } from '../../../../../shared/hooks/store/useModalStore'; +import useApi from '../../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../../shared/hooks/useToaster'; +import { QueryKeys } from '../../../../../shared/queries'; +import { AuthenticationKeyType } from '../../../../../shared/types'; +import { AuthenticationKeyFormTextField } from './AuthenticationKeyFormTextField'; + +interface FormValues { + name: string; + key_type: string; + key: string; +} + +export const AddAuthenticationKeyModal = () => { + const { LL } = useI18nContext(); + const toaster = useToaster(); + + const [{ visible: isOpen }, setModalState] = useModalStore( + (state) => [state.addAuthenticationKeyModal, state.setAddAuthenticationKeyModal], + shallow, + ); + + const { + user: { addAuthenticationKey }, + } = useApi(); + + const queryClient = useQueryClient(); + const { colors } = useTheme(); + + const { + mutate: addAuthenticationKeyMutation, + isLoading: isAddAuthenticationKeyLoading, + } = useMutation(addAuthenticationKey, { + onSuccess: () => { + setModalState({ visible: false }); + reset(); + queryClient.invalidateQueries([QueryKeys.FETCH_AUTHENTICATION_KEYS]); + toaster.success(LL.userPage.authenticationKeys.addModal.messages.keyAdded()); + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onError: (error: any) => { + console.error(error); + + if (error.code === 'ERR_BAD_REQUEST') { + if (error.response.data.msg === 'invalid key format') { + setError('key', { + message: + LL.userPage.authenticationKeys.addModal.messages.unsupportedKeyFormat(), + type: 'value', + }); + return; + } + + if (error.response.data.msg === 'key already exists') { + setError('key', { + message: LL.userPage.authenticationKeys.addModal.messages.keyExists(), + type: 'value', + }); + return; + } + } + + toaster.error(LL.userPage.authenticationKeys.addModal.messages.genericError()); + }, + }); + + const schema = useMemo( + () => + yup + .object() + .shape({ + name: yup.string().required(), + key: yup.string().required(), + key_type: yup.string().required(), + }) + .required(), + [], + ); + + const submitHandler: SubmitHandler = async (values) => { + const data = { + key_type: values.key_type, + name: values.name.trim(), + key: values.key.trim(), + } as FormValues; + addAuthenticationKeyMutation(data); + }; + + const { handleSubmit, control, reset, setValue, setError } = useForm({ + defaultValues: { + name: '', + key_type: AuthenticationKeyType.SSH, + key: '', + }, + resolver: yupResolver(schema), + mode: 'all', + }); + + const keyTypeValue = useWatch({ control, name: 'key_type' }); + + return ( + setModalState({ visible: visibility })} + onClose={() => { + reset(); + }} + backdrop + > +
+
+ {/* TODO: loop */} +
+
+ + + + +
+
+ +
+
+ ); +}; diff --git a/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/AuthenticationKeyFormTextField.tsx b/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/AuthenticationKeyFormTextField.tsx new file mode 100644 index 000000000..9978a2320 --- /dev/null +++ b/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/AuthenticationKeyFormTextField.tsx @@ -0,0 +1,72 @@ +import { isUndefined } from 'lodash-es'; +import { useMemo } from 'react'; +import { FieldValues, useController, UseControllerProps } from 'react-hook-form'; + +import { InputFloatingErrors } from '../../../../../shared/defguard-ui/components/Layout/Input/types'; +import { AuthenticationKeyTextField, TextareaProps } from './AuthenticationKeyTextField'; + +interface Props + extends Omit { + controller: UseControllerProps; + floatingErrors?: { + title?: string; + errorMessages?: string[]; + }; +} +export const AuthenticationKeyFormTextField = ({ + controller, + floatingErrors, + disabled, + ...rest +}: Props) => { + const { + field, + fieldState: { isDirty, isTouched, error }, + formState: { isSubmitted }, + } = useController(controller); + + const isInvalid = useMemo(() => { + if (disabled) return false; + if ( + (!isUndefined(error) && (isDirty || isTouched)) || + (!isUndefined(error) && isSubmitted) + ) { + return true; + } + return false; + }, [error, isDirty, isSubmitted, isTouched, disabled]); + + const floatingErrorsData = useMemo((): InputFloatingErrors | undefined => { + if (floatingErrors && floatingErrors.title && error && error.types && isInvalid) { + let errors: string[] = []; + for (const val of Object.values(error.types)) { + if (typeof val === 'string') { + errors.push(val); + } + if (Array.isArray(val)) { + errors = [...errors, ...val]; + } + } + if (floatingErrors.errorMessages && floatingErrors.errorMessages.length) { + errors = [...errors, ...floatingErrors.errorMessages]; + } + return { + title: floatingErrors.title, + errorMessages: errors, + }; + } + return undefined; + }, [error, floatingErrors, isInvalid]); + + return ( + + ); +}; diff --git a/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/AuthenticationKeyTextField.tsx b/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/AuthenticationKeyTextField.tsx new file mode 100644 index 000000000..8c1056d81 --- /dev/null +++ b/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/AuthenticationKeyTextField.tsx @@ -0,0 +1,190 @@ +import './style.scss'; + +import { + arrow, + autoUpdate, + flip, + FloatingPortal, + offset, + useFloating, +} from '@floating-ui/react'; +import classNames from 'classnames'; +import { AnimatePresence, HTMLMotionProps, motion } from 'framer-motion'; +import { isUndefined } from 'lodash-es'; +import React, { ReactNode, useId, useMemo, useRef, useState } from 'react'; + +import SvgIconWarning from '../../../../../shared/components/svg/IconWarning'; +import { FloatingArrow } from '../../../../../shared/defguard-ui/components/Layout/FloatingArrow/FloatingArrow'; +import { FloatingBox } from '../../../../../shared/defguard-ui/components/Layout/FloatingBox/FloatingBox'; +import { InputFloatingErrors } from '../../../../../shared/defguard-ui/components/Layout/Input/types'; + +export interface TextareaProps extends HTMLMotionProps<'textarea'> { + labelExtras?: ReactNode; + required?: boolean; + invalid?: boolean; + label?: string | ReactNode; + disableOuterLabelColon?: boolean; + errorMessage?: string; + floatingErrors?: InputFloatingErrors; + disposable?: boolean; + disposeHandler?: (v?: unknown) => void; +} + +export const AuthenticationKeyTextField = React.forwardRef< + HTMLTextAreaElement, + TextareaProps +>( + ( + { + invalid, + value, + disposable, + placeholder, + disabled = false, + errorMessage, + label, + disableOuterLabelColon, + floatingErrors, + labelExtras, + ...props + }, + forwardedRef, + ) => { + const innerInputRef = useRef(null); + + const inputId = useId(); + + const [floatingErrorsOpen, setFloatingErrorsOpen] = useState(false); + + const floatingErrorsArrow = useRef(null); + + const { + refs: { setFloating }, + placement, + middlewareData, + floatingStyles, + } = useFloating({ + open: floatingErrorsOpen, + onOpenChange: setFloatingErrorsOpen, + placement: 'bottom-end', + middleware: [ + offset(10), + flip(), + arrow({ + element: floatingErrorsArrow, + }), + ], + whileElementsMounted: (refElement, floatingElement, updateFunc) => + autoUpdate(refElement, floatingElement, updateFunc), + }); + + const getInputContainerClassName = useMemo(() => { + return classNames('authentication-key-textarea', { + invalid, + disabled, + disposable: disposable && !disabled, + }); + }, [disabled, disposable, invalid]); + + return ( +
+ {(!isUndefined(label) || !isUndefined(labelExtras)) && ( +
+ {!isUndefined(label) && ( + { + if (innerInputRef) { + innerInputRef.current?.focus(); + } + }} + > + {label} + {!disableOuterLabelColon && ':'} + + )} + {!isUndefined(labelExtras) && labelExtras} +
+ )} + + { + innerInputRef.current = r; + if (typeof forwardedRef === 'function') { + forwardedRef(r); + } else { + if (forwardedRef) { + forwardedRef.current = r; + } + } + }} + {...props} + value={value} + placeholder={placeholder} + id={inputId} + disabled={disabled} + /> + + + {invalid && errorMessage && !disabled && ( + + {errorMessage} + + )} + + + + {floatingErrorsOpen && floatingErrors && ( + +

{floatingErrors.title}

+
+ {floatingErrors.errorMessages.map((errorMessage) => ( +
+ +

{errorMessage}

+
+ ))} +
+ +
+ )} +
+
+
+ ); + }, +); diff --git a/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/style.scss b/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/style.scss new file mode 100644 index 000000000..b1cc90096 --- /dev/null +++ b/web/src/pages/users/shared/modals/AddAuthenticationKeyModal/style.scss @@ -0,0 +1,97 @@ +@use '../../../../../shared/scss/helpers' as *; + +#add-authentication-key-modal { + + & > .add-authentication-key-content { + padding: 30px; + } +} + +.add-authentication-key-content { + padding-left: 30px; + padding-right: 30px; +} + +.authentication-keys-container { + display: flex; + column-gap: 10px; + margin-bottom: 30px; +} + +.add-authentication-key-buttons-container { + display: flex; + column-gap: 10px; + margin-top: 40px; + + & > button { + width: 130px; + } +} + +.authentication-key-textarea-container { + display: flex; + height: 150px; + margin-bottom: 5px; +} + +.authentication-key-textarea { + max-height: 150px; + background-color: var(--surface-frame-bg); + border-radius: 10px; + width: 100%; + padding: 10px 20px; + border: 1px solid var(--border-primary); + + &.invalid { + border-color: var(--border-alert); + } + + &.disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &.disposable { + & > .icon-container { + cursor: pointer; + } + } +} + +.errors { + display: flex; + flex-flow: column; + row-gap: 10px; + + & > .error { + display: flex; + flex-flow: row nowrap; + overflow: hidden; + column-gap: 5px; + align-items: center; + justify-content: flex-start; + + svg { + transform: scale(66.67%); + } + + & > p { + white-space: nowrap; + color: var(--text-body-primary); + + @include typography(app-modal-2); + } + } +} + +.error-message { + position: absolute; + @include typography(app-wizard-1); + @include text-overflow-dots; + white-space: nowrap; + max-width: 100%; + overflow: hidden; + bottom: 5px; + + color: var(--text-alert); +} diff --git a/web/src/pages/users/shared/modals/DeleteAuthenticationKeyModal/DeleteAuthenticationKeyModal.tsx b/web/src/pages/users/shared/modals/DeleteAuthenticationKeyModal/DeleteAuthenticationKeyModal.tsx new file mode 100644 index 000000000..8e4214fb9 --- /dev/null +++ b/web/src/pages/users/shared/modals/DeleteAuthenticationKeyModal/DeleteAuthenticationKeyModal.tsx @@ -0,0 +1,59 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { shallow } from 'zustand/shallow'; + +import { useI18nContext } from '../../../../../i18n/i18n-react'; +import { ConfirmModal } from '../../../../../shared/defguard-ui/components/Layout/modals/ConfirmModal/ConfirmModal'; +import { ConfirmModalType } from '../../../../../shared/defguard-ui/components/Layout/modals/ConfirmModal/types'; +import useApi from '../../../../../shared/hooks/useApi'; +import { useToaster } from '../../../../../shared/hooks/useToaster'; +import { QueryKeys } from '../../../../../shared/queries'; +import { useDeleteAuthenticationKeyModal } from './useDeleteAuthenticationKeyModal'; + +export const DeleteAuthenticationKeyModal = () => { + const { + user: { deleteAuthenticationKey }, + } = useApi(); + + const queryClient = useQueryClient(); + const { LL } = useI18nContext(); + + const [setModalState, authenticationKey, visible] = useDeleteAuthenticationKeyModal( + (state) => [state.setState, state.authenticationKey, state.visible], + shallow, + ); + + const toaster = useToaster(); + + const { mutate, isLoading } = useMutation( + (authenticationKeyId: number) => deleteAuthenticationKey(authenticationKeyId), + { + onSuccess: () => { + toaster.success(LL.userPage.authenticationKeys.keyCard.keyDeleted()); + queryClient.invalidateQueries([QueryKeys.FETCH_AUTHENTICATION_KEYS]); + setModalState({ visible: false, authenticationKey: undefined }); + }, + onError: (err) => { + toaster.error(LL.messages.error()); + setModalState({ visible: false, authenticationKey: undefined }); + console.error(err); + }, + }, + ); + + return ( + setModalState({ visible: v })} + type={ConfirmModalType.WARNING} + title={LL.userPage.authenticationKeys.keyCard.confirmDelete()} + cancelText={LL.form.cancel()} + submitText={LL.common.controls.delete()} + onSubmit={() => { + if (authenticationKey) { + mutate(authenticationKey.id); + } + }} + loading={isLoading} + /> + ); +}; diff --git a/web/src/pages/users/shared/modals/DeleteAuthenticationKeyModal/useDeleteAuthenticationKeyModal.ts b/web/src/pages/users/shared/modals/DeleteAuthenticationKeyModal/useDeleteAuthenticationKeyModal.ts new file mode 100644 index 000000000..3a5412441 --- /dev/null +++ b/web/src/pages/users/shared/modals/DeleteAuthenticationKeyModal/useDeleteAuthenticationKeyModal.ts @@ -0,0 +1,31 @@ +import { createWithEqualityFn } from 'zustand/traditional'; + +import { AuthenticationKey } from '../../../../../shared/types'; + +const defaultValues: StoreValues = { + visible: false, + authenticationKey: undefined, +}; + +export const useDeleteAuthenticationKeyModal = createWithEqualityFn( + (set) => ({ + ...defaultValues, + setState: (values) => set((old) => ({ ...old, ...values })), + open: (values) => set({ ...defaultValues, ...values }), + close: () => set({ visible: false }), + }), + Object.is, +); + +type StoreValues = { + visible: boolean; + authenticationKey?: AuthenticationKey; +}; + +type StoreMethods = { + setState: (values: Partial) => void; + open: (values: Partial) => void; + close: () => void; +}; + +type Store = StoreValues & StoreMethods; diff --git a/web/src/shared/components/svg/IconAuthenticationKey.tsx b/web/src/shared/components/svg/IconAuthenticationKey.tsx new file mode 100644 index 000000000..fddc9adea --- /dev/null +++ b/web/src/shared/components/svg/IconAuthenticationKey.tsx @@ -0,0 +1,11 @@ +import { SVGProps } from 'react'; + +const IconAuthenticationKey = (props: SVGProps) => ( + + + + + +); + +export default IconAuthenticationKey; diff --git a/web/src/shared/hooks/store/useModalStore.ts b/web/src/shared/hooks/store/useModalStore.ts index 48eca8968..a1ad65ff1 100644 --- a/web/src/shared/hooks/store/useModalStore.ts +++ b/web/src/shared/hooks/store/useModalStore.ts @@ -42,6 +42,16 @@ export const useModalStore = createWithEqualityFn( user: undefined, }, // DO NOT EXTEND THIS STORE + addAuthenticationKeyModal: { + visible: false, + user: undefined, + }, + // DO NOT EXTEND THIS STORE + deleteAuthenticationKeyModal: { + visible: false, + authenticationKey: undefined, + }, + // DO NOT EXTEND THIS STORE deleteUserModal: { visible: false, user: undefined, @@ -150,6 +160,16 @@ export const useModalStore = createWithEqualityFn( set((state) => ({ enableOpenidClientModal: { ...state.enableOpenidClientModal, ...data }, })), + // DO NOT EXTEND THIS STORE + setAddAuthenticationKeyModal: (data) => + set((state) => ({ + addAuthenticationKeyModal: { ...state.addAuthenticationKeyModal, ...data }, + })), + // DO NOT EXTEND THIS STORE + setDeleteAuthenticationKeyModal: (data) => + set((state) => ({ + deleteAuthenticationKeyModal: { ...state.deleteAuthenticationKeyModal, ...data }, + })), }), Object.is, ); diff --git a/web/src/shared/hooks/useApi.tsx b/web/src/shared/hooks/useApi.tsx index 53227383d..8a07ffda5 100644 --- a/web/src/shared/hooks/useApi.tsx +++ b/web/src/shared/hooks/useApi.tsx @@ -383,6 +383,16 @@ const useApi = (props?: HookProps): ApiHook => { const startDesktopActivation: ApiHook['user']['startDesktopActivation'] = (data) => client.post(`/user/${data.username}/start_desktop`, data).then(unpackRequest); + const fetchAuthenticationKeys: ApiHook['user']['fetchAuthenticationKeys'] = () => + client.get(`/authentication_keys`).then(unpackRequest); + + const addAuthenticationKey: ApiHook['user']['addAuthenticationKey'] = (data) => + client.post(`/authentication_keys`, data).then(unpackRequest); + + const deleteAuthenticationKey: ApiHook['user']['deleteAuthenticationKey'] = ( + id: number, + ) => client.delete(`/authentication_keys/${id}`).then(unpackRequest); + const patchSettings: ApiHook['settings']['patchSettings'] = (data) => client.patch('/settings', data).then(unpackRequest); @@ -439,6 +449,9 @@ const useApi = (props?: HookProps): ApiHook => { removeFromGroup, startEnrollment, startDesktopActivation, + fetchAuthenticationKeys, + addAuthenticationKey, + deleteAuthenticationKey, }, device: { addDevice: addDevice, diff --git a/web/src/shared/queries.ts b/web/src/shared/queries.ts index 24cf7fd64..088a2e8c8 100644 --- a/web/src/shared/queries.ts +++ b/web/src/shared/queries.ts @@ -24,4 +24,5 @@ export const QueryKeys = { FETCH_NETWORK_GATEWAYS_STATUS: 'FETCH_NETWORK_GATEWAYS_STATUS', FETCH_SUPPORT_DATA: 'FETCH_SUPPORT_DATA', FETCH_LOGS: 'FETCH_LOGS', + FETCH_AUTHENTICATION_KEYS: 'FETCH_AUTHENTICATION_KEYS', }; diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index cbc797c28..4120382df 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -20,6 +20,11 @@ export enum UserMFAMethod { WEB3 = 'Web3', } +export enum AuthenticationKeyType { + SSH = 'SSH', + GPG = 'GPG', +} + export type User = { id: number; username: string; @@ -173,6 +178,16 @@ export interface ProvisionKeyModal { user?: User; } +export interface AddAuthenticationKeyModal { + visible: boolean; + user?: User; +} + +export interface DeleteAuthenticationKeyModal { + visible: boolean; + authenticationKey?: AuthenticationKey; +} + export interface DeleteOpenidClientModal { visible: boolean; client?: OpenidClient; @@ -352,6 +367,20 @@ export type AuthCodeRequsest = { code: number; }; +export interface AddAuthenticationKeyRequest { + name: string; + key: string; + key_type: string; +} + +export interface AuthenticationKey { + id: number; + user_id: number; + name: string; + key: string; + key_type: string; +} + export interface ApiHook { getAppInfo: () => Promise; changePasswordSelf: (data: ChangePasswordSelfRequest) => Promise; @@ -380,6 +409,9 @@ export interface ApiHook { startDesktopActivation: ( data: StartEnrollmentRequest, ) => Promise; + fetchAuthenticationKeys: () => Promise; + addAuthenticationKey: (data: AddAuthenticationKeyRequest) => EmptyApiResponse; + deleteAuthenticationKey: (id: number) => EmptyApiResponse; }; device: { addDevice: (device: AddDeviceRequest) => Promise; @@ -631,6 +663,8 @@ export interface UseModalStore { changePasswordModal: ChangePasswordModal; changeWalletModal: ChangeWalletModal; provisionKeyModal: ProvisionKeyModal; + addAuthenticationKeyModal: AddAuthenticationKeyModal; + deleteAuthenticationKeyModal: DeleteAuthenticationKeyModal; // DO NOT EXTEND THIS STORE webhookModal: WebhookModal; addOpenidClientModal: StandardModalState; @@ -659,6 +693,9 @@ export interface UseModalStore { setDeleteOpenidClientModal: ModalSetter; // DO NOT EXTEND THIS STORE setEnableOpenidClientModal: ModalSetter; + // DO NOT EXTEND THIS STORE + setAddAuthenticationKeyModal: ModalSetter; + setDeleteAuthenticationKeyModal: ModalSetter; } export interface UseOpenIDStore {