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}
+
+ {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 (
+
+
+
+
+
+ );
+};
+
+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"