diff --git a/package-lock.json b/package-lock.json index 53f5cea35..20372c5a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4027,13 +4027,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/@fingerprintjs/fingerprintjs": { - "version": "3.4.2", - "license": "MIT", - "dependencies": { - "tslib": "^2.4.1" - } - }, "node_modules/@github/webauthn-json": { "version": "2.1.1", "license": "MIT", @@ -5836,6 +5829,7 @@ "resolved": "https://registry.npmjs.org/@pm2/pm2-version-check/-/pm2-version-check-1.0.4.tgz", "integrity": "sha512-SXsM27SGH3yTWKc2fKR4SYNxsmnvuBQ9dd6QHtEWmiZ/VqaOYPAIlS8+vMcn27YLtAEBGvNRSh3TPNvtjZgfqA==", "dev": true, + "license": "MIT", "dependencies": { "debug": "^4.3.1" } @@ -24781,6 +24775,13 @@ "user-agent-data-types": "^0.4.2" } }, + "packages/web-core/node_modules/@fingerprintjs/fingerprintjs": { + "version": "3.4.2", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.1" + } + }, "packages/web-js": { "name": "@corbado/web-js", "version": "2.12.1", diff --git a/packages/react/src/components/ui/buttons/Button.tsx b/packages/react/src/components/ui/buttons/Button.tsx index 2a8338b6b..8819d52be 100644 --- a/packages/react/src/components/ui/buttons/Button.tsx +++ b/packages/react/src/components/ui/buttons/Button.tsx @@ -16,7 +16,12 @@ export const Button = forwardRef( ref={ref} {...rest} > - {isLoading ? : children} + {children} + {isLoading && ( +
+ +
+ )} ); }, diff --git a/packages/react/src/components/ui/buttons/CopyButton.tsx b/packages/react/src/components/ui/buttons/CopyButton.tsx new file mode 100644 index 000000000..f23b6a360 --- /dev/null +++ b/packages/react/src/components/ui/buttons/CopyButton.tsx @@ -0,0 +1,60 @@ +import type { FC } from 'react'; +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { CopyIcon } from '../icons/CopyIcon'; +import { TickIcon } from '../icons/TickIcon'; +import { Text } from '../typography'; + +interface Props { + text: string | undefined; +} + +const RESET_TIMEOUT = 4 * 1000; + +const CopyButton: FC = ({ text }) => { + const { t } = useTranslation(); + const [copied, setCopied] = useState(false); + + useEffect(() => { + const reset = () => { + setCopied(false); + }; + + if (copied) { + const timeout = setTimeout(reset, RESET_TIMEOUT); + + return () => clearTimeout(timeout); + } + + return; + }, [copied]); + + const onClick = async () => { + if (text) { + await navigator.clipboard.writeText(text); + setCopied(true); + } + }; + + if (copied) { + return ( +
+ +
+ {t('user-details.copied')} +
+
+ ); + } + + return ( + void onClick()} + /> + ); +}; + +export default CopyButton; diff --git a/packages/react/src/components/ui/buttons/Link.tsx b/packages/react/src/components/ui/buttons/Link.tsx index fd03d66d9..ab8ed42bc 100644 --- a/packages/react/src/components/ui/buttons/Link.tsx +++ b/packages/react/src/components/ui/buttons/Link.tsx @@ -4,12 +4,14 @@ import React from 'react'; export interface LinkProps { href: string; className?: string; + onClick?: (e: React.MouseEvent) => void; } -export const Link: FC> = ({ href, className, children }) => { +export const Link: FC> = ({ href, className, children, onClick }) => { return ( {children} diff --git a/packages/react/src/components/ui/icons/AddIcon.tsx b/packages/react/src/components/ui/icons/AddIcon.tsx index a33e97ae3..caab1773f 100644 --- a/packages/react/src/components/ui/icons/AddIcon.tsx +++ b/packages/react/src/components/ui/icons/AddIcon.tsx @@ -1,15 +1,23 @@ import addSrc from '@corbado/shared-ui/assets/add.svg'; import type { FC } from 'react'; -import { memo, useRef } from 'react'; +import { useRef } from 'react'; import React from 'react'; import { useIconWithTheme } from '../../../hooks/useIconWithTheme'; import type { IconProps } from './Icon'; import { Icon } from './Icon'; -export const AddIcon: FC = memo(props => { +export interface AddIconProps extends IconProps { + color?: 'primary' | 'secondary'; +} + +export const AddIcon: FC = ({ color, ...props }) => { const svgRef = useRef(null); - const { logoSVG } = useIconWithTheme(svgRef, addSrc, '--cb-button-text-primary-color'); + const { logoSVG } = useIconWithTheme( + svgRef, + addSrc, + color === 'secondary' ? '--cb-text-primary-color' : '--cb-button-text-primary-color', + ); return ( = memo(props => { {...props} /> ); -}); +}; diff --git a/packages/react/src/components/ui/icons/AlertIcon.tsx b/packages/react/src/components/ui/icons/AlertIcon.tsx new file mode 100644 index 000000000..b39d7c225 --- /dev/null +++ b/packages/react/src/components/ui/icons/AlertIcon.tsx @@ -0,0 +1,38 @@ +import alertSrc from '@corbado/shared-ui/assets/alert.svg'; +import type { FC } from 'react'; +import { useRef } from 'react'; +import React from 'react'; + +import { useIconWithTheme } from '../../../hooks/useIconWithTheme'; +import type { IconProps } from './Icon'; +import { Icon } from './Icon'; + +export interface AlertIconProps extends IconProps { + color?: 'primary' | 'secondary' | 'error'; +} + +export const AlertIcon: FC = ({ color, ...props }) => { + const svgRef = useRef(null); + + const getColor = () => { + switch (color) { + case 'secondary': + return '--cb-text-primary-color'; + case 'error': + return '--cb-error-text-color'; + default: + return '--cb-button-text-primary'; + } + }; + + const { logoSVG } = useIconWithTheme(svgRef, alertSrc, getColor()); + + return ( + + ); +}; diff --git a/packages/react/src/components/ui/icons/ChangeIcon.tsx b/packages/react/src/components/ui/icons/ChangeIcon.tsx new file mode 100644 index 000000000..167d492c9 --- /dev/null +++ b/packages/react/src/components/ui/icons/ChangeIcon.tsx @@ -0,0 +1,30 @@ +import changeIconSrc from '@corbado/shared-ui/assets/change.svg'; +import type { FC } from 'react'; +import { useRef } from 'react'; +import React from 'react'; + +import { useIconWithTheme } from '../../../hooks/useIconWithTheme'; +import type { IconProps } from './Icon'; +import { Icon } from './Icon'; + +export interface ChangeIconProps extends IconProps { + color?: 'primary' | 'secondary'; +} + +export const ChangeIcon: FC = ({ color, ...props }) => { + const svgRef = useRef(null); + const { logoSVG } = useIconWithTheme( + svgRef, + changeIconSrc, + color === 'secondary' ? '--cb-text-secondary-color' : '--cb-text-primary-color', + ); + + return ( + + ); +}; diff --git a/packages/react/src/components/ui/icons/CopyIcon.tsx b/packages/react/src/components/ui/icons/CopyIcon.tsx new file mode 100644 index 000000000..a9d7cd14e --- /dev/null +++ b/packages/react/src/components/ui/icons/CopyIcon.tsx @@ -0,0 +1,30 @@ +import copyIconSrc from '@corbado/shared-ui/assets/copy.svg'; +import type { FC } from 'react'; +import { useRef } from 'react'; +import React from 'react'; + +import { useIconWithTheme } from '../../../hooks/useIconWithTheme'; +import type { IconProps } from './Icon'; +import { Icon } from './Icon'; + +export interface CopyIconProps extends IconProps { + color?: 'primary' | 'secondary'; +} + +export const CopyIcon: FC = ({ color, ...props }) => { + const svgRef = useRef(null); + const { logoSVG } = useIconWithTheme( + svgRef, + copyIconSrc, + color === 'secondary' ? '--cb-text-secondary-color' : '--cb-text-primary-color', + ); + + return ( + + ); +}; diff --git a/packages/react/src/components/ui/icons/PendingIcon.tsx b/packages/react/src/components/ui/icons/PendingIcon.tsx new file mode 100644 index 000000000..ceca4fc55 --- /dev/null +++ b/packages/react/src/components/ui/icons/PendingIcon.tsx @@ -0,0 +1,22 @@ +import pendingIconSrc from '@corbado/shared-ui/assets/pending.svg'; +import type { FC } from 'react'; +import { memo, useRef } from 'react'; +import React from 'react'; + +import { useIconWithTheme } from '../../../hooks/useIconWithTheme'; +import type { IconProps } from './Icon'; +import { Icon } from './Icon'; + +export const PendingIcon: FC = memo(props => { + const svgRef = useRef(null); + const { logoSVG } = useIconWithTheme(svgRef, pendingIconSrc, '--cb-passkey-list-badge-color'); + + return ( + + ); +}); diff --git a/packages/react/src/components/ui/icons/PrimaryIcon.tsx b/packages/react/src/components/ui/icons/PrimaryIcon.tsx new file mode 100644 index 000000000..80a4aaebd --- /dev/null +++ b/packages/react/src/components/ui/icons/PrimaryIcon.tsx @@ -0,0 +1,22 @@ +import primaryIconSrc from '@corbado/shared-ui/assets/primary.svg'; +import type { FC } from 'react'; +import { memo, useRef } from 'react'; +import React from 'react'; + +import { useIconWithTheme } from '../../../hooks/useIconWithTheme'; +import type { IconProps } from './Icon'; +import { Icon } from './Icon'; + +export const PrimaryIcon: FC = memo(props => { + const svgRef = useRef(null); + const { logoSVG } = useIconWithTheme(svgRef, primaryIconSrc, '--cb-passkey-list-badge-color'); + + return ( + + ); +}); diff --git a/packages/react/src/components/ui/icons/TickIcon.tsx b/packages/react/src/components/ui/icons/TickIcon.tsx new file mode 100644 index 000000000..d748bfdae --- /dev/null +++ b/packages/react/src/components/ui/icons/TickIcon.tsx @@ -0,0 +1,22 @@ +import syncIconSrc from '@corbado/shared-ui/assets/tick.svg'; +import type { FC } from 'react'; +import { memo, useRef } from 'react'; +import React from 'react'; + +import { ColorType, useIconWithTheme } from '../../../hooks/useIconWithTheme'; +import type { IconProps } from './Icon'; +import { Icon } from './Icon'; + +export const TickIcon: FC = memo(props => { + const svgRef = useRef(null); + const { logoSVG } = useIconWithTheme(svgRef, syncIconSrc, '--cb-success-color', ColorType.Stroke); + + return ( + + ); +}); diff --git a/packages/react/src/components/ui/icons/VerifiedIcon.tsx b/packages/react/src/components/ui/icons/VerifiedIcon.tsx new file mode 100644 index 000000000..33c3e2a48 --- /dev/null +++ b/packages/react/src/components/ui/icons/VerifiedIcon.tsx @@ -0,0 +1,22 @@ +import verifiedIconSrc from '@corbado/shared-ui/assets/verified.svg'; +import type { FC } from 'react'; +import { memo, useRef } from 'react'; +import React from 'react'; + +import { useIconWithTheme } from '../../../hooks/useIconWithTheme'; +import type { IconProps } from './Icon'; +import { Icon } from './Icon'; + +export const VerifiedIcon: FC = memo(props => { + const svgRef = useRef(null); + const { logoSVG } = useIconWithTheme(svgRef, verifiedIconSrc, '--cb-passkey-list-badge-color'); + + return ( + + ); +}); diff --git a/packages/react/src/components/user-details/Alert.tsx b/packages/react/src/components/user-details/Alert.tsx new file mode 100644 index 000000000..73e4d5da6 --- /dev/null +++ b/packages/react/src/components/user-details/Alert.tsx @@ -0,0 +1,24 @@ +import type { FC } from 'react'; +import React from 'react'; + +import { Text } from '../ui'; +import { AlertIcon } from '../ui/icons/AlertIcon'; + +interface Props { + text: string; + variant?: 'error' | 'info'; +} + +const Alert: FC = ({ text, variant = 'error' }) => { + return ( +
+ + {text} +
+ ); +}; + +export default Alert; diff --git a/packages/react/src/components/user-details/DropdownMenu.tsx b/packages/react/src/components/user-details/DropdownMenu.tsx new file mode 100644 index 000000000..725600f77 --- /dev/null +++ b/packages/react/src/components/user-details/DropdownMenu.tsx @@ -0,0 +1,66 @@ +import type { FC } from 'react'; +import React, { useEffect, useRef } from 'react'; + +import { Text } from '../ui'; +interface Props { + items: string[]; + onItemClick: (item: string) => void; + getItemClassName: (item: string) => string | undefined; +} + +const DropdownMenu: FC = ({ items, onItemClick, getItemClassName }) => { + const [visible, setVisible] = React.useState(false); + const menuRef = useRef(null); + + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setVisible(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( + <> +
+
setVisible(!visible)} + > + ⋯ +
+ {visible && ( +
+ {items.map((item, index) => ( +
{ + onItemClick(item); + setVisible(false); + }} + className='cb-dropdown-menu-item' + > + + {item} + +
+ ))} +
+ )} +
+ + ); +}; + +export default DropdownMenu; diff --git a/packages/react/src/components/user-details/UserDetailsCard.tsx b/packages/react/src/components/user-details/UserDetailsCard.tsx new file mode 100644 index 000000000..2043b9220 --- /dev/null +++ b/packages/react/src/components/user-details/UserDetailsCard.tsx @@ -0,0 +1,19 @@ +import type { FC, PropsWithChildren } from 'react'; +import React from 'react'; + +import { Text } from '../ui'; + +interface Props extends PropsWithChildren { + header: string; +} + +const UserDetailsCard: FC = ({ header, children }) => { + return ( +
+ {header} +
{children}
+
+ ); +}; + +export default UserDetailsCard; diff --git a/packages/react/src/contexts/CorbadoSessionContext.tsx b/packages/react/src/contexts/CorbadoSessionContext.tsx index 7b3647b6b..c9daa7d26 100644 --- a/packages/react/src/contexts/CorbadoSessionContext.tsx +++ b/packages/react/src/contexts/CorbadoSessionContext.tsx @@ -1,4 +1,4 @@ -import type { CorbadoUser, PassKeyList, SessionUser } from '@corbado/types'; +import type { CorbadoUser, LoginIdentifierType, PassKeyList, SessionUser, UserDetailsConfig } from '@corbado/types'; import type { CorbadoApp, CorbadoError, NonRecoverableError, PasskeyDeleteError } from '@corbado/web-core'; import { createContext } from 'react'; import type { Result } from 'ts-results'; @@ -18,6 +18,15 @@ export interface CorbadoSessionContextProps { getPasskeys: (abortController?: AbortController) => Promise>; deletePasskey: (id: string) => Promise>; getFullUser: (abortController?: AbortController) => Promise>; + getUserDetailsConfig: (abortController?: AbortController) => Promise>; + updateFullName: (fullName: string) => Promise>; + updateUsername: (identifierId: string, username: string) => Promise>; + createIdentifier: (identifierType: LoginIdentifierType, value: string) => Promise>; + deleteIdentifier: (identifierId: string) => Promise>; + verifyIdentifierStart: (identifierId: string) => Promise>; + verifyIdentifierFinish: (identifierId: string, code: string) => Promise>; + makePrimary: (identifierId: string, identifierType: LoginIdentifierType) => Promise>; + deleteUser: () => Promise>; globalError: NonRecoverableError | undefined; } @@ -33,6 +42,15 @@ export const initialContext: CorbadoSessionContextProps = { getPasskeys: missingImplementation, deletePasskey: missingImplementation, getFullUser: missingImplementation, + getUserDetailsConfig: missingImplementation, + updateFullName: missingImplementation, + updateUsername: missingImplementation, + createIdentifier: missingImplementation, + deleteIdentifier: missingImplementation, + verifyIdentifierStart: missingImplementation, + verifyIdentifierFinish: missingImplementation, + makePrimary: missingImplementation, + deleteUser: missingImplementation, }; export const CorbadoSessionContext = createContext(initialContext); diff --git a/packages/react/src/contexts/CorbadoSessionProvider.tsx b/packages/react/src/contexts/CorbadoSessionProvider.tsx index 48ca99058..cc2cb3d5e 100644 --- a/packages/react/src/contexts/CorbadoSessionProvider.tsx +++ b/packages/react/src/contexts/CorbadoSessionProvider.tsx @@ -1,4 +1,4 @@ -import type { CorbadoAppParams, SessionUser } from '@corbado/types'; +import type { CorbadoAppParams, LoginIdentifierType, SessionUser } from '@corbado/types'; import type { NonRecoverableError } from '@corbado/web-core'; import { CorbadoApp } from '@corbado/web-core'; import type { FC, PropsWithChildren } from 'react'; @@ -86,6 +86,66 @@ export const CorbadoSessionProvider: FC = ({ [corbadoApp], ); + const getUserDetailsConfig = useCallback( + (abortController?: AbortController) => { + return corbadoApp?.sessionService.getUserDetailsConfig(abortController ?? new AbortController()); + }, + [corbadoApp], + ); + + const updateFullName = useCallback( + (fullName: string) => { + return corbadoApp.sessionService.updateFullName(fullName); + }, + [corbadoApp], + ); + + const updateUsername = useCallback( + (identifierId: string, username: string) => { + return corbadoApp.sessionService.updateUsername(identifierId, username); + }, + [corbadoApp], + ); + + const createIdentifier = useCallback( + (identifierType: LoginIdentifierType, value: string) => { + return corbadoApp.sessionService.createIdentifier(identifierType, value); + }, + [corbadoApp], + ); + + const deleteIdentifier = useCallback( + (identifierId: string) => { + return corbadoApp.sessionService.deleteIdentifier(identifierId); + }, + [corbadoApp], + ); + + const verifyIdentifierStart = useCallback( + (identifierId: string) => { + return corbadoApp.sessionService.verifyIdentifierStart(identifierId); + }, + [corbadoApp], + ); + + const verifyIdentifierFinish = useCallback( + (identifierId: string, code: string) => { + return corbadoApp.sessionService.verifyIdentifierFinish(identifierId, code); + }, + [corbadoApp], + ); + + const makePrimary = useCallback( + (identifierId: string, identifierType: LoginIdentifierType) => { + return corbadoApp.sessionService.makePrimary(identifierId, identifierType); + }, + [corbadoApp], + ); + + const deleteUser = useCallback(() => { + return corbadoApp.sessionService.deleteUser(); + }, [corbadoApp]); + return ( = ({ isAuthenticated, appendPasskey, getFullUser, + getUserDetailsConfig, + updateFullName, + updateUsername, + createIdentifier, + deleteIdentifier, + verifyIdentifierStart, + verifyIdentifierFinish, + makePrimary, + deleteUser, getPasskeys, deletePasskey, logout, diff --git a/packages/react/src/contexts/CorbadoUserDetailsContext.tsx b/packages/react/src/contexts/CorbadoUserDetailsContext.tsx new file mode 100644 index 000000000..8bce90a17 --- /dev/null +++ b/packages/react/src/contexts/CorbadoUserDetailsContext.tsx @@ -0,0 +1,54 @@ +import type { Identifier, SocialAccount } from '@corbado/types'; +import { createContext } from 'react'; + +const missingImplementation = (): never => { + throw new Error('Please make sure that your components are wrapped inside '); +}; + +interface ProcessedUser { + name: string; + username: string; + emails: Identifier[]; + phoneNumbers: Identifier[]; + socialAccounts: SocialAccount[]; +} + +export interface CorbadoUserDetailsContextProps { + loading: boolean; + processUser: ProcessedUser | undefined; + name: string | undefined; + setName: (name: string) => void; + username: Identifier | undefined; + setUsername: (username: Identifier | undefined) => void; + emails: Identifier[] | undefined; + setEmails: (email: Identifier[] | undefined) => void; + phones: Identifier[] | undefined; + usernameEnabled: boolean; + emailEnabled: boolean; + phoneEnabled: boolean; + fullNameRequired: boolean; + setPhones: (phone: Identifier[] | undefined) => void; + getCurrentUser: (abortController?: AbortController) => Promise; + getConfig: (abortController?: AbortController) => Promise; +} + +export const initialContext: CorbadoUserDetailsContextProps = { + loading: false, + processUser: undefined, + name: undefined, + setName: missingImplementation, + username: undefined, + setUsername: missingImplementation, + emails: undefined, + setEmails: missingImplementation, + phones: undefined, + usernameEnabled: false, + emailEnabled: false, + phoneEnabled: false, + fullNameRequired: false, + setPhones: missingImplementation, + getCurrentUser: missingImplementation, + getConfig: missingImplementation, +}; + +export const CorbadoUserDetailsContext = createContext(initialContext); diff --git a/packages/react/src/contexts/CorbadoUserDetailsProvider.tsx b/packages/react/src/contexts/CorbadoUserDetailsProvider.tsx new file mode 100644 index 000000000..1c2cb9def --- /dev/null +++ b/packages/react/src/contexts/CorbadoUserDetailsProvider.tsx @@ -0,0 +1,141 @@ +import { LoginIdentifierType } from '@corbado/shared-ui'; +import type { CorbadoUser, Identifier } from '@corbado/types'; +import type { FC, PropsWithChildren } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { useCorbado } from '../hooks/useCorbado'; +import { CorbadoUserDetailsContext } from './CorbadoUserDetailsContext'; + +export const CorbadoUserDetailsProvider: FC = ({ children }) => { + const { corbadoApp, isAuthenticated, getFullUser, getUserDetailsConfig } = useCorbado(); + + const [loading, setLoading] = useState(false); + const [currentUser, setCurrentUser] = useState(); + const [name, setName] = useState(); + const [username, setUsername] = useState(); + const [emails, setEmails] = useState(); + const [phones, setPhones] = useState(); + + const [usernameEnabled, setUsernameEnabled] = useState(false); + const [emailEnabled, setEmailEnabled] = useState(false); + const [phoneEnabled, setPhoneEnabled] = useState(false); + + const [fullNameRequired, setFullNameRequired] = useState(false); + + useEffect(() => { + if (!isAuthenticated) { + return; + } + + const abortController = new AbortController(); + void getCurrentUser(abortController); + void getConfig(abortController); + + return () => { + abortController.abort(); + }; + }, [isAuthenticated]); + + const processUser = useMemo(() => { + if (!currentUser) { + return { + name: '', + username: '', + emails: [], + phoneNumbers: [], + socialAccounts: [], + }; + } + + return { + name: currentUser.fullName, + username: currentUser.identifiers.find(id => id.type === 'username')?.value || '', + emails: currentUser.identifiers.filter(id => id.type === 'email'), + phoneNumbers: currentUser.identifiers.filter(id => id.type === 'phone'), + socialAccounts: currentUser.socialAccounts, + }; + }, [currentUser]); + + const getCurrentUser = useCallback( + async (abortController?: AbortController) => { + setLoading(true); + const result = await getFullUser(abortController); + if (result.err && result.val.ignore) { + return; + } + + if (!result || result?.err) { + throw new Error(result?.val.name); + } + + setCurrentUser(result.val); + setName(result.val.fullName || ''); + const usernameIdentifier = result.val.identifiers.find( + identifier => identifier.type == LoginIdentifierType.Username, + ); + setUsername(usernameIdentifier); + const emails = result.val.identifiers.filter(identifier => identifier.type == LoginIdentifierType.Email); + setEmails(emails); + const phones = result.val.identifiers.filter(identifier => identifier.type == LoginIdentifierType.Phone); + setPhones(phones); + setLoading(false); + }, + [corbadoApp], + ); + + const getConfig = useCallback( + async (abortController?: AbortController) => { + setLoading(true); + const result = await getUserDetailsConfig(abortController); + if (result.err && result.val.ignore) { + return; + } + + if (!result || result?.err) { + throw new Error(result?.val.name); + } + + setFullNameRequired(result.val.fullNameRequired); + for (const identifierConfig of result.val.identifiers) { + switch (identifierConfig.type) { + case LoginIdentifierType.Username: + setUsernameEnabled(true); + break; + case LoginIdentifierType.Email: + setEmailEnabled(true); + break; + case LoginIdentifierType.Phone: + setPhoneEnabled(true); + break; + } + } + setLoading(false); + }, + [corbadoApp], + ); + + return ( + + {children} + + ); +}; diff --git a/packages/react/src/hooks/useCorbadoUserDetails.ts b/packages/react/src/hooks/useCorbadoUserDetails.ts new file mode 100644 index 000000000..fec40753b --- /dev/null +++ b/packages/react/src/hooks/useCorbadoUserDetails.ts @@ -0,0 +1,14 @@ +import { useContext } from 'react'; + +import type { CorbadoUserDetailsContextProps } from '../contexts/CorbadoUserDetailsContext'; +import { CorbadoUserDetailsContext } from '../contexts/CorbadoUserDetailsContext'; + +export const useCorbadoUserDetails = (): CorbadoUserDetailsContextProps => { + const corbadoUserDetails = useContext(CorbadoUserDetailsContext); + + if (!corbadoUserDetails) { + throw new Error('Please make sure that your components are wrapped inside '); + } + + return corbadoUserDetails; +}; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a5d656d68..00bc648c2 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -7,14 +7,14 @@ import CorbadoAuth from './screens/core/CorbadoAuth'; import Login from './screens/core/Login'; import PasskeyList from './screens/core/PasskeyList'; import SignUp from './screens/core/SignUp'; -import { User } from './screens/core/User'; +import UserDetails from './screens/core/UserDetails'; export { CorbadoProvider, useCorbado, CorbadoAuth, + UserDetails, PasskeyList, - User, SignUp, Login, CorbadoThemes, diff --git a/packages/react/src/screens/core/PasskeyList.tsx b/packages/react/src/screens/core/PasskeyList.tsx index d4381a525..995df232e 100644 --- a/packages/react/src/screens/core/PasskeyList.tsx +++ b/packages/react/src/screens/core/PasskeyList.tsx @@ -53,9 +53,7 @@ const PasskeyList: FC = () => { return (
-
- {t('title')} -
+ {t('title')} {passkeys?.passkeys.map(passkey => ( { - const { corbadoApp, isAuthenticated, globalError, getFullUser } = useCorbado(); - const { t } = useTranslation('translation', { keyPrefix: 'user' }); - const [currentUser, setCurrentUser] = useState(); - const [loading, setLoading] = useState(false); - - useEffect(() => { - if (!isAuthenticated) { - return; - } - - const abortController = new AbortController(); - void getCurrentUser(abortController); - - return () => { - abortController.abort(); - }; - }, [isAuthenticated]); - - const headerText = useMemo(() => t('header'), [t]); - const nameFieldLabel = useMemo(() => t('name'), [t]); - const usernameFieldLabel = useMemo(() => t('username'), [t]); - const emailFieldLabel = useMemo(() => t('email'), [t]); - const phoneFieldLabel = useMemo(() => t('phone'), [t]); - const socialFieldLabel = useMemo(() => t('social'), [t]); - const verifiedText = useMemo(() => t('verified'), [t]); - const unverifiedText = useMemo(() => t('unverified'), [t]); - const processUser = useMemo((): ProcessedUser => { - if (!currentUser) { - return { - name: '', - emails: [], - phoneNumbers: [], - socialAccounts: [], - }; - } - - return { - name: currentUser.fullName, - username: currentUser.identifiers.find(id => id.type === 'username')?.value, - emails: currentUser.identifiers.filter(id => id.type === 'email'), - phoneNumbers: currentUser.identifiers.filter(id => id.type === 'phone'), - socialAccounts: currentUser.socialAccounts, - }; - }, [currentUser]); - - const getCurrentUser = useCallback( - async (abortController: AbortController) => { - setLoading(true); - const result = await getFullUser(abortController); - if (result.err && result.val.ignore) { - return; - } - - if (!result || result?.err) { - throw new Error(result?.val.name); - } - - setCurrentUser(result.val); - setLoading(false); - }, - [corbadoApp], - ); - - if (!isAuthenticated) { - return
{t('warning_notLoggedIn')}
; - } - - if (loading) { - return ; - } - - return ( - -
- - {headerText} - - {processUser.name && ( - - )} - {processUser.username && ( - - )} -
- {processUser.emails.map((email, i) => ( -
-
- -
-
- - {email.status === 'verified' ? verifiedText : unverifiedText} - -
-
- ))} -
-
- {processUser.phoneNumbers.map((phone, i) => ( -
-
- -
-
- - {phone.status === 'verified' ? verifiedText : unverifiedText} - -
-
- ))} -
-
- {processUser.socialAccounts.map((social, i) => ( -
-
- -
-
- - {t(`providers.${social.providerType}`) || social.providerType} - -
-
- ))} -
-
-
- ); -}; diff --git a/packages/react/src/screens/core/UserDetails.tsx b/packages/react/src/screens/core/UserDetails.tsx new file mode 100644 index 000000000..b1ec874a4 --- /dev/null +++ b/packages/react/src/screens/core/UserDetails.tsx @@ -0,0 +1,78 @@ +import type { FC } from 'react'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useCorbado } from '../..'; +import { LoadingSpinner, PasskeyListErrorBoundary, Text } from '../../components'; +import { CorbadoUserDetailsProvider } from '../../contexts/CorbadoUserDetailsProvider'; +import EmailsEdit from '../user-details/EmailsEdit'; +import NameEdit from '../user-details/NameEdit'; +import PhonesEdit from '../user-details/PhonesEdit'; +import UserDelete from '../user-details/UserDelete'; +import UsernameEdit from '../user-details/UsernameEdit'; + +export const UserDetails: FC = () => { + const { globalError, isAuthenticated, loading } = useCorbado(); + const { t } = useTranslation('translation'); + + const title = useMemo(() => t('user-details.title'), [t]); + + // const headerSocial = useMemo(() => t('user-details.social'), [t]); + + if (!isAuthenticated) { + return
{t('user-details.warning_notLoggedIn')}
; + } + + if (loading) { + return ; + } + + return ( + + +
+ {title} + + + + + + {/*
+ {processUser.socialAccounts.map((social, i) => ( +
+
+
+ +
+
+ + {t(`providers.${social.providerType}`) || social.providerType} + +
+
+
+ ))} +
*/} + + +
+
+
+ ); +}; + +export default UserDetails; diff --git a/packages/react/src/screens/user-details/EmailsEdit.tsx b/packages/react/src/screens/user-details/EmailsEdit.tsx new file mode 100644 index 000000000..ea13097b9 --- /dev/null +++ b/packages/react/src/screens/user-details/EmailsEdit.tsx @@ -0,0 +1,260 @@ +import { LoginIdentifierType } from '@corbado/shared-ui'; +import type { Identifier } from '@corbado/types'; +import { t } from 'i18next'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { Button, InputField, Text } from '../../components'; +import { AddIcon } from '../../components/ui/icons/AddIcon'; +import { PendingIcon } from '../../components/ui/icons/PendingIcon'; +import { PrimaryIcon } from '../../components/ui/icons/PrimaryIcon'; +import { VerifiedIcon } from '../../components/ui/icons/VerifiedIcon'; +import DropdownMenu from '../../components/user-details/DropdownMenu'; +import UserDetailsCard from '../../components/user-details/UserDetailsCard'; +import { useCorbado } from '../../hooks/useCorbado'; +import { useCorbadoUserDetails } from '../../hooks/useCorbadoUserDetails'; +import { getErrorCode } from '../../util'; +import IdentifierDeleteDialog from './IdentifierDeleteDialog'; +import IdentifierVerifyDialog from './IdentifierVerifyDialog'; + +const EmailsEdit = () => { + const { createIdentifier, makePrimary } = useCorbado(); + const { emails = [], getCurrentUser, emailEnabled } = useCorbadoUserDetails(); + + const initialEmails = useRef(); + + const [loading, setLoading] = useState(false); + const [verifyingEmails, setVerifyingEmails] = useState([]); + const [addingEmail, setAddingEmail] = useState(false); + const [deletingEmail, setDeletingEmail] = useState(); + const [newEmail, setNewEmail] = useState(''); + const [errorMessage, setErrorMessage] = useState(); + + const headerEmail = useMemo(() => t('user-details.email'), [t]); + + const badgePrimary = useMemo(() => t('user-details.primary'), [t]); + const badgeVerified = useMemo(() => t('user-details.verified'), [t]); + const badgePending = useMemo(() => t('user-details.pending'), [t]); + + const warningEmail = useMemo(() => t('user-details.warning_invalid_email'), [t]); + + const buttonSave = useMemo(() => t('user-details.save'), [t]); + const buttonCancel = useMemo(() => t('user-details.cancel'), [t]); + const buttonCopy = useMemo(() => t('user-details.copy'), [t]); + const buttonAddEmail = useMemo(() => t('user-details.add_email'), [t]); + const buttonPrimary = useMemo(() => t('user-details.make_primary'), [t]); + const buttonVerify = useMemo(() => t('user-details.verify'), [t]); + const buttonRemove = useMemo(() => t('user-details.remove'), [t]); + + useEffect(() => { + if (initialEmails.current === undefined && emails.length > 0) { + initialEmails.current = emails; + + return; + } + + emails.forEach(email => { + if (initialEmails.current?.every(e => e.id !== email.id)) { + setVerifyingEmails(prev => [...prev, email]); + } + }); + + initialEmails.current = undefined; + }, [emails]); + + const addEmail = async () => { + if (loading) { + return; + } + + if (!newEmail) { + setErrorMessage(warningEmail); + return; + } + + setLoading(true); + + const res = await createIdentifier(LoginIdentifierType.Email, newEmail); + if (res.err) { + const code = getErrorCode(res.val.message); + if (code) { + if (code === 'identifier_already_in_use') { + setErrorMessage(t('user-details.email_unique')); + } + + if (code === 'identifier_invalid_format') { + setErrorMessage(t('errors.identifier_invalid_format.email')); + } + + console.error(t(`errors.${code}`)); + } + setLoading(false); + return; + } + + void getCurrentUser() + .then(() => { + setNewEmail(''); + setAddingEmail(false); + setErrorMessage(undefined); + }) + .finally(() => setLoading(false)); + }; + + const makeEmailPrimary = async (email: Identifier) => { + setLoading(true); + + const res = await makePrimary(email.id, LoginIdentifierType.Email); + if (res.err) { + console.error(res.val.message); + } + + void getCurrentUser() + .then(() => setLoading(false)); + } + + const startEmailVerification = (email: Identifier) => { + setVerifyingEmails(prev => [...prev, email]); + }; + + const onFinishEmailVerification = (email: Identifier) => { + setVerifyingEmails(prev => prev.filter(v => v.id !== email.id)); + }; + + if (!emailEnabled) { + return null; + } + + const getBadges = (email: Identifier) => { + const badges = []; + + if (email.status === 'verified') { + badges.push({ text: badgeVerified, icon: }); + } else { + badges.push({ text: badgePending, icon: }); + } + + if (email.primary) { + badges.push({ text: badgePrimary, icon: }); + } + + return badges + }; + + const copyEmail = async (email: string) => { + await navigator.clipboard.writeText(email); + }; + + const getMenuItems = (email: Identifier) => { + const items = [buttonCopy]; + + if (email.status === 'verified') { + if (!email.primary) { + items.push(buttonPrimary); + } + } else { + items.push(buttonVerify); + } + + items.push(buttonRemove); + + return items; + }; + + return ( + + {emails.map((email, index) => ( +
+ {verifyingEmails.some(verifyingEmail => verifyingEmail.id === email.id) ? ( + onFinishEmailVerification(email)} + /> + ) : ( + <> +
+
+ {email.value} + {getBadges(email).map(badge => ( +
+ {badge.icon} + {badge.text} +
+ ))} +
+ { + if (item === buttonPrimary) { + void makeEmailPrimary(email); + } else if (item === buttonVerify) { + void startEmailVerification(email); + } else if (item === buttonRemove) { + setDeletingEmail(email); + } else { + void copyEmail(email.value); + } + }} + getItemClassName={item => (item === buttonRemove ? 'cb-error-text-color' : '')} + /> +
+ {deletingEmail === email && ( + setDeletingEmail(undefined)} + /> + )} + + )} +
+ ))} + {addingEmail ? ( +
e.preventDefault()} + className='cb-user-details-identifier-container' + > + setNewEmail(e.target.value)} + errorMessage={errorMessage} + /> + + + + ) : ( + + )} +
+ ); +}; + +export default EmailsEdit; diff --git a/packages/react/src/screens/user-details/IdentifierDeleteDialog.tsx b/packages/react/src/screens/user-details/IdentifierDeleteDialog.tsx new file mode 100644 index 000000000..d1954fb26 --- /dev/null +++ b/packages/react/src/screens/user-details/IdentifierDeleteDialog.tsx @@ -0,0 +1,122 @@ +import type { Identifier } from '@corbado/types'; +import type { FC } from 'react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, Text } from '../../components'; +import Alert from '../../components/user-details/Alert'; +import { useCorbado } from '../../hooks/useCorbado'; +import { useCorbadoUserDetails } from '../../hooks/useCorbadoUserDetails'; +import { getErrorCode } from '../../util'; + +interface Props { + identifier: Identifier; + onCancel: () => void; +} + +const IdentifierDeleteDialog: FC = ({ identifier, onCancel }) => { + const { t } = useTranslation('translation'); + const { deleteIdentifier } = useCorbado(); + const { getCurrentUser } = useCorbadoUserDetails(); + const [errorMessage, setErrorMessage] = useState(); + + const removeEmail = async () => { + const res = await deleteIdentifier(identifier.id); + if (res.err) { + const code = getErrorCode(res.val.message); + if (code) { + if (code === 'no_remaining_verified_identifier' || code === 'no_remaining_identifier') { + setErrorMessage(t('user-details.no_remaining_verified_identifier')); + } + console.error(t(`errors.${code}`)); + } + return; + } + void getCurrentUser(); + onCancel(); + }; + + const getHeading = () => { + switch (identifier.type) { + case 'email': + return t('user-details.email_delete.header'); + case 'phone': + return t('user-details.phone_delete.header'); + default: + return ''; + } + }; + + const getBody = () => { + switch (identifier.type) { + case 'email': + return t('user-details.email_delete.body'); + case 'phone': + return t('user-details.phone_delete.body'); + default: + return ''; + } + }; + + const getAlert = () => { + switch (identifier.type) { + case 'email': + return t('user-details.email_delete.alert'); + case 'phone': + return t('user-details.phone_delete.alert'); + default: + return ''; + } + }; + + return ( +
+ {errorMessage ? ( + <> + + + + ) : ( + <> + + {getHeading()} + + {getBody()} + + + +
+ + +
+ + )} +
+ ); +}; + +export default IdentifierDeleteDialog; diff --git a/packages/react/src/screens/user-details/IdentifierVerifyDialog.tsx b/packages/react/src/screens/user-details/IdentifierVerifyDialog.tsx new file mode 100644 index 000000000..7c6954724 --- /dev/null +++ b/packages/react/src/screens/user-details/IdentifierVerifyDialog.tsx @@ -0,0 +1,194 @@ +import type { Identifier } from '@corbado/types'; +import type { FC, MouseEvent } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, Link, LoadingSpinner, OtpInputGroup, Text } from '../../components'; +import { useCorbado } from '../../hooks/useCorbado'; +import { useCorbadoUserDetails } from '../../hooks/useCorbadoUserDetails'; +import { getErrorCode } from '../../util'; + +interface Props { + identifier: Identifier; + onCancel: () => void; +} + +const IdentifierVerifyDialog: FC = ({ identifier, onCancel }) => { + const { t } = useTranslation('translation'); + + const { verifyIdentifierFinish, verifyIdentifierStart } = useCorbado(); + + const { getCurrentUser } = useCorbadoUserDetails(); + + const [loading, setLoading] = useState(false); + const [remainingTime, setRemainingTime] = useState(0); + const [errorMessage, setErrorMessage] = useState(); + const timer = useRef(); + + const getHeading = () => { + switch (identifier.type) { + case 'email': + return t('user-details.email_verify.header'); + case 'phone': + return t('user-details.phone_verify.header'); + default: + return ''; + } + }; + + const getBody = () => { + switch (identifier.type) { + case 'email': + return t('user-details.email_verify.body'); + case 'phone': + return t('user-details.phone_verify.body'); + default: + return ''; + } + }; + + const getResend = () => { + switch (identifier.type) { + case 'email': + return t('user-details.email_verify.link', { counter: remainingTime }); + case 'phone': + return t('user-details.phone_verify.link', { counter: remainingTime }); + default: + return ''; + } + }; + + useEffect(() => { + setLoading(false); + + void resendEmailVerification(); + + const timer = startTimer(); + + return () => clearInterval(timer); + }, []); + + const finishEmailVerification = async (emailChallengeCode: string) => { + if (!emailChallengeCode) { + return; + } + + const res = await verifyIdentifierFinish(identifier.id, emailChallengeCode); + if (res.err) { + const code = getErrorCode(res.val.message); + if (code) { + // possible code: invalid_challenge_solution_email-otp + setErrorMessage(t('user-details.warning_invalid_challenge')); + console.error(t(`errors.${code}`)); + } + return; + } + + void getCurrentUser().then(() => onCancel()); + }; + + function startTimer() { + const newRemainingTime = 30; + + if (newRemainingTime < 1) { + return; + } + + setRemainingTime(newRemainingTime); + timer.current = setInterval(() => setRemainingTime(time => (time > 0 ? time - 1 : time)), 1000); + + return timer.current; + } + + const handleOtpChange = useCallback((userOtp: string[]) => { + const otp = userOtp.join(''); + if (otp.length !== 6) { + return; + } + + setLoading(true); + void finishEmailVerification(otp).finally(() => setLoading(false)); + }, []); + + const resendEmailVerification = async (e?: MouseEvent) => { + e?.preventDefault(); + + if (remainingTime > 0) { + return; + } + + setErrorMessage(undefined); + + const res = await verifyIdentifierStart(identifier.id); + + if (res.err) { + const code = getErrorCode(res.val.message); + if (code) { + console.error(t(`errors.${code}`)); + } + return; + } + + setRemainingTime(30); + }; + + return ( +
+ + {getHeading()} + + + {identifier.value} + + + {getBody()} + +
+ + {loading ? ( +
+ +
+ ) : ( +
+ )} +
+ void resendEmailVerification(e)} + > + {getResend()} + + +
+ ); +}; + +export default IdentifierVerifyDialog; diff --git a/packages/react/src/screens/user-details/NameEdit.tsx b/packages/react/src/screens/user-details/NameEdit.tsx new file mode 100644 index 000000000..3f69b1d14 --- /dev/null +++ b/packages/react/src/screens/user-details/NameEdit.tsx @@ -0,0 +1,143 @@ +import type { FC, MouseEvent } from 'react'; +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { Button, InputField, Text } from '../../components'; +import CopyButton from '../../components/ui/buttons/CopyButton'; +import { AddIcon } from '../../components/ui/icons/AddIcon'; +import { ChangeIcon } from '../../components/ui/icons/ChangeIcon'; +import UserDetailsCard from '../../components/user-details/UserDetailsCard'; +import { useCorbado } from '../../hooks/useCorbado'; +import { useCorbadoUserDetails } from '../../hooks/useCorbadoUserDetails'; +import { getErrorCode } from '../../util'; + +const NameEdit: FC = () => { + const { updateFullName } = useCorbado(); + const { name, getCurrentUser, processUser, setName, fullNameRequired } = useCorbadoUserDetails(); + const { t } = useTranslation('translation'); + + const [editingName, setEditingName] = useState(false); + const [loading, setLoading] = useState(false); + + const headerName = useMemo(() => t('user-details.name'), [t]); + const buttonAddName = useMemo(() => t('user-details.add_name'), [t]); + + const buttonSave = useMemo(() => t('user-details.save'), [t]); + const buttonCancel = useMemo(() => t('user-details.cancel'), [t]); + const buttonChange = useMemo(() => t('user-details.change'), [t]); + + const [errorMessage, setErrorMessage] = useState(undefined); + + const changeName = async () => { + setErrorMessage(undefined); + + if (loading) { + return; + } + + if (!name) { + setErrorMessage(t('user-details.name_required')); + return; + } + + setLoading(true); + const res = await updateFullName(name); + if (res.err) { + const code = getErrorCode(res.val.message); + + if (code === 'missing_full_name') { + setErrorMessage(t('errors.missing_full_name')); + } + + console.error(res.val.message); + setLoading(false); + return; + } + + void getCurrentUser() + .then(() => setEditingName(false)) + .finally(() => { + setLoading(false); + }); + }; + + if (!processUser || !fullNameRequired) { + return; + } + + const onCancel = (e: MouseEvent) => { + e.preventDefault(); + setName(processUser.name); + setEditingName(false); + setErrorMessage(undefined); + }; + + return ( + + {!processUser.name && !editingName ? ( + + ) : ( +
{ + e.preventDefault(); + }} + > +
+ setName(e.target.value)} + errorMessage={errorMessage} + /> + +
+ {editingName ? ( +
+ + +
+ ) : ( + + )} +
+ )} +
+ ); +}; + +export default NameEdit; diff --git a/packages/react/src/screens/user-details/PhonesEdit.tsx b/packages/react/src/screens/user-details/PhonesEdit.tsx new file mode 100644 index 000000000..6844346e3 --- /dev/null +++ b/packages/react/src/screens/user-details/PhonesEdit.tsx @@ -0,0 +1,252 @@ +import { LoginIdentifierType } from '@corbado/shared-ui'; +import type { Identifier } from '@corbado/types'; +import { t } from 'i18next'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; + +import { Button, PhoneInputField, Text } from '../../components'; +import { AddIcon } from '../../components/ui/icons/AddIcon'; +import { PendingIcon } from '../../components/ui/icons/PendingIcon'; +import { PrimaryIcon } from '../../components/ui/icons/PrimaryIcon'; +import { VerifiedIcon } from '../../components/ui/icons/VerifiedIcon'; +import DropdownMenu from '../../components/user-details/DropdownMenu'; +import UserDetailsCard from '../../components/user-details/UserDetailsCard'; +import { useCorbado } from '../../hooks/useCorbado'; +import { useCorbadoUserDetails } from '../../hooks/useCorbadoUserDetails'; +import { getErrorCode } from '../../util'; +import IdentifierDeleteDialog from './IdentifierDeleteDialog'; +import IdentifierVerifyDialog from './IdentifierVerifyDialog'; + +const PhonesEdit = () => { + const { createIdentifier, makePrimary } = useCorbado(); + const { phones = [], getCurrentUser, phoneEnabled } = useCorbadoUserDetails(); + + const initialPhones = useRef(); + + const [loading, setLoading] = useState(false); + const [verifyingPhones, setVerifyingPhones] = useState([]); + const [errorMessage, setErrorMessage] = useState(); + const [deletingPhone, setDeletingPhone] = useState(); + const [addingPhone, setAddingPhone] = useState(false); + const [newPhone, setNewPhone] = useState(''); + + const headerPhone = useMemo(() => t('user-details.phone'), [t]); + + const badgePrimary = useMemo(() => t('user-details.primary'), [t]); + const badgeVerified = useMemo(() => t('user-details.verified'), [t]); + const badgePending = useMemo(() => t('user-details.pending'), [t]); + + const buttonSave = useMemo(() => t('user-details.save'), [t]); + const buttonCopy = useMemo(() => t('user-details.copy'), [t]); + const buttonCancel = useMemo(() => t('user-details.cancel'), [t]); + const buttonAddPhone = useMemo(() => t('user-details.add_phone'), [t]); + const buttonPrimary = useMemo(() => t('user-details.make_primary'), [t]); + const buttonVerify = useMemo(() => t('user-details.verify'), [t]); + const buttonRemove = useMemo(() => t('user-details.remove'), [t]); + + useEffect(() => { + if (initialPhones.current === undefined && phones.length > 0) { + initialPhones.current = phones; + + return; + } + + phones.forEach(phone => { + if (initialPhones.current?.every(p => p.id !== phone.id)) { + setVerifyingPhones(prev => [...prev, phone]); + } + }); + + initialPhones.current = undefined; + }, [phones]); + + const addPhone = async () => { + if (loading) { + return; + } + + if (!newPhone) { + setErrorMessage(t('user-details.warning_invalid_phone')); + return; + } + + setLoading(true); + + const res = await createIdentifier(LoginIdentifierType.Phone, newPhone); + if (res.err) { + const code = getErrorCode(res.val.message); + if (code) { + if (code === 'identifier_already_in_use') { + setErrorMessage(t('user-details.phone_unique')); + } + + if (code === 'identifier_invalid_format') { + setErrorMessage(t('errors.identifier_invalid_format.phone')); + } + + console.error(t(`errors.${code}`)); + } + setLoading(false); + return; + } + + void getCurrentUser() + .then(() => { + setNewPhone(''); + setAddingPhone(false); + setErrorMessage(undefined); + }) + .finally(() => setLoading(false)); + }; + + const makePhonePrimary = async (phone: Identifier) => { + setLoading(true); + + const res = await makePrimary(phone.id, LoginIdentifierType.Phone); + if (res.err) { + console.error(res.val.message); + } + + void getCurrentUser() + .then(() => setLoading(false)); + } + + const startPhoneVerification = (phone: Identifier) => { + setVerifyingPhones(prev => [...prev, phone]); + }; + + const onFinishPhoneVerification = (phone: Identifier) => { + setVerifyingPhones(prev => prev.filter(v => v.id !== phone.id)); + }; + + if (!phoneEnabled) { + return null; + } + + const getBadges = (email: Identifier) => { + const badges = []; + + if (email.status === 'verified') { + badges.push({ text: badgeVerified, icon: }); + } else { + badges.push({ text: badgePending, icon: }); + } + + if (email.primary) { + badges.push({ text: badgePrimary, icon: }); + } + + return badges + }; + + const getMenuItems = (phone: Identifier) => { + const items = [buttonCopy]; + + if (phone.status !== 'verified') { + items.push(buttonVerify); + } + + items.push(buttonRemove); + + return items; + }; + + const copyPhone = async (phone: string) => { + await navigator.clipboard.writeText(phone); + }; + + return ( + + {phones.reverse().map((phone, index) => ( +
+ {verifyingPhones.some(verifyingPhone => verifyingPhone.id === phone.id) ? ( + onFinishPhoneVerification(phone)} + /> + ) : ( + <> +
+
+ {phone.value} + {getBadges(phone).map(badge => ( +
+ {badge.icon} + {badge.text} +
+ ))} +
+ { + if (item === buttonPrimary) { + void makePhonePrimary(phone); + } else if (item === buttonVerify) { + void startPhoneVerification(phone); + } else if (item === buttonRemove) { + setDeletingPhone(phone); + } else { + void copyPhone(phone.value); + } + }} + getItemClassName={item => (item === buttonRemove ? 'cb-error-text-color' : '')} + /> +
+ {deletingPhone === phone && ( + setDeletingPhone(undefined)} + /> + )} + + )} +
+ ))} + {addingPhone ? ( +
e.preventDefault()} + className='cb-user-details-identifier-container' + > + + + + + ) : ( + + )} +
+ ); +}; + +export default PhonesEdit; diff --git a/packages/react/src/screens/user-details/UserDelete.tsx b/packages/react/src/screens/user-details/UserDelete.tsx new file mode 100644 index 000000000..ee188412b --- /dev/null +++ b/packages/react/src/screens/user-details/UserDelete.tsx @@ -0,0 +1,68 @@ +import { t } from 'i18next'; +import React, { useMemo, useState } from 'react'; + +import { Button, Text } from '../../components'; +import Alert from '../../components/user-details/Alert'; +import UserDetailsCard from '../../components/user-details/UserDetailsCard'; +import { useCorbado } from '../../hooks/useCorbado'; + +const UserDelete = () => { + const { deleteUser, logout } = useCorbado(); + + const [isDeleting, setIsDeleting] = useState(false); + + const headerDelete = useMemo(() => t('user-details.delete_account'), [t]); + const buttonDelete = useMemo(() => t('user-details.delete'), [t]); + const textDelete = useMemo(() => t('user-details.delete_account_text'), [t]); + const titleDelete = useMemo(() => t('user-details.delete_account_title'), [t]); + const cancelDelete = useMemo(() => t('user-details.cancel'), [t]); + const confirmDelete = useMemo(() => t('user-details.confirm'), [t]); + + const deleteAccount = async () => { + const res = await deleteUser(); + if (res.err) { + // no possible error code + console.error(res.val.message); + return; + } + + void logout(); + }; + + return ( + + {titleDelete} + {textDelete} + {!isDeleting ? ( + + ) : ( + <> + + +
+ + + +
+ + )} +
+ ); +}; + +export default UserDelete; diff --git a/packages/react/src/screens/user-details/UsernameEdit.tsx b/packages/react/src/screens/user-details/UsernameEdit.tsx new file mode 100644 index 000000000..9df2a30d4 --- /dev/null +++ b/packages/react/src/screens/user-details/UsernameEdit.tsx @@ -0,0 +1,233 @@ +import { LoginIdentifierType } from '@corbado/shared-ui'; +import { t } from 'i18next'; +import React, { useMemo, useState } from 'react'; + +import { Button, InputField, Text } from '../../components'; +import CopyButton from '../../components/ui/buttons/CopyButton'; +import { AddIcon } from '../../components/ui/icons/AddIcon'; +import { ChangeIcon } from '../../components/ui/icons/ChangeIcon'; +import { CopyIcon } from '../../components/ui/icons/CopyIcon'; +import UserDetailsCard from '../../components/user-details/UserDetailsCard'; +import { useCorbado } from '../../hooks/useCorbado'; +import { useCorbadoUserDetails } from '../../hooks/useCorbadoUserDetails'; +import { getErrorCode } from '../../util'; + +const UsernameEdit = () => { + const { createIdentifier, updateUsername } = useCorbado(); + const { username, getCurrentUser, setUsername, processUser, usernameEnabled } = useCorbadoUserDetails(); + + const [addingUsername, setAddingUsername] = useState(false); + const [editingUsername, setEditingUsername] = useState(false); + const [loading, setLoading] = useState(false); + + const [newUsername, setNewUsername] = useState(username?.value); + + const [errorMessage, setErrorMessage] = useState(); + + const headerUsername = useMemo(() => t('user-details.username'), [t]); + const buttonSave = useMemo(() => t('user-details.save'), [t]); + const buttonCancel = useMemo(() => t('user-details.cancel'), [t]); + const buttonChange = useMemo(() => t('user-details.change'), [t]); + const buttonAddUsername = useMemo(() => t('user-details.add_username'), [t]); + + const copyUsername = async () => { + await navigator.clipboard.writeText(username?.value || ''); + }; + + const addUsername = async () => { + setErrorMessage(undefined); + + if (loading) { + return; + } + + if (!username || !username.value) { + setErrorMessage(t('user-details.username_required')); + return; + } + const res = await createIdentifier(LoginIdentifierType.Username, username?.value || ''); + if (res.err) { + const code = getErrorCode(res.val.message); + if (code) { + if (code === 'identifier_already_in_use') { + setErrorMessage(t('user-details.username_unique')); + } + + if (code === 'identifier_invalid_format') { + setErrorMessage(t('errors.identifier_invalid_format.username')); + } + + console.error(t(`errors.${code}`)); + } + + setLoading(false); + return; + } + + void getCurrentUser() + .then(() => { + setAddingUsername(false); + setErrorMessage(undefined); + }) + .finally(() => setLoading(false)); + }; + + const changeUsername = async () => { + setErrorMessage(undefined); + + if (loading) { + return; + } + + if (!username || !newUsername) { + setErrorMessage(t('user-details.username_required')); + return; + } + + if (username.value === newUsername) { + setErrorMessage(t('user-details.username_unique')); + return; + } + + setLoading(true); + + const res = await updateUsername(username.id, newUsername); + if (res.err) { + const code = getErrorCode(res.val.message); + + if (code === 'identifier_already_in_use') { + setErrorMessage(t('user-details.username_unique')); + } + + if (code === 'identifier_invalid_format') { + setErrorMessage(t('errors.identifier_invalid_format.username')); + } + + setLoading(false); + console.error(res.val.message); + return; + } + + void getCurrentUser() + .then(() => setEditingUsername(false)) + .finally(() => setLoading(false)); + }; + + if (!processUser || !usernameEnabled) { + return; + } + + return ( + + {!processUser.username ? ( +
+ {addingUsername ? ( +
{ + e.preventDefault(); + void addUsername(); + }} + > +
+ + setUsername({ id: '', type: 'username', status: 'verified', primary: false, value: e.target.value }) + } + /> + void copyUsername()} + /> +
+ + +
+ ) : ( + + )} +
+ ) : ( +
+ {username && ( +
e.preventDefault()}> +
+ setNewUsername(e.target.value)} + /> + +
+ {editingUsername ? ( +
+ + +
+ ) : ( + + )} +
+ )} +
+ )} +
+ ); +}; + +export default UsernameEdit; diff --git a/packages/react/src/util.ts b/packages/react/src/util.ts new file mode 100644 index 000000000..cd0e82dff --- /dev/null +++ b/packages/react/src/util.ts @@ -0,0 +1,5 @@ +export const getErrorCode = (message: string) => { + const regex = /\(([^)]+)\)/; + const matches = regex.exec(message); + return matches ? matches[1] : undefined; +}; diff --git a/packages/shared-ui/src/assets/alert.svg b/packages/shared-ui/src/assets/alert.svg new file mode 100644 index 000000000..b0b135c47 --- /dev/null +++ b/packages/shared-ui/src/assets/alert.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/shared-ui/src/assets/change.svg b/packages/shared-ui/src/assets/change.svg new file mode 100644 index 000000000..b5a4dead0 --- /dev/null +++ b/packages/shared-ui/src/assets/change.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared-ui/src/assets/copy.svg b/packages/shared-ui/src/assets/copy.svg new file mode 100644 index 000000000..d5784eefc --- /dev/null +++ b/packages/shared-ui/src/assets/copy.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared-ui/src/assets/pending.svg b/packages/shared-ui/src/assets/pending.svg new file mode 100644 index 000000000..ca1aac7e5 --- /dev/null +++ b/packages/shared-ui/src/assets/pending.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared-ui/src/assets/primary.svg b/packages/shared-ui/src/assets/primary.svg new file mode 100644 index 000000000..d242e9bfe --- /dev/null +++ b/packages/shared-ui/src/assets/primary.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared-ui/src/assets/tick.svg b/packages/shared-ui/src/assets/tick.svg new file mode 100644 index 000000000..3fa4f7705 --- /dev/null +++ b/packages/shared-ui/src/assets/tick.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/shared-ui/src/assets/verified.svg b/packages/shared-ui/src/assets/verified.svg new file mode 100644 index 000000000..fc9a8160d --- /dev/null +++ b/packages/shared-ui/src/assets/verified.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/shared-ui/src/i18n/en.json b/packages/shared-ui/src/i18n/en.json index febc8af41..711d06fe6 100644 --- a/packages/shared-ui/src/i18n/en.json +++ b/packages/shared-ui/src/i18n/en.json @@ -379,7 +379,7 @@ } }, "passkey-list": { - "title": "PASSKEYS", + "title": "Passkeys", "warning_notLoggedIn": "Please log in to see your passkeys.", "message_noPasskeys": "You don't have any passkeys yet.", "button_createPasskey": "Create a Passkey", @@ -407,20 +407,68 @@ "button_confirm": "Ok" } }, - "user": { - "header": "Your Account", + "user-details": { + "copied": "Copied", + "title": "User Details", "name": "Name", "username": "Username", - "email": "Email", + "email": "Email address", "phone": "Phone number", "social": "Social accounts", - "verified": "verified", - "unverified": "pending", + "delete_account": "Delete account", + "delete_account_title": "Do you want to delete this account? ", + "delete_account_text": "This will permanently remove your personal account and all of its content.", + "delete_account_alert": "This action is not reversible", + "primary": "Primary", + "make_primary": "Make primary", + "verified": "Verified", + "pending": "Pending", + "verify": "Verify", + "remove": "Remove", + "save": "Save", + "copy": "Copy", + "cancel": "Cancel", + "change": "Change", + "confirm": "Confirm", + "add_name": "Add name", + "add_username": "Add username", + "add_email": "Add email", + "add_phone": "Add phone", + "delete": "Delete", "warning_notLoggedIn": "Please log in to see your current user information.", + "warning_invalid_email": "Please enter a valid email", + "warning_invalid_phone": "Please enter a valid phone number", + "warning_invalid_challenge": "Please enter the correct code", "providers": { "google": "Google", "microsoft": "Microsoft", "github": "GitHub" + }, + "name_required": "Please enter a valid name.", + "username_required": "Please enter a valid username.", + "username_unique": "This username is already used", + "email_unique": "This email address is already used", + "phone_unique": "This phone number is already used", + "no_remaining_verified_identifier": "Can not remove the last verified identifier", + "email_delete": { + "header": "Remove Email Address", + "body": "Are you sure you want to remove this email address ?", + "alert": "You will no longer be able to log in with this email address. " + }, + "email_verify": { + "header": "Verify your email address", + "body": "Please verify your email by entering the one-time passcode we just sent to your email.", + "link": "Didn’t receive an email? Resend ({{counter}}s)" + }, + "phone_delete": { + "header": "Remove Phone Number", + "body": "Are you sure you want to remove this phone number ?", + "alert": "You will no longer be able to log in with this phone number. " + }, + "phone_verify": { + "header": "Verify your phone number", + "body": "Please verify your phone number by entering the one-time passcode we just sent to your phone number.", + "link": "Didn’t receive an SMS? Resend ({{counter}}s)" } } } diff --git a/packages/shared-ui/src/index.ts b/packages/shared-ui/src/index.ts index e2d13daf8..78753e399 100644 --- a/packages/shared-ui/src/index.ts +++ b/packages/shared-ui/src/index.ts @@ -1,9 +1,12 @@ import './styles/index.css'; import addIcon from './assets/add.svg'; +import alertIcon from './assets/alert.svg'; import appleIcon from './assets/apple.svg'; import cancelIcon from './assets/cancel.svg'; +import changeIcon from './assets/change.svg'; import circleExclamationIcon from './assets/circle-exclamation.svg'; +import copyIcon from './assets/copy.svg'; import deleteIcon from './assets/delete.svg'; import deviceIcon from './assets/device-icon.svg'; import editIcon from './assets/edit.svg'; @@ -34,12 +37,16 @@ import passkeyDefaultIcon from './assets/passkey-default.svg'; import passkeyErrorIcon from './assets/passkey-error.svg'; import passkeyHybridIcon from './assets/passkey-hybrid.svg'; import passkeyHybridDarkIcon from './assets/passkey-hybrid-dark.svg'; +import pendingIcon from './assets/pending.svg'; import personIcon from './assets/person.svg'; import phoneIcon from './assets/phone.svg'; +import primaryIcon from './assets/primary.svg'; import rightIcon from './assets/right-arrow.svg'; import secureIcon from './assets/secure-icon.svg'; import shieldIcon from './assets/shield.svg'; import syncIcon from './assets/sync.svg'; +import tickIcon from './assets/tick.svg'; +import verifiedIcon from './assets/verified.svg'; import visibilityIcon from './assets/visibility.svg'; import yahooIcon from './assets/yahoo.svg'; import i18nDe from './i18n/de.json'; @@ -54,6 +61,7 @@ export * from './flowHandler'; export type { BehaviorSubject } from 'rxjs'; export const assets = { + alertIcon, rightIcon, deleteIcon, passkeyDefaultIcon, @@ -96,4 +104,10 @@ export const assets = { passkeyHybridIcon, passkeyHybridDarkIcon, lockIcon, + copyIcon, + changeIcon, + primaryIcon, + verifiedIcon, + pendingIcon, + tickIcon, }; diff --git a/packages/shared-ui/src/styles/buttons.css b/packages/shared-ui/src/styles/buttons.css index 0f921ea9a..9647ec1f6 100644 --- a/packages/shared-ui/src/styles/buttons.css +++ b/packages/shared-ui/src/styles/buttons.css @@ -1,3 +1,7 @@ +button { + position: relative; +} + .cb-link { font-weight: var(--cb-font-weight-bold); text-decoration: none; @@ -117,3 +121,18 @@ button.cb-primary-button-error-variant { height: 1.5rem; vertical-align: middle; } + +.cb-button-loading { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: inherit; + border: inherit; + border-radius: inherit; + color: inherit; +} diff --git a/packages/shared-ui/src/styles/common.css b/packages/shared-ui/src/styles/common.css index a9a1b72c0..2b538dbf6 100644 --- a/packages/shared-ui/src/styles/common.css +++ b/packages/shared-ui/src/styles/common.css @@ -192,6 +192,11 @@ margin: 2rem auto 2.25rem auto; } +.cb-break { + flex-basis: 100%; + height: 0; +} + @media screen and (max-width: 481px) and (min-width: 400px) { .cb-container { width: 395px; diff --git a/packages/shared-ui/src/styles/index.css b/packages/shared-ui/src/styles/index.css index 52b0fe25b..21b30b680 100644 --- a/packages/shared-ui/src/styles/index.css +++ b/packages/shared-ui/src/styles/index.css @@ -7,7 +7,7 @@ @import './common.css'; @import './buttons.css'; @import './inputs.css'; -@import './user.css'; +@import './user-details.css'; @import './corbado-auth.css'; @import './passkey-list.css'; @import './themes/emerald-funk.css'; diff --git a/packages/shared-ui/src/styles/inputs.css b/packages/shared-ui/src/styles/inputs.css index bdb1061d6..45c61e647 100644 --- a/packages/shared-ui/src/styles/inputs.css +++ b/packages/shared-ui/src/styles/inputs.css @@ -178,6 +178,7 @@ border: none; margin-bottom: 0.5rem; width: 98%; + padding: 0.5rem; } .cb-phone-input-field-search:focus { diff --git a/packages/shared-ui/src/styles/passkey-list.css b/packages/shared-ui/src/styles/passkey-list.css index 7875f9cf0..8f447753a 100644 --- a/packages/shared-ui/src/styles/passkey-list.css +++ b/packages/shared-ui/src/styles/passkey-list.css @@ -6,8 +6,10 @@ } .cb-passkey-list-title { - font-size: calc(var(--cb-base-font-size) * 1.9); - margin: 0.5rem 0rem 2rem 0rem; + font-size: calc(var(--cb-base-font-size) * 2.7); + font-family: var(--cb-primary-font); + font-weight: bold; + margin: 0rem 0rem 0.5rem 1rem; } .cb-passkey-list-card { diff --git a/packages/shared-ui/src/styles/typography.css b/packages/shared-ui/src/styles/typography.css index 131bed04b..8637bb0c1 100644 --- a/packages/shared-ui/src/styles/typography.css +++ b/packages/shared-ui/src/styles/typography.css @@ -6,6 +6,10 @@ color: var(--cb-text-secondary-color); } +.cb-error-text-color { + color: var(--cb-error-text-color); +} + .cb-header-text-color { color: var(--cb-header-text-color); } @@ -60,3 +64,9 @@ font-family: var(--cb-primary-font); font-size: calc(var(--cb-base-font-size) * 2.3); } + +.cb-truncate { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/shared-ui/src/styles/user-details.css b/packages/shared-ui/src/styles/user-details.css new file mode 100644 index 000000000..b379b4c70 --- /dev/null +++ b/packages/shared-ui/src/styles/user-details.css @@ -0,0 +1,450 @@ +.cb-user-details-container { + display: flex; + flex-direction: column; + margin: 1.25rem 0rem; + text-align: left; + text-overflow: ellipsis; +} + +.cb-user-details-section { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1.5rem; + border: 1px solid var(--cb-border-color); + border-radius: var(--cb-border-radius-lg); + padding: 1rem 1.25rem; +} + +.cb-user-details-section-header { + margin-bottom: 1rem; +} + +.cb-user-details-title { + font-size: calc(var(--cb-base-font-size) * 2.7); + font-family: var(--cb-primary-font); + font-weight: bold; + margin: 0.5rem 1rem; +} + +.cb-user-details-card { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: start; + gap: 1rem; + border-radius: var(--cb-border-radius-sm); + border: var(--cb-passkey-list-border-color) 1px solid; + margin: 0.5rem 0; + padding: 1rem 1rem; + background-color: var(--cb-white); + flex-wrap: wrap; + line-height: 1.4; +} + +.cb-user-details-header { + margin-bottom: 0.5rem; + font-size: calc(var(--cb-base-font-size) * 1.9); + font-family: var(--cb-primary-font); + width: 170px; + font-weight: bold; +} + +.cb-user-details-subheader { + font-size: calc(var(--cb-base-font-size) * 1.5); + font-family: var(--cb-primary-font); + font-weight: bold; +} + +.cb-user-details-text { + display: inline-block; + font-size: calc(var(--cb-base-font-size) * 1.3); + font-family: var(--cb-primary-font); +} + +.cb-user-details-body { + width: 100%; + max-width: 500px; +} + +.cb-user-details-body-row { + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; +} + +.cb-user-details-body-row-icon { + height: 1.5rem; + cursor: pointer; + padding: 0.5rem 0.4rem; + align-self: flex-start; +} + +.cb-user-details-body-button { + display: flex; + align-items: center; + padding: 0.4rem 2rem; + border: 1px solid #ccc; + border-radius: 0.5rem; + background-color: transparent; + cursor: pointer; + box-sizing: border-box; + font-family: var(--cb-primary-font); + + &:hover { + background-color: var(--cb-box-color-hover); + } +} + +.cb-user-details-body-button-icon { + height: 1em; + width: auto; + margin-right: 8px; +} + +.cb-user-details-body-button-primary { + padding: 0.4rem 3rem; + background-color: var(--cb-primary-color); + border: 1px solid transparent; + border-radius: var(--cb-border-radius-sm); + cursor: pointer; + box-sizing: border-box; + font-family: var(--cb-primary-font); + + span { + color: var(--cb-button-text-primary-color); + } + + &:hover { + background-color: var(--cb-primary-color-hover); + } +} + +.cb-user-details-body-button-secondary, +.cb-user-details-body-button-cancel, +.cb-user-details-verify-identifier-button-cancel { + padding: 0.4rem 3rem; + background-color: var(--cb-secondary-color); + border: 1px solid transparent; + border-radius: var(--cb-border-radius-sm); + cursor: pointer; + box-sizing: border-box; + font-family: var(--cb-primary-font); + margin-left: 0.5rem; + + &:hover { + background-color: var(--cb-box-color-hover); + } +} + +.cb-user-details-verify-identifier-button-cancel { + align-self: flex-end; + width: fit-content; +} + +.cb-user-details-body-button-secondary span { + color: var(--cb-secondary-link-color); +} + +.cb-user-details-body-button-delete { + color: var(--cb-error-text-color); + padding: 0.4rem 0; + background-color: var(--cb-color-primary); + border: 1px solid transparent; + border-radius: var(--cb-border-radius); + cursor: pointer; + box-sizing: border-box; + font-family: var(--cb-primary-font); + padding-right: 1rem; +} + +.cb-user-details-identifier-container { + gap: 1rem; + border-radius: var(--cb-border-radius-sm); + border: var(--cb-border-color) 1px solid; + margin-bottom: 0.75rem; + padding: 0.6rem 0.5rem 0.6rem 1rem; +} + +.cb-user-details-header-badge-section { + margin: auto 0; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + overflow: hidden; + + span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 300px; + } +} + +.cb-user-details-header-badge { + display: flex; + gap: 0.5rem; + flex-direction: row; + border-radius: var(--cb-border-radius-lg); + border: none; + color: var(--cb-passkey-list-badge-color); + background-color: var(--cb-passkey-list-badge-background-color); + padding: 0.35rem 0.7rem; + align-items: center; + justify-content: center; +} + +.cb-user-details-header-badge-icon { + flex: 2; + height: 1rem; +} + +.cb-user-details-header-badge-text { + flex: 10; + font-weight: 500; + white-space: nowrap; + overflow: visible; + color: var(--cb-passkey-list-badge-color); + font-family: var(--cb-secondary-font); + font-size: calc(var(--cb-base-font-size) * 1.25); +} + +.cb-user-details-section-indentifier { + width: 85%; + align-self: self-start; +} + +/* .cb-user-details-section-indentifiers-list { + margin-top: 1rem; +} */ + +.cb-user-details-section-indentifiers-list-item { + display: flex; + flex-direction: row; + gap: 0.5rem; + align-items: center; +} + +.cb-user-details-section-indentifiers-list-item-field { + flex: 8; +} + +.cb-user-details-section-indentifiers-list-item-field-input { + width: var(--cb-container-full-width); +} + +.cb-user-details-section-indentifiers-list-item-badge { + padding: 0.5rem; + flex: 1; + margin-top: 0.75rem; + border-radius: var(--cb-border-radius-sm); +} + +.cb-user-details-section-indentifiers-list-item-badge-primary { + background-color: var(--cb-primary-color); + color: var(--cb-button-text-primary-color); +} + +.cb-user-details-section-indentifiers-list-item-badge-secondary { + background-color: var(--cb-text-secondary-color); + color: var(--cb-text-primary-color); +} + +.cb-user-details-section-indentifiers-list-item-badge-text { + color: var(--cb-button-text-primary-color); +} + +.cb-user-details-alert-container { + display: flex; + flex-direction: row; + gap: 8px; + align-items: center; + margin: 0.5rem 0; +} + +.cb-user-details-alert-icon { + width: 13px; + height: 13px; +} + +.cb-user-details-input { + width: 100%; + + input { + margin-top: 0px; + } + + input:disabled { + background-color: inherit; + } +} + +.cb-dropdown-menu { + position: relative; +} + +.cb-dropdown-menu-container { + z-index: 1000; + position: absolute; + top: 30px; + right: 10px; + width: 120px; + background-color: var(--cb-white); + box-shadow: 0px 2px 6px 2px var(--cb-border-color); + border-radius: var(--cb-border-radius-sm); +} + +.cb-dropdown-menu-item { + padding: 0.5rem 0.7rem; + border-bottom: 1px solid var(--cb-border-color); + cursor: pointer; + + &:hover { + background-color: var(--cb-box-color-hover); + cursor: pointer; + } + + &:last-child { + border-bottom: none; + } +} + +.cb-dropdown-menu-trigger { + cursor: pointer; + font-family: var(--cb-primary-font); + font-size: 14px; + font-weight: var(--cb-font-weight-bold); + margin-right: 4px; +} + +.cb-user-details-badge-text { + flex: 10; + font-weight: 500; + white-space: nowrap; + overflow: visible; + color: var(--cb-passkey-list-badge-color); + font-family: var(--cb-secondary-font); + font-size: calc(var(--cb-base-font-size) * 1.25); +} + +.cb-user-details-deletion-dialog { + margin-top: 1rem; + margin-bottom: 0.5rem; + background-color: var(--cb-user-details-dialog-background-color); + padding: 1rem; + border-radius: var(--cb-border-radius-sm); + border: 1px solid var(--cb-user-details-dialog-border-color); + display: flex; + flex-direction: column; + gap: 6px; +} + +button.cb-user-details-deletion-dialog-primary-button { + background-color: var(--cb-error-color); + color: var(--cb-white); + width: fit-content; + padding: 0.5rem 1.5rem; +} + +button.cb-user-details-deletion-dialog-secondary-button { + background-color: transparent; + border: 1px solid var(--cb-user-details-dialog-border-color); + color: var(--cb-text-secondary-color); + width: fit-content; + padding: 0.5rem 1.5rem; +} + +button.cb-user-details-deletion-dialog-primary-button:hover { + background-color: var(--cb-error-color-hover); +} + +button.cb-user-details-deletion-dialog-secondary-button:hover { + background-color: var(--cb-box-color-hover); +} + +.cb-user-details-deletion-dialog-cta { + display: flex; + flex-direction: row; + gap: 1rem; +} + +.cb-otp-inputs-container { + display: flex; + flex-direction: row; + gap: 0.5rem; + margin-bottom: 1rem; + width: 100%; + max-width: 340px; + margin-top: 0.5rem; + + .cb-otp-input-container { + margin-top: 0px; + } +} + +.cb-otp-inputs-placeholder, +.cb-otp-inputs-loader { + border: 0.25rem solid transparent; + width: 20px; + height: 20px; + display: block; + flex-shrink: 0; +} + +.cb-otp-inputs-loader { + border-color: var(--cb-primary-color) var(--cb-primary-color) var(--cb-primary-color) transparent; +} + +.cb-otp-inputs-loader-container { + margin-top: 0.3rem; + display: flex; + justify-items: center; + height: 52px; +} + +.cb-user-details-verify-identifier-container { + display: flex; + flex-direction: column; + gap: 6px; +} + +.cb-tooltip-container { + position: relative; + display: inline-block; +} + +.cb-tooltip { + position: absolute; + bottom: -20px; + margin-top: 0.1rem; + left: 50%; + transform: translate(-50%, 0); + border-radius: var(--cb-border-radius-sm); + background-color: var(--cb-box-color); + box-shadow: 0px 2px 6px 2px var(--cb-border-color); + padding: 0.2rem 0.4rem; +} + +.cb-user-details-button-spinner { + border-width: 2px; + border-color: transparent var(--cb-white) var(--cb-white) var(--cb-white); + width: 8px; + height: 8px; +} + +@media screen and (max-width: 481px) { + .cb-user-details-section-indentifier { + width: 75%; + } + + .cb-user-details-body-subtitle { + display: none; + } + + .cb-user-details-badge-text { + font-size: 10px; + } +} diff --git a/packages/shared-ui/src/styles/user.css b/packages/shared-ui/src/styles/user.css deleted file mode 100644 index c791c21b6..000000000 --- a/packages/shared-ui/src/styles/user.css +++ /dev/null @@ -1,64 +0,0 @@ -.cb-user-details-section { - display: flex; - flex-direction: column; - gap: 0.5rem; - margin-bottom: 1.5rem; - border: 1px solid var(--cb-border-color); - border-radius: var(--cb-border-radius-lg); - padding: 1rem 1.25rem; -} - -.cb-user-details-section-header { - margin-bottom: 1rem; -} - -.cb-user-details-section-indentifier { - width: 85%; - align-self: self-start; -} - -.cb-user-details-section-indentifiers-list { - margin-top: 1rem; -} - -.cb-user-details-section-indentifiers-list-item { - display: flex; - flex-direction: row; - gap: 0.5rem; - align-items: center; -} - -.cb-user-details-section-indentifiers-list-item-field { - flex: 8; -} - -.cb-user-details-section-indentifiers-list-item-field-input { - width: var(--cb-container-full-width); -} - -.cb-user-details-section-indentifiers-list-item-badge { - padding: 0.5rem; - flex: 1; - margin-top: 0.75rem; - border-radius: var(--cb-border-radius-sm); -} - -.cb-user-details-section-indentifiers-list-item-badge-primary { - background-color: var(--cb-primary-color); - color: var(--cb-button-text-primary-color); -} - -.cb-user-details-section-indentifiers-list-item-badge-secondary { - background-color: var(--cb-text-secondary-color); - color: var(--cb-text-primary-color); -} - -.cb-user-details-section-indentifiers-list-item-badge-text { - color: var(--cb-button-text-primary-color); -} - -@media screen and (max-width: 481px) { - .cb-user-details-section-indentifier { - width: 75%; - } -} diff --git a/packages/shared-ui/src/styles/variables.css b/packages/shared-ui/src/styles/variables.css index dde6e0169..29968e929 100644 --- a/packages/shared-ui/src/styles/variables.css +++ b/packages/shared-ui/src/styles/variables.css @@ -1,6 +1,6 @@ :root { --cb-primary-color: #1953ff; - --cb-primary-color-hover: rgba(25, 83, 255, 0.75); + --cb-primary-color-hover: #2b5dff; --cb-primary-color-disabled: #dfdfe0; --cb-button-text-primary-color: #fff; --cb-text-primary-color: #000000; @@ -16,6 +16,9 @@ --cb-box-color: #f5f5f5; --cb-box-color-hover: #f5f5f5; --cb-error-text-color: #fa5f55; + --cb-error-color: #d51d1d; + --cb-success-color: #79d51d; + --cb-error-color-hover: #e65252; --cb-otp-disabled-color: #fbfbfb; --cb-divider-line-color: rgba(0, 0, 0, 0.3); --cb-input-disabled-color: #dfdfe0; @@ -25,6 +28,8 @@ --cb-passkey-list-description-text-color: #8d8d8d; --cb-passkey-list-border-color: rgb(212 212 212 / 60%); --cb-passkey-list-border-hover-color: #848484; + --cb-user-details-dialog-background-color: #f8f8f8; + --cb-user-details-dialog-border-color: #e5e5e5; --cb-primary-font: 'Space Grotesk', sans-serif; --cb-secondary-font: 'Inter', sans-serif; --cb-font-weight-bold: 700; @@ -64,6 +69,8 @@ --cb-passkey-list-description-text-color: #d9d9d9; --cb-passkey-list-border-color: rgb(132 140 166 / 65%); --cb-passkey-list-border-hover-color: #ffffff; + --cb-user-details-dialog-background-color: #00000; + --cb-user-details-dialog-border-color: rgba(132, 140, 166, 0.65); } @media screen and (max-width: 481px) { diff --git a/packages/types/src/session.ts b/packages/types/src/session.ts index e5119ba9e..427ff4b4c 100644 --- a/packages/types/src/session.ts +++ b/packages/types/src/session.ts @@ -43,14 +43,17 @@ export interface CorbadoUser { /** * Interface for Identifier. * @interface + * @property {string} id - The ID of the identifier. * @property {string} value - The value of the identifier. * @property {LoginIdentifierType} type - The type of the identifier. * @property {string} status - The status of the identifier. */ export interface Identifier { + id: string; value: string; type: LoginIdentifierType; status: string; + primary: boolean; } /** @@ -86,3 +89,25 @@ export const LoginIdentifierType = { * @typedef {string} LoginIdentifierType */ export type LoginIdentifierType = (typeof LoginIdentifierType)[keyof typeof LoginIdentifierType]; + +/** + * Interface for IdentifierConfig. + * @interface + * @property {LoginIdentifierType} type - The type of the identifier. + * @property {string} enforceVerification - Indicates verification policy. + * @property {boolean} useAsLoginIdentifier - Indicates used for login. + */ +export interface IdentifierConfig { + type: LoginIdentifierType; +} + +/** + * Interface for UserDetailsConfig. + * @interface + * @property {boolean} fullNameRequired - Indicates if full name is required. + * @property {Array} identifiers - Config for each identifier type. + */ +export interface UserDetailsConfig { + fullNameRequired: boolean; + identifiers: Array; +} diff --git a/packages/web-core/openapi/spec_v2.yaml b/packages/web-core/openapi/spec_v2.yaml index 4baee1c5c..a0f076f5f 100644 --- a/packages/web-core/openapi/spec_v2.yaml +++ b/packages/web-core/openapi/spec_v2.yaml @@ -71,7 +71,7 @@ paths: schema: $ref: '#/components/schemas/meRsp' patch: - description: Updates current user + description: Updates current user (full name, primary email, or primary phone) operationId: CurrentUserUpdate tags: - Users @@ -1923,11 +1923,13 @@ components: meUpdateReq: type: object - required: - - fullName properties: fullName: type: string + primaryEmailIdentifierID: + type: string + primaryPhoneIdentifierID: + type: string identifier: type: object @@ -1936,6 +1938,7 @@ components: - value - type - status + - primary properties: id: type: string @@ -1945,6 +1948,8 @@ components: $ref: '#/components/schemas/loginIdentifierType' status: type: string + primary: + type: boolean socialAccount: type: object diff --git a/packages/web-core/src/api/v2/api.ts b/packages/web-core/src/api/v2/api.ts index 89c639647..fabab0317 100644 --- a/packages/web-core/src/api/v2/api.ts +++ b/packages/web-core/src/api/v2/api.ts @@ -1181,6 +1181,12 @@ export interface Identifier { * @memberof Identifier */ 'status': string; + /** + * + * @type {boolean} + * @memberof Identifier + */ + 'primary': boolean; } @@ -1632,7 +1638,19 @@ export interface MeUpdateReq { * @type {string} * @memberof MeUpdateReq */ - 'fullName': string; + 'fullName'?: string; + /** + * + * @type {string} + * @memberof MeUpdateReq + */ + 'primaryEmailIdentifierID'?: string; + /** + * + * @type {string} + * @memberof MeUpdateReq + */ + 'primaryPhoneIdentifierID'?: string; } /** * @@ -5244,7 +5262,7 @@ export const UsersApiAxiosParamCreator = function (configuration?: Configuration }; }, /** - * Updates current user + * Updates current user (full name, primary email, or primary phone) * @param {MeUpdateReq} meUpdateReq * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -5421,7 +5439,7 @@ export const UsersApiFp = function(configuration?: Configuration) { return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** - * Updates current user + * Updates current user (full name, primary email, or primary phone) * @param {MeUpdateReq} meUpdateReq * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -5553,7 +5571,7 @@ export const UsersApiFactory = function (configuration?: Configuration, basePath return localVarFp.currentUserSessionRefresh(options).then((request) => request(axios, basePath)); }, /** - * Updates current user + * Updates current user (full name, primary email, or primary phone) * @param {MeUpdateReq} meUpdateReq * @param {*} [options] Override http request option. * @throws {RequiredError} @@ -5710,7 +5728,7 @@ export class UsersApi extends BaseAPI { } /** - * Updates current user + * Updates current user (full name, primary email, or primary phone) * @param {MeUpdateReq} meUpdateReq * @param {*} [options] Override http request option. * @throws {RequiredError} diff --git a/packages/web-core/src/api/v2/base.ts b/packages/web-core/src/api/v2/base.ts index a378b2b3e..774913341 100644 --- a/packages/web-core/src/api/v2/base.ts +++ b/packages/web-core/src/api/v2/base.ts @@ -19,7 +19,7 @@ import type { Configuration } from './configuration'; import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios'; import globalAxios from 'axios'; -export const BASE_PATH = "https://.frontendapi.corbado.io".replace(/\/+$/, ""); +export const BASE_PATH = "http://localhost".replace(/\/+$/, ""); /** * diff --git a/packages/web-core/src/services/SessionService.ts b/packages/web-core/src/services/SessionService.ts index 40dbee359..38621315d 100644 --- a/packages/web-core/src/services/SessionService.ts +++ b/packages/web-core/src/services/SessionService.ts @@ -1,5 +1,5 @@ /// <- add this line -import type { CorbadoUser, PassKeyList, SessionUser } from '@corbado/types'; +import type { CorbadoUser, PassKeyList, SessionUser, UserDetailsConfig } from '@corbado/types'; import type { AxiosHeaders, AxiosInstance, @@ -15,7 +15,7 @@ import { Err, Ok, Result } from 'ts-results'; import { Configuration } from '../api/v1'; import type { SessionConfigRsp, ShortSessionCookieConfig } from '../api/v2'; -import { ConfigsApi, UsersApi } from '../api/v2'; +import { ConfigsApi, LoginIdentifierType, UsersApi } from '../api/v2'; import { ShortSession } from '../models/session'; import { AuthState, @@ -45,6 +45,7 @@ const packageVersion = '0.0.0'; */ export class SessionService { #usersApi: UsersApi = new UsersApi(); + #configsApi: ConfigsApi; #webAuthnService: WebAuthnService; readonly #setShortSessionCookie: boolean; @@ -68,6 +69,13 @@ export class SessionService { this.#longSession = undefined; this.#setShortSessionCookie = setShortSessionCookie; this.#isPreviewMode = isPreviewMode; + + const config = new Configuration({ + apiKey: this.#projectId, + }); + + const axiosInstance = this.#createAxiosInstanceV2(); + this.#configsApi = new ConfigsApi(config, this.#getDefaultFrontendApiUrl(), axiosInstance); } /** @@ -157,6 +165,84 @@ export class SessionService { return this.wrapWithErr(async () => this.#usersApi.currentUserGet({ signal: abortController.signal })); } + public async getUserDetailsConfig( + abortController: AbortController, + ): Promise> { + return this.wrapWithErr(async () => this.#configsApi.getUserDetailsConfig({ signal: abortController.signal })); + } + + async updateFullName(fullName: string): Promise> { + return Result.wrapAsync(async () => { + await this.#usersApi.currentUserUpdate({ fullName }); + return void 0; + }); + } + + async updateUsername(identifierId: string, username: string): Promise> { + return Result.wrapAsync(async () => { + await this.#usersApi.currentUserIdentifierUpdate({ + identifierID: identifierId, + identifierType: LoginIdentifierType.Username, + value: username, + }); + return void 0; + }); + } + + async createIdentifier(identifierType: LoginIdentifierType, value: string): Promise> { + return Result.wrapAsync(async () => { + await this.#usersApi.currentUserIdentifierCreate({ identifierType, value }); + return void 0; + }); + } + + async deleteIdentifier(identifierId: string): Promise> { + return Result.wrapAsync(async () => { + await this.#usersApi.currentUserIdentifierDelete({ identifierID: identifierId }); + return void 0; + }); + } + + async verifyIdentifierStart(identifierId: string): Promise> { + return Result.wrapAsync(async () => { + await this.#usersApi.currentUserIdentifierVerifyStart({ + identifierID: identifierId, + clientInformation: { + bluetoothAvailable: (await WebAuthnService.canUseBluetooth()) ?? false, + canUsePasskeys: await WebAuthnService.doesBrowserSupportPasskeys(), + clientEnvHandle: WebAuthnService.getClientHandle() ?? undefined, + javaScriptHighEntropy: await WebAuthnService.getHighEntropyValues(), + }, + }); + return void 0; + }); + } + + async verifyIdentifierFinish(identifierId: string, code: string): Promise> { + return Result.wrapAsync(async () => { + await this.#usersApi.currentUserIdentifierVerifyFinish({ identifierID: identifierId, code }); + return void 0; + }); + } + + async makePrimary(identifierId: string, identifierType: LoginIdentifierType): Promise> { + return Result.wrapAsync(async () => { + if (identifierType === LoginIdentifierType.Email) { + await this.#usersApi.currentUserUpdate({ primaryEmailIdentifierID: identifierId }); + } else if (identifierType === LoginIdentifierType.Username) { + await this.#usersApi.currentUserUpdate({ primaryPhoneIdentifierID: identifierId }); + } + return void 0; + }); + } + + async deleteUser(): Promise> { + return Result.wrapAsync(async () => { + await this.#usersApi.currentUserDelete({}); + return void 0; + }); + } + async appendPasskey(): Promise> { const canUsePasskeys = await WebAuthnService.doesBrowserSupportPasskeys(); @@ -528,15 +614,8 @@ export class SessionService { }; #loadSessionConfig = async (): Promise> => { - const config = new Configuration({ - apiKey: this.#projectId, - }); - - const axiosInstance = this.#createAxiosInstanceV2(); - const configsApi = new ConfigsApi(config, this.#getDefaultFrontendApiUrl(), axiosInstance); - return Result.wrapAsync(async () => { - const r = await configsApi.getSessionConfig(); + const r = await this.#configsApi.getSessionConfig(); return r.data; }); }; diff --git a/packages/web-js/src/core/Corbado.ts b/packages/web-js/src/core/Corbado.ts index a2113abdc..68a43d31d 100644 --- a/packages/web-js/src/core/Corbado.ts +++ b/packages/web-js/src/core/Corbado.ts @@ -1,5 +1,5 @@ -import { CorbadoAuth, Login, PasskeyList, SignUp, User } from '@corbado/react'; -import type { CorbadoAuthConfig, CorbadoLoginConfig, CorbadoSignUpConfig } from '@corbado/types'; +import { CorbadoAuth, Login, PasskeyList, SignUp, UserDetails } from '@corbado/react'; +import type { CorbadoAuthConfig, CorbadoLoginConfig, CorbadoSignUpConfig, LoginIdentifierType } from '@corbado/types'; import type { FC } from 'react'; import type { Root } from 'react-dom/client'; @@ -74,11 +74,11 @@ export class Corbado { this.#unmountComponent(element); } - mountUserUI(element: HTMLElement) { - this.#mountComponent(element, User, {}); + mountUserDetailsUI(element: HTMLElement) { + this.#mountComponent(element, UserDetails, {}); } - unmountUserUI(element: HTMLElement) { + unmountUserDetailsUI(element: HTMLElement) { this.#unmountComponent(element); } @@ -106,6 +106,44 @@ export class Corbado { return this.#getCorbadoAppState().corbadoApp.sessionService.getFullUser(abortController ?? new AbortController()); } + getUserDetailsConfig(abortController?: AbortController) { + return this.#getCorbadoAppState().corbadoApp.sessionService.getUserDetailsConfig( + abortController ?? new AbortController(), + ); + } + + updateFullName(fullName: string) { + return this.#getCorbadoAppState().corbadoApp.sessionService.updateFullName(fullName); + } + + updateUsername(identifierId: string, username: string) { + return this.#getCorbadoAppState().corbadoApp.sessionService.updateUsername(identifierId, username); + } + + createIdentifier(identifierType: LoginIdentifierType, value: string) { + return this.#getCorbadoAppState().corbadoApp.sessionService.createIdentifier(identifierType, value); + } + + deleteIdentifier(identifierId: string) { + return this.#getCorbadoAppState().corbadoApp.sessionService.deleteIdentifier(identifierId); + } + + verifyIdentifierStart(identifierId: string) { + return this.#getCorbadoAppState().corbadoApp.sessionService.verifyIdentifierStart(identifierId); + } + + verifyIdentifierFinish(identifierId: string, code: string) { + return this.#getCorbadoAppState().corbadoApp.sessionService.verifyIdentifierFinish(identifierId, code); + } + + makePrimary(identifierId: string, identifierType: LoginIdentifierType) { + return this.#getCorbadoAppState().corbadoApp.sessionService.makePrimary(identifierId, identifierType); + } + + deleteUser() { + return this.#getCorbadoAppState().corbadoApp.sessionService.deleteUser(); + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any #mountComponent = >(element: HTMLElement, Component: FC, componentOptions: T) => { if (!this.#corbadoAppState) { diff --git a/playground/react/src/components/AuthDetails.tsx b/playground/react/src/components/AuthDetails.tsx index d3df43875..2bb374255 100644 --- a/playground/react/src/components/AuthDetails.tsx +++ b/playground/react/src/components/AuthDetails.tsx @@ -1,4 +1,4 @@ -import { PasskeyList, useCorbado, User } from '@corbado/react'; +import { UserDetails, PasskeyList, useCorbado } from '@corbado/react'; import { useNavigate, useParams } from 'react-router-dom'; export const AuthDetails = () => { @@ -9,7 +9,7 @@ export const AuthDetails = () => { return (
- +