diff --git a/src/api/auth-identities.tsx b/src/api/auth-identities.tsx index 7bf72a5b39..cae72cccd3 100644 --- a/src/api/auth-identities.tsx +++ b/src/api/auth-identities.tsx @@ -1,6 +1,6 @@ import { handleResponse, handleSettledResult } from "util/helpers"; import { LxdApiResponse } from "types/apiResponse"; -import { LxdIdentity } from "types/permissions"; +import { LxdIdentity, TlsIdentityTokenDetail } from "types/permissions"; export const fetchIdentities = (): Promise => { return new Promise((resolve, reject) => { @@ -75,3 +75,22 @@ export const deleteIdentities = (identities: LxdIdentity[]): Promise => { .catch(reject); }); }; + +export const createFineGrainedTlsIdentity = ( + clientName: string, +): Promise => { + return new Promise((resolve, reject) => { + fetch(`/1.0/auth/identities/tls`, { + method: "POST", + body: JSON.stringify({ + name: clientName, + token: true, + }), + }) + .then(handleResponse) + .then((data: LxdApiResponse) => + resolve(data.metadata), + ) + .catch(reject); + }); +}; diff --git a/src/pages/permissions/CreateIdentityModal.tsx b/src/pages/permissions/CreateIdentityModal.tsx new file mode 100644 index 0000000000..cc55dee264 --- /dev/null +++ b/src/pages/permissions/CreateIdentityModal.tsx @@ -0,0 +1,145 @@ +import { FC, useState } from "react"; +import { + ActionButton, + Button, + Icon, + Input, + Modal, + Notification, +} from "@canonical/react-components"; +import { useFormik } from "formik"; +import { createFineGrainedTlsIdentity } from "api/auth-identities"; +import { base64EncodeObject } from "util/helpers"; +import { useQueryClient } from "@tanstack/react-query"; +import { queryKeys } from "util/queryKeys"; + +interface Props { + onClose: () => void; +} + +const CreateIdentityModal: FC = ({ onClose }) => { + const [token, setToken] = useState(null); + const [error, setError] = useState(false); + const [copied, setCopied] = useState(false); + const queryClient = useQueryClient(); + + const formik = useFormik({ + initialValues: { + identityName: "", + }, + onSubmit: (values) => { + setError(false); + createFineGrainedTlsIdentity(values.identityName) + .then((response) => { + const encodedToken = base64EncodeObject(response); + setToken(encodedToken); + + void queryClient.invalidateQueries({ + queryKey: [queryKeys.identities], + }); + }) + .catch(() => { + setError(true); + }); + }, + }); + + const handleCopy = async () => { + if (token) { + try { + await navigator.clipboard.writeText(token); + setCopied(true); + + setTimeout(() => { + setCopied(false); + }, 5000); + } catch (error) { + console.error(error); + } + } + }; + + return ( + + {token && ( + <> + + + + + )} + {!token && ( + void formik.submitForm()} + disabled={ + formik.values.identityName.length === 0 || formik.isSubmitting + } + loading={formik.isSubmitting} + > + Generate token + + )} + + } + > + {error && ( + + )} + {!token && ( + + )} + {token && ( + <> +

The token below can be used to log in as the new identity.

+ + + +
+ {token} +
+ + )} +
+ ); +}; + +export default CreateIdentityModal; diff --git a/src/pages/permissions/CreateTlsIdentityBtn.tsx b/src/pages/permissions/CreateTlsIdentityBtn.tsx new file mode 100644 index 0000000000..9d4ae0ae98 --- /dev/null +++ b/src/pages/permissions/CreateTlsIdentityBtn.tsx @@ -0,0 +1,31 @@ +import { Button, Icon } from "@canonical/react-components"; +import { useSmallScreen } from "context/useSmallScreen"; +import { FC } from "react"; +import usePortal from "react-useportal"; +import CreateIdentityModal from "./CreateIdentityModal"; + +const CreateTlsIdentityBtn: FC = () => { + const isSmallScreen = useSmallScreen(); + const { openPortal, closePortal, isOpen, Portal } = usePortal(); + + return ( + <> + {isOpen && ( + + + + )} + + + ); +}; + +export default CreateTlsIdentityBtn; diff --git a/src/pages/permissions/PermissionIdentities.tsx b/src/pages/permissions/PermissionIdentities.tsx index f9a8f9763f..a8d6b8f762 100644 --- a/src/pages/permissions/PermissionIdentities.tsx +++ b/src/pages/permissions/PermissionIdentities.tsx @@ -35,6 +35,7 @@ import DeleteIdentityBtn from "./actions/DeleteIdentityBtn"; import { useSupportedFeatures } from "context/useSupportedFeatures"; import { isUnrestricted } from "util/helpers"; import IdentityResource from "components/IdentityResource"; +import CreateTlsIdentityBtn from "./CreateTlsIdentityBtn"; const PermissionIdentities: FC = () => { const notify = useNotify(); @@ -269,6 +270,9 @@ const PermissionIdentities: FC = () => { /> )} + + + } > diff --git a/src/sass/_instance_detail_page.scss b/src/sass/_instance_detail_page.scss index a4877ba921..30132405fa 100644 --- a/src/sass/_instance_detail_page.scss +++ b/src/sass/_instance_detail_page.scss @@ -7,13 +7,3 @@ margin: 0 $sph--large; } } - -.create-instance-from-snapshot-modal, -.duplicate-instances-modal, -.create-image-from-instance-modal { - .p-modal__dialog { - @include large { - width: 35rem; - } - } -} diff --git a/src/sass/_permission_confirm_modal.scss b/src/sass/_permission_confirm_modal.scss index 6cb0ba6e32..0031eed7ad 100644 --- a/src/sass/_permission_confirm_modal.scss +++ b/src/sass/_permission_confirm_modal.scss @@ -48,3 +48,12 @@ margin-top: $spv--large; } } + +.token-code-block { + background-color: rgb(0 0 0 / 5%); + margin-bottom: 1.5rem; + + code { + background-color: transparent; + } +} diff --git a/src/sass/styles.scss b/src/sass/styles.scss index 522c297c0f..d335aa24d6 100644 --- a/src/sass/styles.scss +++ b/src/sass/styles.scss @@ -354,6 +354,17 @@ body { cursor: pointer; } +.create-instance-from-snapshot-modal, +.duplicate-instances-modal, +.create-image-from-instance-modal, +.create-tls-identity { + .p-modal__dialog { + @include large { + width: 35rem; + } + } +} + .host-path-device-modal { .p-modal__dialog { @include large { diff --git a/src/types/permissions.d.ts b/src/types/permissions.d.ts index fb250d68c1..ed992896ad 100644 --- a/src/types/permissions.d.ts +++ b/src/types/permissions.d.ts @@ -1,8 +1,13 @@ export interface LxdIdentity { id: string; // fingerprint for tls and email for oidc - type: string; + type: + | "Client certificate" + | "Client certificate (pending)" + | "Client certificate (unrestricted)" + | "OIDC client"; name: string; authentication_method: "tls" | "oidc"; + tls_certificate: string; groups?: string[] | null; effective_groups?: string[]; effective_permissions?: LxdPermission[]; @@ -30,3 +35,12 @@ export interface IdpGroup { name: string; groups: string[]; // these should be names of lxd groups } + +export interface TlsIdentityTokenDetail { + client_name: string; + addresses: string[]; + expires_at: string; + fingerprint: string; + type: "Client certificate (pending)" | "Client certificate"; + secret: string; +} diff --git a/src/util/helpers.tsx b/src/util/helpers.tsx index b30670da93..b396a58e3d 100644 --- a/src/util/helpers.tsx +++ b/src/util/helpers.tsx @@ -340,3 +340,14 @@ export const getDefaultStoragePool = (profile: LxdProfile) => { export const isUnrestricted = (identity: LxdIdentity) => { return identity.type === "Client certificate (unrestricted)"; }; + +export const isFineGrainedTls = (identity: LxdIdentity) => { + return ["Client certificate (pending)", "Client certificate"].includes( + identity.type, + ); +}; + +export const base64EncodeObject = (data: object) => { + const jsonString = JSON.stringify(data); + return btoa(jsonString); +};