diff --git a/app/package.json b/app/package.json index 011cef76a..8561bd8f6 100644 --- a/app/package.json +++ b/app/package.json @@ -21,6 +21,7 @@ "@emotion/react": "11.4.0", "@emotion/styled": "11.3.0", "@improbable-eng/grpc-web": "0.14.0", + "@types/react-collapse": "^5.0.1", "big.js": "6.1.1", "bootstrap": "4.6.1", "buffer": "6.0.3", @@ -40,8 +41,10 @@ "qrcode.react": "^3.1.0", "rc-dialog": "^8.9.0", "rc-select": "11.5.0", + "rc-switch": "^4.0.0", "rc-tooltip": "4.2.1", "react": "17.0.2", + "react-collapse": "^5.1.1", "react-dom": "17.0.2", "react-i18next": "11.7.0", "react-router-dom": "^6.3.0", diff --git a/app/src/App.scss b/app/src/App.scss index 96c23bccb..9f5014925 100644 --- a/app/src/App.scss +++ b/app/src/App.scss @@ -18,6 +18,7 @@ @import '../node_modules/rc-tooltip/assets/bootstrap_white.css'; @import '../node_modules/rc-dialog/assets/index.css'; @import './assets/styles/rc-select.scss'; +@import './assets/styles/rc-switch.scss'; // react-toastify styles @import '../node_modules/react-toastify/dist/ReactToastify.css'; diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx index bb0a02a98..270c6bda3 100644 --- a/app/src/AppRoutes.tsx +++ b/app/src/AppRoutes.tsx @@ -9,6 +9,9 @@ const LazyHistoryPage = React.lazy(() => import('components/history/HistoryPage' const LazyPoolPage = React.lazy(() => import('components/pool/PoolPage')); const LazySettingsPage = React.lazy(() => import('components/settings/SettingsPage')); const LazyConnectPage = React.lazy(() => import('components/connect/ConnectPage')); +const LazyCustomSessionPage = React.lazy( + () => import('components/connect/CustomSessionPage'), +); const AppRoutes: React.FC = () => { return ( @@ -63,6 +66,14 @@ const AppRoutes: React.FC = () => { } /> + + + + } + /> ); diff --git a/app/src/api/lit.ts b/app/src/api/lit.ts index a0952d8a3..aa9ebf03d 100644 --- a/app/src/api/lit.ts +++ b/app/src/api/lit.ts @@ -1,6 +1,9 @@ -import * as LIT from 'types/generated/lit-sessions_pb'; +import * as ACCOUNT from 'types/generated/lit-accounts_pb'; +import * as SESSION from 'types/generated/lit-sessions_pb'; +import { Accounts } from 'types/generated/lit-accounts_pb_service'; import { Sessions } from 'types/generated/lit-sessions_pb_service'; import { b64 } from 'util/strings'; +import { MAX_DATE } from 'util/constants'; import BaseApi from './base'; import GrpcClient from './grpc'; @@ -16,24 +19,46 @@ class LitApi extends BaseApi { this._grpc = grpc; } + /** + * call the Lit `CreateAccount` RPC and return the response + */ + async createAccount( + accountBalance: number, + expirationDate: Date, + ): Promise { + const req = new ACCOUNT.CreateAccountRequest(); + req.setAccountBalance(accountBalance.toString()); + + if (expirationDate === MAX_DATE) { + req.setExpirationDate('0'); + } else { + req.setExpirationDate(Math.floor(expirationDate.getTime() / 1000).toString()); + } + + const res = await this._grpc.request(Accounts.CreateAccount, req, this._meta); + return res.toObject(); + } + /** * call the Lit `AddSession` RPC and return the response */ async addSession( label: string, - sessionType: LIT.SessionTypeMap[keyof LIT.SessionTypeMap], + sessionType: SESSION.SessionTypeMap[keyof SESSION.SessionTypeMap], expiry: Date, mailboxServerAddr: string, devServer: boolean, - macaroonCustomPermissions: Array, - ): Promise { - const req = new LIT.AddSessionRequest(); + macaroonCustomPermissions: Array, + accountId: string, + ): Promise { + const req = new SESSION.AddSessionRequest(); req.setLabel(label); req.setSessionType(sessionType); req.setExpiryTimestampSeconds(Math.floor(expiry.getTime() / 1000).toString()); req.setMailboxServerAddr(mailboxServerAddr); req.setDevServer(devServer); req.setMacaroonCustomPermissionsList(macaroonCustomPermissions); + req.setAccountId(accountId); const res = await this._grpc.request(Sessions.AddSession, req, this._meta); return res.toObject(); @@ -42,8 +67,8 @@ class LitApi extends BaseApi { /** * call the Lit `ListSessions` RPC and return the response */ - async listSessions(): Promise { - const req = new LIT.ListSessionsRequest(); + async listSessions(): Promise { + const req = new SESSION.ListSessionsRequest(); const res = await this._grpc.request(Sessions.ListSessions, req, this._meta); return res.toObject(); } @@ -53,8 +78,8 @@ class LitApi extends BaseApi { */ async revokeSession( localPublicKey: string, - ): Promise { - const req = new LIT.RevokeSessionRequest(); + ): Promise { + const req = new SESSION.RevokeSessionRequest(); req.setLocalPublicKey(b64(localPublicKey)); const res = await this._grpc.request(Sessions.RevokeSession, req, this._meta); return res.toObject(); diff --git a/app/src/assets/styles/rc-switch.scss b/app/src/assets/styles/rc-switch.scss new file mode 100644 index 000000000..2afc43b04 --- /dev/null +++ b/app/src/assets/styles/rc-switch.scss @@ -0,0 +1,117 @@ +$switchPrefixCls: rc-switch; + +$duration: 0.3s; + +.rc-switch { + position: relative; + display: inline-block; + box-sizing: border-box; + width: 44px; + height: 22px; + line-height: 20px; + padding: 0; + vertical-align: middle; + border-radius: 20px 20px; + border: 1px solid #ccc; + background-color: #ccc; + cursor: pointer; + transition: all $duration cubic-bezier(0.35, 0, 0.25, 1); + + &-inner { + color: #fff; + font-size: 12px; + position: absolute; + left: 24px; + top: 0; + } + + &:after { + position: absolute; + width: 18px; + height: 18px; + left: 2px; + top: 1px; + border-radius: 50% 50%; + background-color: #fff; + content: ' '; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.26); + transform: scale(1); + transition: left $duration cubic-bezier(0.35, 0, 0.25, 1); + animation-timing-function: cubic-bezier(0.35, 0, 0.25, 1); + animation-duration: $duration; + animation-name: rcSwitchOff; + } + + &:hover:after { + transform: scale(1.1); + animation-name: rcSwitchOn; + } + + &:focus { + box-shadow: 0 0 0 2px tint(#2db7f5, 80%); + outline: none; + } + + &-checked { + border: 1px solid #87d068; + background-color: #87d068; + + .rc-switch-inner { + left: 6px; + } + + &:after { + left: 22px; + } + } + + &-disabled { + cursor: no-drop; + background: #ccc; + border-color: #ccc; + + &:after { + background: #9e9e9e; + animation-name: none; + cursor: no-drop; + } + + &:hover:after { + transform: scale(1); + animation-name: none; + } + } + + &-label { + display: inline-block; + line-height: 20px; + font-size: 14px; + padding-left: 10px; + vertical-align: middle; + white-space: normal; + pointer-events: none; + user-select: text; + } +} + +@keyframes rcSwitchOn { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.25); + } + 100% { + transform: scale(1.1); + } +} + +@keyframes rcSwitchOff { + 0% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } +} diff --git a/app/src/components/base/grid.tsx b/app/src/components/base/grid.tsx index 92baaca4d..51a76d07d 100644 --- a/app/src/components/base/grid.tsx +++ b/app/src/components/base/grid.tsx @@ -1,5 +1,21 @@ import React, { CSSProperties } from 'react'; +/** + * This component represents a container in the bootstrap Grid layout + */ +export const Container: React.FC<{ + className?: string; + style?: CSSProperties; +}> = ({ children, className, style }) => { + const cn: string[] = ['container']; + className && cn.push(className); + return ( +
+ {children} +
+ ); +}; + /** * This component represents a Row in the bootstrap Grid layout */ diff --git a/app/src/components/common/FormDate.tsx b/app/src/components/common/FormDate.tsx new file mode 100644 index 000000000..5a92fffe5 --- /dev/null +++ b/app/src/components/common/FormDate.tsx @@ -0,0 +1,76 @@ +import React, { ReactNode } from 'react'; +import styled from '@emotion/styled'; + +const Styled = { + Wrapper: styled.div` + position: relative; + font-family: ${props => props.theme.fonts.work.light}; + font-weight: 300; + font-size: ${props => props.theme.sizes.s}; + color: ${props => props.theme.colors.offWhite}; + `, + Input: styled.input` + color: ${props => props.theme.colors.offWhite}; + background-color: ${props => props.theme.colors.overlay}; + border-width: 0; + border-bottom: 1px solid ${props => props.theme.colors.gray}; + padding: 5px 40px 5px 5px; + width: 100%; + + &:active, + &:focus { + outline: none; + border-bottom-color: ${props => props.theme.colors.white}; + } + + &::placeholder { + color: ${props => props.theme.colors.gray}; + } + + // Fix color of the date picker icon in chrome + ::-webkit-calendar-picker-indicator { + filter: invert(1); + } + `, + Extra: styled.div` + position: absolute; + top: 0; + right: 0; + background-color: transparent; + padding: 5px; + `, +}; + +interface Props { + label?: string; + value?: string; + extra?: ReactNode; + placeholder?: string; + className?: string; + onChange?: (value: string) => void; +} + +const FormDate: React.FC = ({ + label, + value, + placeholder, + extra, + className, + onChange, +}) => { + const { Wrapper, Input, Extra } = Styled; + return ( + + onChange && onChange(e.target.value)} + placeholder={placeholder} + aria-label={label} + /> + {extra && {extra}} + + ); +}; + +export default FormDate; diff --git a/app/src/components/common/FormField.tsx b/app/src/components/common/FormField.tsx index 0e6a56bc9..f015cee0b 100644 --- a/app/src/components/common/FormField.tsx +++ b/app/src/components/common/FormField.tsx @@ -22,12 +22,13 @@ interface Props { info?: ReactNode; error?: ReactNode; tip?: string; + className?: string; } -const FormField: React.FC = ({ label, info, error, tip, children }) => { +const FormField: React.FC = ({ label, info, error, tip, children, className }) => { const { Wrapper, Info } = Styled; return ( - + {label && ( {label} diff --git a/app/src/components/common/FormInputNumber.tsx b/app/src/components/common/FormInputNumber.tsx index bf9edc9a7..5c834c4dd 100644 --- a/app/src/components/common/FormInputNumber.tsx +++ b/app/src/components/common/FormInputNumber.tsx @@ -7,6 +7,7 @@ interface Props { value?: number; extra?: ReactNode; placeholder?: string; + className?: string; onChange: (value: number) => void; } @@ -15,6 +16,7 @@ const FormInputNumber: React.FC = ({ value, extra, placeholder, + className, onChange, }) => { const handleChange = useCallback( @@ -35,6 +37,7 @@ const FormInputNumber: React.FC = ({ return ( props.theme.colors.offWhite}; + } + `, + BackLink: styled(Button)` + display: inline-block; + position: absolute; + top: 30px; + right: 0px; + left: auto; + z-index: 10; + + @media (${props => props.theme.breakpoints.m}) { + top: 30px; + right: 0px; + } + `, + Title: styled(DisplayLarge)` + font-weight: ${props => props.theme.fonts.open.bold}; + margin-bottom: 16px; + `, + Content: styled.div``, +}; + +interface Props { + title: string; + description: ReactNode; + onBackClick?: () => void; +} + +const OverlayFormWrap: React.FC = ({ + title, + description, + onBackClick, + children, +}) => { + // scroll to the top of the screen when this comp is mounted + useEffect(() => window.scrollTo(0, 0), []); + + const { Wrapper, BackLink, Title, Content } = Styled; + return ( + + {onBackClick && ( + + + + )} + + + + {title} +
+ {description} +
+ {children} +
+
+
+
+ ); +}; + +export default observer(OverlayFormWrap); diff --git a/app/src/components/common/v2/FormSwitch.tsx b/app/src/components/common/v2/FormSwitch.tsx new file mode 100644 index 000000000..e460ab7e8 --- /dev/null +++ b/app/src/components/common/v2/FormSwitch.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { observer } from 'mobx-react-lite'; +import styled from '@emotion/styled'; +import Switch from 'rc-switch'; + +const Styled = { + Wrapper: styled.div``, + Switch: styled(Switch)` + &.rc-switch-checked { + border: 1px solid ${props => props.theme.colors.iris}; + background-color: ${props => props.theme.colors.iris}; + } + `, +}; + +interface Props { + checked?: boolean; + onChange?: (checked: boolean) => void; +} + +const FormSelect: React.FC = ({ checked, onChange }) => { + const { Wrapper, Switch } = Styled; + return ( + + onChange && onChange(v)} /> + + ); +}; + +export default observer(FormSelect); diff --git a/app/src/components/common/v2/Text.tsx b/app/src/components/common/v2/Text.tsx new file mode 100644 index 000000000..8ba2405cb --- /dev/null +++ b/app/src/components/common/v2/Text.tsx @@ -0,0 +1,152 @@ +import styled from '@emotion/styled'; + +interface TextProps { + bold?: boolean; + semiBold?: boolean; + center?: boolean; + block?: boolean; + muted?: boolean; + space?: 4 | 8 | 12 | 16 | 20 | 24 | 32 | 40 | 48 | 56 | 64 | 96 | 120 | 160 | 200; + desktopSpace?: + | 4 + | 8 + | 12 + | 16 + | 20 + | 24 + | 32 + | 40 + | 48 + | 56 + | 64 + | 96 + | 120 + | 160 + | 200; +} + +const BaseText = styled.span` + // On mobile, default to semi-bold when the bold prop is used + font-family: ${props => + props.bold || props.semiBold + ? props.theme.fonts.open.semiBold + : props.theme.fonts.open.regular}; + font-weight: ${props => (props.bold || props.semiBold ? 600 : 400)}; + ${props => props.muted && `color: ${props.theme.colors.gray};`} + ${props => props.space && `margin-bottom: ${props.space}px;`} + text-align: left; + + @media (${props => props.theme.breakpoints.m}) { + ${props => props.desktopSpace && `margin-bottom: ${props.desktopSpace}px;`} + + // On larger devices, make bold elements bold instead of semi-bold + font-family: ${props => + props.bold + ? props.theme.fonts.open.bold + : props.semiBold + ? props.theme.fonts.open.semiBold + : props.theme.fonts.open.regular}; + + // The text-align property is ignored on mobile + text-align: ${props => (props.center ? 'center' : 'left')}; + } +`; + +const BaseBlock = BaseText.withComponent('div'); + +export const Mega = styled(BaseBlock)` + font-size: 40px; + line-height: 48px; + + @media (${props => props.theme.breakpoints.m}) { + font-size: 56px; + line-height: 56px; + } +`; + +export const DisplayLarge = styled(BaseBlock)` + font-size: 32px; + line-height: 40px; + + @media (${props => props.theme.breakpoints.m}) { + font-size: 40px; + line-height: 48px; + } +`; + +export const Display = styled(BaseBlock)` + font-size: 24px; + line-height: 32px; + + @media (${props => props.theme.breakpoints.m}) { + font-size: 32px; + line-height: 40px; + } +`; + +export const DisplaySmall = styled(BaseBlock)` + font-size: 20px; + line-height: 24px; + + @media (${props => props.theme.breakpoints.m}) { + font-size: 24px; + line-height: 32px; + } +`; + +export const Title = styled(BaseBlock)` + font-size: 18px; + line-height: 24px; + + @media (${props => props.theme.breakpoints.m}) { + font-size: 20px; + line-height: 24px; + } +`; + +export const Header = styled(BaseBlock)` + font-size: 16px; + line-height: 24px; + + @media (${props => props.theme.breakpoints.m}) { + font-size: 18px; + line-height: 24px; + } +`; + +export const Paragraph = styled(BaseBlock)` + font-size: 14px; + line-height: 20px; + + @media (${props => props.theme.breakpoints.m}) { + font-size: 16px; + line-height: 24px; + } +`; + +export const Small = styled(BaseText)` + display: ${props => (props.block ? 'block' : 'inline-block')}; + font-size: 14px; + line-height: 20px; +`; + +export const Micro = styled(BaseText)` + display: ${props => (props.block ? 'block' : 'inline-block')}; + font-size: 12px; + line-height: 16px; +`; + +export const Label = styled(BaseText)` + display: ${props => (props.block ? 'block' : 'inline-block')}; + font-size: 14px; + line-height: 16px; + text-transform: uppercase; +`; + +export const Muted = styled.span` + color: ${props => props.theme.colors.gray}; +`; + +export const Highlight = styled.span` + color: ${props => props.theme.colors.white}; +`; diff --git a/app/src/components/connect/AddSession.tsx b/app/src/components/connect/AddSession.tsx index f6e62b22a..7980027f5 100644 --- a/app/src/components/connect/AddSession.tsx +++ b/app/src/components/connect/AddSession.tsx @@ -1,9 +1,7 @@ -import React, { useCallback, useState } from 'react'; +import React from 'react'; import { observer } from 'mobx-react-lite'; -import * as LIT from 'types/generated/lit-sessions_pb'; import styled from '@emotion/styled'; import { usePrefixedTranslation } from 'hooks'; -import { MAX_DATE } from 'util/constants'; import { useStore } from 'store'; import { Button, Column, HeaderFour, Row } from 'components/base'; import FormField from 'components/common/FormField'; @@ -42,31 +40,11 @@ interface Props { const AddSession: React.FC = ({ primary }) => { const { l } = usePrefixedTranslation('cmps.connect.AddSession'); - const { sessionStore } = useStore(); - - const [label, setLabel] = useState(''); - const [permissions, setPermissions] = useState('admin'); - const [editing, setEditing] = useState(false); - - const toggleEditing = useCallback(() => setEditing(e => !e), []); - const handleSubmit = useCallback(async () => { - const sessionType = - permissions === 'admin' - ? LIT.SessionType.TYPE_MACAROON_ADMIN - : LIT.SessionType.TYPE_MACAROON_READONLY; - - const session = await sessionStore.addSession(label, sessionType, MAX_DATE, true); - - if (session) { - setLabel(''); - setEditing(false); - } - }, [label, permissions]); - + const { addSessionView } = useStore(); const { Wrapper, FormHeader, FormInput, FormSelect } = Styled; - if (!editing) { + if (!addSessionView.editing) { return ( - + {l('create')} ); @@ -85,24 +63,33 @@ const AddSession: React.FC = ({ primary }) => { - + - {l('common.submit')} - diff --git a/app/src/components/connect/CustomSessionPage.tsx b/app/src/components/connect/CustomSessionPage.tsx new file mode 100644 index 000000000..973387ce2 --- /dev/null +++ b/app/src/components/connect/CustomSessionPage.tsx @@ -0,0 +1,376 @@ +import React, { FormEvent, useCallback } from 'react'; +import { observer } from 'mobx-react-lite'; +import styled from '@emotion/styled'; +import { useStore } from 'store'; +import { usePrefixedTranslation } from 'hooks'; +import { Collapse } from 'react-collapse'; +import { Column, Row, ChevronUp, ChevronDown } from 'components/base'; +import { Paragraph, Small, Label } from 'components/common/v2/Text'; +import OverlayFormWrap from 'components/common/OverlayFormWrap'; +import FormField from 'components/common/FormField'; +import FormInput from 'components/common/FormInput'; +import FormInputNumber from 'components/common/FormInputNumber'; +import FormSelect from 'components/common/FormSelect'; +import FormDate from 'components/common/FormDate'; +import FormSwitch from 'components/common/v2/FormSwitch'; +import PurpleButton from './PurpleButton'; + +const Styled = { + Wrapper: styled.div` + padding: 150px 0; + background-color: ${props => props.theme.colors.blue}; + `, + PermissionTypes: styled.div``, + PermissionType: styled.div<{ active?: boolean }>` + cursor: pointer; + padding: 8px 16px; + border-radius: 4px; + margin-top: 8px; + transition: background-color 200ms ease-in-out; + + &:hover { + background-color: ${props => props.theme.colors.lightBlue}; + } + + ${props => props.active && `background-color: ${props.theme.colors.lightBlue};`}; + `, + Permissions: styled.div` + background-color: ${props => props.theme.colors.lightningNavy}; + padding: 24px 24px 8px 24px; + border-radius: 4px; + margin-top: 8px; + `, + Permission: styled.div` + display: flex; + justify-content: space-between; + padding-bottom: 16px; + `, + FormSelect: styled(FormSelect)` + .rc-select { + font-family: ${props => props.theme.fonts.open.regular}; + font-size: ${props => props.theme.sizes.m}; + background-color: ${props => props.theme.colors.blue}; + padding: 12px 40px 8px 0px; + } + + .rc-select-selection-item { + padding-left: 0; + margin-left: -2px; + } + `, + FormInput: styled(FormInput)` + input { + font-family: ${props => props.theme.fonts.open.regular}; + font-size: ${props => props.theme.sizes.m}; + background-color: ${props => props.theme.colors.blue}; + padding: 12px 40px 12px 0px; + } + `, + FormInputNumber: styled(FormInputNumber)` + input { + background-color: ${props => props.theme.colors.lightningNavy}; + } + `, + FormDate: styled(FormDate)` + input { + font-family: ${props => props.theme.fonts.open.regular}; + font-size: ${props => props.theme.sizes.m}; + background-color: ${props => props.theme.colors.blue}; + padding: 12px 40px 12px 0px; + margin-top: 26px; + } + `, + Small: styled(Small)` + color: ${props => props.theme.colors.lightningGray}; + `, + Button: styled(PurpleButton)` + margin: 16px 16px 0 0; + `, + ToggleAdvanced: styled(Paragraph)` + cursor: pointer; + `, + ProxyField: styled(FormField)` + margin-top: 16px; + `, +}; + +const CustomSessionPage: React.FC = () => { + const { appView, addSessionView } = useStore(); + const { l } = usePrefixedTranslation('cmps.connect.CustomSessionPage'); + + const handleBack = useCallback(() => { + addSessionView.cancel(); + appView.goTo('/connect'); + }, [appView]); + + const handleSubmit = useCallback(async (event: FormEvent) => { + event.preventDefault(); + addSessionView.handleCustomSubmit(); + }, []); + + const setPermissionType = (permissionType: string) => { + return () => { + addSessionView.setPermissionType(permissionType); + }; + }; + + const togglePermission = (permission: string) => { + return () => { + addSessionView.togglePermission(permission); + }; + }; + + const { + Wrapper, + PermissionTypes, + PermissionType, + Permissions, + Permission, + FormSelect, + FormInput, + FormInputNumber, + FormDate, + Small, + Button, + ToggleAdvanced, + ProxyField, + } = Styled; + return ( + + +
+ + + + + + + + + + {addSessionView.expiration === 'custom' ? ( + + + + + + ) : null} + + + + + + + + + {l('admin')} + {l('adminDesc')} + + + + {l('readonly')} + {l('readonlyDesc')} + + + + {l('liquidity')} + {l('liquidityDesc')} + + + + {l('payments')} + {l('paymentsDesc')} + + + + {l('custodial')} + {l('custodialDesc')} + + + + {l('custom')} + {l('customDesc')} + + + + + + + + + {addSessionView.permissionType === 'custodial' && ( + + + + sats} + /> + + )} + + +
+ {l('permView')} + {l('permViewDesc')} +
+ + +
+ + +
+ {l('permOpen')} + {l('permOpenDesc')} +
+ + +
+ + +
+ {l('permClose')} + {l('permCloseDesc')} +
+ + +
+ + +
+ {l('permFees')} + {l('permFeesDesc')} +
+ + +
+ + +
+ {l('permLoop')} + {l('permLoopDesc')} +
+ + +
+ + +
+ {l('permPool')} + {l('permPoolDesc')} +
+ + +
+ + +
+ {l('permSend')} + {l('permSendDesc')} +
+ + +
+ + +
+ {l('permReceive')} + {l('permReceiveDesc')} +
+ + +
+
+
+
+ + + {addSessionView.showAdvanced ? ( + + ) : ( + + )} + {l('advanced')} + + + + + + + + {l('proxyDesc')} + + + + + +
+
+
+ ); +}; + +export default observer(CustomSessionPage); diff --git a/app/src/components/connect/SessionRow.tsx b/app/src/components/connect/SessionRow.tsx index 51987fdd2..193fd5fd9 100644 --- a/app/src/components/connect/SessionRow.tsx +++ b/app/src/components/connect/SessionRow.tsx @@ -7,6 +7,7 @@ import { Session } from 'store/models'; import { BoltOutlined, Close, Column, Copy, QRCode, Row } from 'components/base'; import SortableHeader from 'components/common/SortableHeader'; import Tip from 'components/common/Tip'; +import * as LIT from 'types/generated/lit-sessions_pb'; import QRCodeModal from './QRCodeModal'; /** @@ -151,6 +152,18 @@ const SessionRow: React.FC = ({ session, style }) => { + ) : session.type === LIT.SessionType.TYPE_MACAROON_ACCOUNT ? ( + <> + + + + + + + + + + ) : ( <> diff --git a/app/src/components/layout/Layout.tsx b/app/src/components/layout/Layout.tsx index 853a6bf36..1673a7f7d 100644 --- a/app/src/components/layout/Layout.tsx +++ b/app/src/components/layout/Layout.tsx @@ -50,6 +50,10 @@ const GlobalStyles = (theme: Theme) => ` right: 12px; } } + + .ReactCollapse--collapse { + transition: height 500ms; + } `; const Styled = { diff --git a/app/src/components/theme.tsx b/app/src/components/theme.tsx index 666f0bd33..a62de4619 100644 --- a/app/src/components/theme.tsx +++ b/app/src/components/theme.tsx @@ -45,6 +45,15 @@ const theme: Theme = { lightBlue: '#384770', paleBlue: '#2E3A5C', lightningRed: '#EF4444', + lightningGray: '#B9BDC5', + lightningNavy: '#1D253A', + iris: '#5D5FEF', + }, + breakpoints: { + s: 'min-width: 576px', + m: 'min-width: 768px', + l: 'min-width: 992px', + xl: 'min-width: 1200px', }, }; diff --git a/app/src/emotion-theme.d.ts b/app/src/emotion-theme.d.ts index 4d155d42c..83e2ca06c 100644 --- a/app/src/emotion-theme.d.ts +++ b/app/src/emotion-theme.d.ts @@ -41,6 +41,15 @@ declare module '@emotion/react' { lightBlue: string; paleBlue: string; lightningRed: string; + lightningGray: string; + lightningNavy: string; + iris: string; + }; + breakpoints: { + s: string; + m: string; + l: string; + xl: string; }; } } diff --git a/app/src/i18n/locales/en-US.json b/app/src/i18n/locales/en-US.json index f4d960d53..a6bfb67e1 100644 --- a/app/src/i18n/locales/en-US.json +++ b/app/src/i18n/locales/en-US.json @@ -37,6 +37,47 @@ "cmps.connect.AddSession.labelHint": "My First Session", "cmps.connect.AddSession.expiration": "Expiration", "cmps.connect.AddSession.expirationSuffix": "", + "cmps.connect.AddSession.admin": "Admin", + "cmps.connect.AddSession.readonly": "Read Only", + "cmps.connect.AddSession.custom": "Custom", + "cmps.connect.CustomSessionPage.title": "Custom Permissions", + "cmps.connect.CustomSessionPage.description": "Choose session type or create custom sessions.", + "cmps.connect.CustomSessionPage.expiration": "Expiration", + "cmps.connect.CustomSessionPage.date": "mm/dd/yyyy", + "cmps.connect.CustomSessionPage.permissionType": "Permission Type", + "cmps.connect.CustomSessionPage.admin": "Admin", + "cmps.connect.CustomSessionPage.adminDesc": "User has all permissions.", + "cmps.connect.CustomSessionPage.readonly": "Read-Only", + "cmps.connect.CustomSessionPage.readonlyDesc": "User can only view node data, not take any actions.", + "cmps.connect.CustomSessionPage.liquidity": "Liquidity Manager", + "cmps.connect.CustomSessionPage.liquidityDesc": "User can only set fees, use Loop, and use Pool.", + "cmps.connect.CustomSessionPage.payments": "Payments Manager", + "cmps.connect.CustomSessionPage.paymentsDesc": "User can only send and receive payments.", + "cmps.connect.CustomSessionPage.custodial": "Custodial Account", + "cmps.connect.CustomSessionPage.custodialDesc": "Create a custodial off-chain account for your node.", + "cmps.connect.CustomSessionPage.custom": "Custom", + "cmps.connect.CustomSessionPage.customDesc": "Create a session with fully custom permissions.", + "cmps.connect.CustomSessionPage.permissions": "Permissions", + "cmps.connect.CustomSessionPage.addBalance": "Add Balance", + "cmps.connect.CustomSessionPage.permView": "View Activity", + "cmps.connect.CustomSessionPage.permViewDesc": "See node history and activity.", + "cmps.connect.CustomSessionPage.permOpen": "Open Channel", + "cmps.connect.CustomSessionPage.permOpenDesc": "Open a channel to another peer.", + "cmps.connect.CustomSessionPage.permClose": "Close Channel", + "cmps.connect.CustomSessionPage.permCloseDesc": "Close a channel to another peer.", + "cmps.connect.CustomSessionPage.permFees": "Set Fees", + "cmps.connect.CustomSessionPage.permFeesDesc": "Set fees for your channels.", + "cmps.connect.CustomSessionPage.permLoop": "Loop", + "cmps.connect.CustomSessionPage.permLoopDesc": "Use Loop to manage liquidity.", + "cmps.connect.CustomSessionPage.permPool": "Pool", + "cmps.connect.CustomSessionPage.permPoolDesc": "Buy and sell liqudiity in Pool marketplace.", + "cmps.connect.CustomSessionPage.permSend": "Send", + "cmps.connect.CustomSessionPage.permSendDesc": "Send funds from this node.", + "cmps.connect.CustomSessionPage.permReceive": "Receive", + "cmps.connect.CustomSessionPage.permReceiveDesc": "Receive funds on this node.", + "cmps.connect.CustomSessionPage.advanced": "Advanced Options", + "cmps.connect.CustomSessionPage.proxy": "Proxy Server", + "cmps.connect.CustomSessionPage.proxyDesc": "Specify a custom Lightning Node Connect proxy server", "cmps.connect.ConnectPage.pageTitle": "Lightning Node Connect", "cmps.connect.ConnectPage.description": "Lightning Node Connect enables you to connect to this node from the web.", "cmps.connect.SessionRowHeader.label": "Label", @@ -48,6 +89,7 @@ "cmps.connect.SessionRow.pairTerminal": "Pair with Lightning Terminal", "cmps.connect.SessionRow.generateQR": "Generate QR Code", "cmps.connect.SessionRow.revoke": "Revoke Session", + "cmps.connect.SessionRow.pairCustodial": "Custodial accounts can not be used with Terminal", "cmps.connect.QRCodeModal.title": "LNC QR", "cmps.connect.QRCodeModal.desc": "Scan to connect to Terminal from your mobile phone.", "cmps.home.HomePage.pageTitle": "Home", diff --git a/app/src/store/models/session.ts b/app/src/store/models/session.ts index 2013a2b38..f1840d586 100644 --- a/app/src/store/models/session.ts +++ b/app/src/store/models/session.ts @@ -59,6 +59,8 @@ export default class Session { return 'Admin'; case LIT.SessionType.TYPE_MACAROON_CUSTOM: return 'Custom'; + case LIT.SessionType.TYPE_MACAROON_ACCOUNT: + return 'Custodial'; case LIT.SessionType.TYPE_UI_PASSWORD: return 'LiT UI Password'; } @@ -96,7 +98,7 @@ export default class Session { /** The HEX encoded pairing secret mnemonic and mailbox server address */ get encodedPairingData() { - const data = `${this.pairingSecretMnemonic}||${this.mailboxServerAddr}`; + const data = `${this.pairingSecretMnemonic}||${this.mailboxServerAddr}||${this.typeLabel}`; return Buffer.from(data, 'ascii').toString('base64'); } diff --git a/app/src/store/store.ts b/app/src/store/store.ts index 29297f0cf..cd4485427 100644 --- a/app/src/store/store.ts +++ b/app/src/store/store.ts @@ -20,6 +20,7 @@ import { } from './stores'; import { AccountSectionView, + AddSessionView, AppView, BatchesView, BuildSwapView, @@ -66,6 +67,7 @@ export class Store { orderListView = new OrderListView(this); batchesView = new BatchesView(this); registerSidecarView = new RegisterSidecarView(this); + addSessionView = new AddSessionView(this); /** the backend api services to be used by child stores */ api: { diff --git a/app/src/store/stores/sessionStore.ts b/app/src/store/stores/sessionStore.ts index d7adbfd8d..10aecd13f 100644 --- a/app/src/store/stores/sessionStore.ts +++ b/app/src/store/stores/sessionStore.ts @@ -102,6 +102,9 @@ export default class SessionStore { type: LIT.SessionTypeMap[keyof LIT.SessionTypeMap], expiry: Date, copy = false, + proxy?: string, + customPermissions?: LIT.MacaroonPermission[], + accountId?: string, ) { try { this._store.log.info(`submitting session with label ${label}`, { @@ -114,9 +117,10 @@ export default class SessionStore { label, type, expiry, - this.proxyServer, + proxy || this.proxyServer, !IS_PROD, - [], + customPermissions || [], + accountId || '', ); // fetch all sessions to update the store's state diff --git a/app/src/store/views/addSessionView.ts b/app/src/store/views/addSessionView.ts new file mode 100644 index 000000000..86660276e --- /dev/null +++ b/app/src/store/views/addSessionView.ts @@ -0,0 +1,277 @@ +import { makeAutoObservable, observable } from 'mobx'; +import { Store } from 'store'; +import * as LIT from 'types/generated/lit-sessions_pb'; +import { MAX_DATE, PermissionUriMap } from 'util/constants'; + +export default class AddSessionView { + private _store: Store; + + label = ''; + permissionType = 'admin'; // Expected values: admin | read-only | custodial | custom | liquidity | payments + editing = false; + permissions: { [key: string]: boolean } = { + openChannel: false, + closeChannel: false, + setFees: false, + loop: false, + pool: false, + send: false, + receive: false, + }; + expiration = 'never'; // Expected values: 7 | 30 | 60 | 90 | never | custom + expirationOptions = [ + { label: '7 Days', value: '7' }, + { label: '30 Days', value: '30' }, + { label: '60 Days', value: '60' }, + { label: '90 Days', value: '90' }, + { label: 'Never', value: 'never' }, + { label: 'Custom', value: 'custom' }, + ]; + expirationDate = ''; + showAdvanced = false; + proxy = ''; + custodialBalance = 0; + + constructor(store: Store) { + makeAutoObservable( + this, + { + permissions: observable.deep, + }, + { deep: false, autoBind: true }, + ); + + this._store = store; + } + + // + // Computed properties + // + + get sessionType() { + if (this.permissionType === 'admin') { + return LIT.SessionType.TYPE_MACAROON_ADMIN; + } else if (this.permissionType === 'read-only') { + return LIT.SessionType.TYPE_MACAROON_READONLY; + } else if (this.permissionType === 'custodial') { + return LIT.SessionType.TYPE_MACAROON_ACCOUNT; + } + + return LIT.SessionType.TYPE_MACAROON_CUSTOM; + } + + get sessionDate() { + // If the expiration date is a number of days + if (Number.isInteger(parseInt(this.expiration))) { + const expirationDate = new Date(); + expirationDate.setDate(expirationDate.getDate() + parseInt(this.expiration)); + return expirationDate; + } else if (this.expiration === 'custom') { + return new Date(this.expirationDate); + } + + // Default to max date for when the expiration is "never" + return MAX_DATE; + } + + get sessionProxy() { + if (this.proxy) { + return this.proxy; + } + + return undefined; + } + + get getMacaroonPermissions() { + // Only output macaroon permissions when the session type is custom + if (this.sessionType === LIT.SessionType.TYPE_MACAROON_CUSTOM) { + // Include all read-only URIs by default + const permissions: string[] = ['***readonly***']; + + // Loop over all permissions to determine which are enabled + Object.entries(this.permissions).forEach(([permissionName, permissionEnabled]) => { + if (permissionEnabled) { + // Add all of the URIs for this permission + permissions.push(...PermissionUriMap[permissionName]); + } + }); + + // Convert all of the permission strings into MacaroonPermission objects + return permissions.map(uri => { + const mp = new LIT.MacaroonPermission(); + mp.setEntity('uri'); + mp.setAction(uri); + return mp; + }); + } + + return []; + } + + // + // Actions + // + + setLabel(label: string) { + this.label = label; + } + + setExpiration(expiration: string) { + this.expiration = expiration; + } + + setExpirationDate(expirationDate: string) { + this.expirationDate = expirationDate; + } + + setProxy(proxy: string) { + this.proxy = proxy; + } + + setCustodialBalance(balance: number) { + this.custodialBalance = balance; + } + + setPermissionType(permissionType: string) { + this.permissionType = permissionType; + + switch (permissionType) { + case 'admin': + this.setAllPermissions(true); + break; + + case 'read-only': + this.setAllPermissions(false); + break; + + case 'liquidity': + this.setAllPermissions(false); + this.permissions.setFees = true; + this.permissions.loop = true; + this.permissions.pool = true; + break; + + case 'payments': + this.setAllPermissions(false); + this.permissions.send = true; + this.permissions.receive = true; + break; + + case 'custodial': + this.setAllPermissions(false); + this.permissions.send = true; + this.permissions.receive = true; + break; + + case 'custom': + // We don't need to change anything, let the user customize permissions how they want + break; + } + } + + togglePermission(permission: string) { + this.setPermissionType('custom'); + this.permissions[permission] = !this.permissions[permission]; + } + + toggleEditing() { + this.editing = !this.editing; + } + + toggleAdvanced() { + this.showAdvanced = !this.showAdvanced; + } + + cancel() { + this.label = ''; + this.permissionType = 'admin'; + this.editing = false; + this.setAllPermissions(false); + this.expiration = 'never'; + this.showAdvanced = false; + this._store.settingsStore.sidebarVisible = true; + } + + // + // Async Actions + // + + async handleSubmit() { + if (this.permissionType === 'custom') { + this._store.settingsStore.sidebarVisible = false; + this._store.router.push('/connect/custom'); + } else { + const session = await this._store.sessionStore.addSession( + this.label, + this.sessionType, + MAX_DATE, + true, + ); + + if (session) { + this.cancel(); + } + } + } + + async handleCustomSubmit() { + let label = this.label; + let accountId = ''; + + // Automatically generate human friendly labels for custom sessions + if (label === '') { + label = `My ${this.permissionType} session`; + } + + if (this.permissionType === 'custodial') { + const custodialAccountId = await this.registerCustodialAccount(); + + // Return immediately to prevent a session being created when there is an error creating the custodial account + if (!custodialAccountId) { + return; + } + + accountId = custodialAccountId; + } + + const session = await this._store.sessionStore.addSession( + label, + this.sessionType, + this.sessionDate, + true, + this.sessionProxy, + this.getMacaroonPermissions, + accountId, + ); + + if (session) { + this.cancel(); + this._store.router.push('/connect'); + } + } + + async registerCustodialAccount(): Promise { + try { + const response = await this._store.api.lit.createAccount( + this.custodialBalance, + this.sessionDate, + ); + + if (response.account) { + return response.account.id; + } + } catch (error) { + this._store.appView.handleError(error, 'Unable to register custodial account'); + } + } + + // + // Private helper functions + // + + private setAllPermissions(value: boolean) { + Object.keys(this.permissions).forEach(permissionName => { + this.permissions[permissionName] = value; + }); + } +} diff --git a/app/src/store/views/index.ts b/app/src/store/views/index.ts index f988e459a..8d0670cf6 100644 --- a/app/src/store/views/index.ts +++ b/app/src/store/views/index.ts @@ -10,3 +10,4 @@ export { default as OrderListView } from './orderListView'; export { default as LeaseView } from './leaseView'; export { default as BatchesView } from './batchesView'; export { default as RegisterSidecarView } from './registerSidecarView'; +export { default as AddSessionView } from './addSessionView'; diff --git a/app/src/util/constants.ts b/app/src/util/constants.ts index cc9091a44..135662806 100644 --- a/app/src/util/constants.ts +++ b/app/src/util/constants.ts @@ -83,3 +83,35 @@ export const BitcoinExplorerPresets: Record = { export const LightningExplorerPresets: Record = { '1ml.com': 'https://1ml.com/node/{pubkey}', }; + +/** A map of all the necessary URIs for each set of features */ +export const PermissionUriMap: { [key: string]: string[] } = { + openChannel: [ + '/lnrpc.Lightning/OpenChannel', + '/lnrpc.Lightning/BatchOpenChannel', + '/lnrpc.Lightning/OpenChannelSync', + ], + closeChannel: ['/lnrpc.Lightning/CloseChannel'], + setFees: [ + '/lnrpc.Lightning/EstimateFee', + '/lnrpc.Lightning/FeeReport', + '/lnrpc.Lightning/UpdateChannelPolicy', + ], + loop: ['^/looprpc\\.SwapClient/.*$'], + pool: ['^/poolrpc\\.Trader/.*$'], + send: [ + '/lnrpc.Lightning/SendCoins', + '/lnrpc.Lightning/SendMany', + '/lnrpc.Lightning/SendPayment', + '/lnrpc.Lightning/SendPaymentSync', + '/lnrpc.Lightning/SendToRoute', + '/lnrpc.Lightning/SendToRouteSync', + ], + receive: [ + '/lnrpc.Lightning/NewAddress', + '/lnrpc.Lightning/AddInvoice', + '/lnrpc.Lightning/LookupInvoice', + '/lnrpc.Lightning/ListInvoices', + '/lnrpc.Lightning/SubscribeInvoices', + ], +}; diff --git a/app/yarn.lock b/app/yarn.lock index 0b99bf8db..dfd39e5a8 100644 --- a/app/yarn.lock +++ b/app/yarn.lock @@ -3978,6 +3978,13 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== +"@types/react-collapse@^5.0.1": + version "5.0.1" + resolved "https://registry.yarnpkg.com/@types/react-collapse/-/react-collapse-5.0.1.tgz#078ea1ad15e00ba2063f2e4d8d6760c9375a2023" + integrity sha512-Iq3OrqvzCIP0DmAawU4T2VKH6XAplbjo/D7Qk14mcfQ92plU+OrA2SF10r2XrcFg1Wvya/5f8w1vS29RVpdoLQ== + dependencies: + "@types/react" "*" + "@types/react-dom@17.0.8": version "17.0.8" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.8.tgz#3180de6d79bf53762001ad854e3ce49f36dd71fc" @@ -13000,6 +13007,15 @@ rc-select@11.5.0: rc-virtual-list "^3.2.0" warning "^4.0.3" +rc-switch@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/rc-switch/-/rc-switch-4.0.0.tgz#55fbf99fc2d680791175037d379e170ba51fbe78" + integrity sha512-IfrYC99vN0gKaTyjQdqYuADU0eH00SAFHg3jOp8HrmUpJruhV1SohJzrCbPqPraZeX/6X/QKkdLfkdnUub05WA== + dependencies: + "@babel/runtime" "^7.10.1" + classnames "^2.2.1" + rc-util "^5.0.1" + rc-tooltip@4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/rc-tooltip/-/rc-tooltip-4.2.1.tgz#c1a2d5017ee03a771a9301c0dfdb46dfdf8fef94" @@ -13076,6 +13092,11 @@ react-clientside-effect@^1.2.2: dependencies: "@babel/runtime" "^7.12.13" +react-collapse@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/react-collapse/-/react-collapse-5.1.1.tgz#a2fa08ef13f372141b02e6a7d49ef72427bcbc2b" + integrity sha512-k6cd7csF1o9LBhQ4AGBIdxB60SUEUMQDAnL2z1YvYNr9KoKr+nDkhN6FK7uGaBd/rYrYfrMpzpmJEIeHRYogBw== + react-dev-utils@^12.0.1: version "12.0.1" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.1.tgz#ba92edb4a1f379bd46ccd6bcd4e7bc398df33e73"