diff --git a/app/global.d.ts b/app/global.d.ts index ead47d2..fc318d5 100644 --- a/app/global.d.ts +++ b/app/global.d.ts @@ -1 +1,8 @@ /// +/// + +enum TabView { + Settings, + Contacts, + About, +} diff --git a/app/setting/components/TabContent.js b/app/setting/components/TabContent.js new file mode 100644 index 0000000..992c251 --- /dev/null +++ b/app/setting/components/TabContent.js @@ -0,0 +1,42 @@ +import { ContactList } from './contactList'; +import { + createAuthView, + createShareEmailInput, + createSignOutButton, +} from '../util/createViews'; +import { useSettings } from '../context/SettingsContext'; + +// This file should be used to define the different tabs shown in settings. +// The tabs are defined as functions that return the pseudo-JSX for the tab content. + +export const SettingsTab = () => { + const store = useSettings(); + const isUserSignedIn = !!store.getAuthToken(); + return View( + { style: { display: 'flex', flexDirection: 'column', gap: '10px' } }, + [ + isUserSignedIn ? createShareEmailInput() : createAuthView(), + isUserSignedIn && View({}, createSignOutButton()), + ], + ); +}; + +export const ContactsTab = () => { + const store = useSettings(); + if (!store.getAuthToken()) { + console.error('No access token found'); + return Text({}, 'No access token found. Please sign in first.'); + } + const items = ContactList(); + return items.length + ? items + : Text({}, 'No contacts found. Add some in your Settings tab.'); +}; + +export const AboutTab = () => Text({ style: { fontSize: '12px' } }, 'TODO'); + +export const TAB_COMPONENTS = { + Settings: SettingsTab, + Contacts: ContactsTab, + About: AboutTab, +}; diff --git a/app/setting/components/button.js b/app/setting/components/button.js index 616aa4e..87d8e4d 100644 --- a/app/setting/components/button.js +++ b/app/setting/components/button.js @@ -1,4 +1,4 @@ -export default PrimaryButton = ({ label, onClick }) => { +export const PrimaryButton = ({ label, onClick }) => { return Button({ label, onClick, @@ -11,3 +11,23 @@ export default PrimaryButton = ({ label, onClick }) => { }, }); }; + +export const XButton = (onClick) => { + return Button({ + label: Image({ style: { width: '10px' }, src: xb64 }), + onClick, + style: { + background: '#FF0000', + borderRadius: '12px', + boxShadow: 'none', + color: '#FFF', + fontSize: '12px', + minWidth: '16px', + minHeight: '16px', + padding: '0', + }, + }); +}; + +const xb64 = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAA7EAAAOxAGVKw4bAAABdklEQVRIiZ2V3YrCMBBGi+xFKcUHK1L27cQLKRKWRUR8NFl63e/sRRONbTqNDgRCO7+HzExRRALsJHVAXbwpQA04SW3qZyGpAXpgAK5A9YbzytsM3kcDPBW887skIrnkBAEqSefYUNIdaIJCC/SSCAHCXdIN2C45l1RLui7Y9kBbAHtfWkoG4CZpVskES9JW0iEoujiLhLzg8jbnadaTCtzDBiiB44rBDdgyvpZr9D11OqCc8iwBt4bLHwuLmzmflO5C5kHeuD+xGM8uB1ceFiuIL3UJRT4WI0gurh8Ly8aIsWF8MXHQ2d07//oke6uJXhCx0IyW8/OSNwPXZTWINVty7ubsysAyMHbwZUVnjgs/co1ZhP9fS6qB32xcQA6WE9E7Z2zGLgsX6/Mn2USS1ppxAFwhqWVcc6lxbc4W7Gbsge+g2Ei6x2UCXSrzRJBS0nFi+wfsYqWCcVH3fDBbiGaXX5U74qUfKbbAwcJiBKkk7R9YvPwD3quO5XgK8UIAAAAASUVORK5CYII='; diff --git a/app/setting/components/contactList.js b/app/setting/components/contactList.js new file mode 100644 index 0000000..1031de4 --- /dev/null +++ b/app/setting/components/contactList.js @@ -0,0 +1,48 @@ +import { useSettings } from '../context/SettingsContext'; +import { removeFilePermissionById } from '../util/google'; +import { XButton } from './button'; + +/** + * Creates a list of Contact elements to display + * @param {object} contacts list of contacts + * @returns {Array} list of Contact elements to display + */ +export const ContactList = () => { + const settings = useSettings(); + const accessToken = settings.getAuthToken(); + + if (!accessToken) { + console.error('No access token found'); + return []; + } + const email = settings.getEmail(); + const contacts = settings.getSetting(`contactsList_${email}`) || {}; + + const contactsMap = new Map(Object.entries(contacts)); + const list = []; + for (const [contact, permissionId] of contactsMap) { + list.push( + Contact(contact, () => { + removeFilePermissionById(permissionId, accessToken).then((result) => { + if (result.success) { + console.log('Successfully removed contact:', contact); + contactsMap.delete(contact); + const updatedContacts = Object.fromEntries(contactsMap); + settings.setSetting(`contactsList_${email}`, updatedContacts); + } else { + console.error('Failed to remove contact:', contact); + } + }); + }), + ); + } + + return list; +}; + +const Contact = (contact, onClick) => { + return View({ style: { display: 'flex', gap: '10px' } }, [ + contact, + XButton(onClick), + ]); +}; diff --git a/app/setting/components/tabs.js b/app/setting/components/tabs.js new file mode 100644 index 0000000..26627bb --- /dev/null +++ b/app/setting/components/tabs.js @@ -0,0 +1,36 @@ +import { useSettings } from '../context/SettingsContext'; + +const tabs = ['Settings', 'Contacts', 'About']; + +/** + * Create the tabs for the settings page + * @param {string} activeTab the currently active tab + * @param {*} setSetting function to set the active tab + * @returns + */ +export const Tabs = () => { + const store = useSettings(); + const activeTab = store.getState().activeTab; + const tabButtons = tabs.map((tabName) => { + const isActive = tabName === activeTab; + return Tab(tabName, isActive, () => store.setState('activeTab', tabName)); + }); + + return View({ style: { display: 'flex', marginBottom: '15px' } }, tabButtons); +}; + +const Tab = (label, isActive, onClick) => { + const btn = Button({ + label, + onClick, + style: { + flex: '1', + boxShadow: 'none', + background: isActive ? '#000' : '#FFF', + color: isActive ? '#FFF' : '#000', + display: 'inline', + fontSize: '12px', + }, + }); + return btn; +}; diff --git a/app/setting/components/textInput.js b/app/setting/components/textInput.js new file mode 100644 index 0000000..8143755 --- /dev/null +++ b/app/setting/components/textInput.js @@ -0,0 +1,25 @@ +export const Input = (label, placeholder, onChange) => { + return TextInput({ + label, + placeholder, + onChange, + subStyle: { + border: 'thin rgba(0,0,0,0.1) solid', + borderRadius: '8px', + boxSizing: 'content-box', + color: '#000', + height: '.8em', + lineHeight: '1.5em', + marginTop: '-16px', + padding: '8px', + paddingTop: '1.2em', + }, + labelStyle: { + color: '#555', + fontSize: '0.8em', + paddingLeft: '8px', + position: 'relative', + top: '0.2em', + }, + }); +}; diff --git a/app/setting/components/toast.js b/app/setting/components/toast.js new file mode 100644 index 0000000..9ddf72e --- /dev/null +++ b/app/setting/components/toast.js @@ -0,0 +1,6 @@ +export const VisibleToast = (message) => { + return Toast({ + message, + visible: true, + }); +}; diff --git a/app/setting/context/SettingsContext.js b/app/setting/context/SettingsContext.js new file mode 100644 index 0000000..543b55c --- /dev/null +++ b/app/setting/context/SettingsContext.js @@ -0,0 +1,41 @@ +/** + * This file contains the settings context and the settings store. + * The settings store is used to manage the settings of the app + * and provide context to the settings components. + * @param {object} storage settingsStorage object + * @returns + */ +let globalSettingsStore = null; + +export const createSettingsStore = ({ + settings, + settingsStorage: settingsStore, +}) => { + const store = { + setState(key, value) { + settingsStore.setItem(key, value); + }, + getState() { + return settings; + }, + setSetting: (key, value) => settingsStore.setItem(key, value), + getSetting: (key) => settingsStore.getItem(key), + getAuthToken: () => { + const authData = JSON.parse(settingsStore.getItem('googleAuthData')); + return authData?.access_token || ''; + }, + getEmail: () => { + const authData = JSON.parse(settingsStore.getItem('googleAuthData')); + return authData?.email; + }, + }; + globalSettingsStore = store; + return store; +}; + +export const useSettings = () => { + if (!globalSettingsStore) { + throw new Error('Settings store must be initialized before use'); + } + return globalSettingsStore; +}; diff --git a/app/setting/index.js b/app/setting/index.js index bd5675d..78cfbcf 100644 --- a/app/setting/index.js +++ b/app/setting/index.js @@ -1,9 +1,6 @@ -import { - GOOGLE_API_CLIENT_ID, - GOOGLE_API_CLIENT_SECRET, - GOOGLE_API_REDIRECT_URI, -} from '../google-api-constants'; -import PrimaryButton from './components/button'; +import { TAB_COMPONENTS } from './components/TabContent'; +import { Tabs } from './components/tabs'; +import { createSettingsStore } from './context/SettingsContext'; AppSettingsPage({ state: { @@ -28,129 +25,36 @@ AppSettingsPage({ if (this.isTokenExpired() || !this.state.googleAuthData) { this.state.googleAuthData = null; } + + if (!props.settingsStorage.getItem('activeTab')) { + props.settingsStorage.setItem('activeTab', 'Settings'); + } console.log('state:', this.state); }, build(props) { this.setState(props); - - const nowTag = new Date().toISOString().substring(0, 19); - if (props.settingsStorage.getItem('now') !== nowTag) - props.settingsStorage.setItem('now', nowTag); - - const userSignedIn = !!this.state.googleAuthData; - const signInBtn = PrimaryButton({ - label: 'Sign in', - }); - - const clearBtn = PrimaryButton({ - label: 'Clear', - onClick: () => { - console.log( - 'before clear', - this.state.props.settingsStorage.toObject(), - ); - // this.state.props.settingsStorage.clear(); - props.settingsStorage.setItem('googleAuthData', null); - props.settingsStorage.setItem('googleAuthCode', null); - this.state.googleAuthData = ''; - console.log('after clear', this.state.props.settingsStorage.toObject()); - }, - }); - - const clearDiv = View( - { - style: { - display: 'inline', - }, - }, - clearBtn, - ); - - const shareEmailInput = TextInput({ - label: 'Share with others', - placeholder: 'Enter email address...', - onChange: async (value) => { - console.log('emailInput', value); - await shareFilesWithEmail( - value, - this.state.googleAuthData.access_token, - ); - }, - subStyle: { - border: 'thin rgba(0,0,0,0.1) solid', - borderRadius: '8px', - boxSizing: 'content-box', - color: '#000', - height: '1.5em', - lineHeight: '1.5em', - marginTop: '-16px', - padding: '8px', - paddingTop: '1.2em', - }, - labelStyle: { - color: '#555', - fontSize: '0.8em', - paddingLeft: '8px', - position: 'relative', - top: '0.2em', - }, - }); - - const tt = Text( + const store = createSettingsStore(props); + const currentTab = store.getState().activeTab || 'Settings'; + const TabComponent = TAB_COMPONENTS[currentTab]; + return Section( { style: { - fontSize: '12px', - marginTop: '10px', + padding: '10px', }, }, - `Google Auth Data: ${JSON.stringify(this.state.googleAuthData)}`, - ); - - const authDiv = Auth({ - label: signInBtn, - authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', - requestTokenUrl: 'https://oauth2.googleapis.com/token', - scope: 'https://www.googleapis.com/auth/drive', - clientId: GOOGLE_API_CLIENT_ID, - clientSecret: GOOGLE_API_CLIENT_SECRET, - oAuthParams: { - redirect_uri: GOOGLE_API_REDIRECT_URI, - response_type: 'code', - include_granted_scopes: 'true', - access_type: 'offline', - prompt: 'consent', - }, - onAccessToken: (token) => { - console.log('onAccessToken', token); - }, - onReturn: async (authBody) => { - console.log('onReturn', authBody); - // this.state.props.settingsStorage.setItem('googleAuthCode', authBody.code) - const authData = await requestGoogleAuthData(authBody); - authData.requested_at = new Date(); - authData.expires_at = new Date( - authData.requested_at.getTime() + authData.expires_in * 1000, - ); - this.state.props.settingsStorage.setItem( - 'googleAuthData', - JSON.stringify(authData), - ); - console.log('authData', this.state.googleAuthData); - }, - }); - - const signedInView = [clearDiv, shareEmailInput]; - - return View( - { - style: { - padding: '20px', - display: 'flex', - flexDirection: 'column', - gap: '10px', - }, - }, - [userSignedIn ? signedInView : authDiv], + [ + Tabs(), + View( + { + style: { + display: 'flex', + flexDirection: 'column', + gap: '10px', + }, + }, + TabComponent(), + ), + ], ); }, @@ -164,56 +68,3 @@ AppSettingsPage({ return now >= expiresAt; }, }); - -/** - * Request Google Auth Data from Google API after receiving auth code - * @param authResponse the auth code from Google API - * @returns access token and other data for using API - */ -const requestGoogleAuthData = async (authResponse) => { - const params = { - grant_type: 'authorization_code', - client_id: GOOGLE_API_CLIENT_ID, - client_secret: GOOGLE_API_CLIENT_SECRET, - redirect_uri: GOOGLE_API_REDIRECT_URI, - code: authResponse.code, - }; - - const body = Object.keys(params) - .map( - (key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`, - ) - .join('&'); - - // console.log(body) - const data = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: body, - }); - return await data.json(); -}; - -const shareFilesWithEmail = async (address, accessToken) => { - const fileId = '1e40yZOhM5_Wd5IQkwVJpPh23pohGg,RiN3Ayp4fxYtzU'; // todo remove hardcode, share with folder - const body = JSON.stringify({ - role: 'reader', - type: 'user', - value: address, - }); - - const response = await fetch( - `https://www.googleapis.com/drive/v2/files/${fileId}/permissions`, - { - method: 'POST', - body, - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }, - ); - return response; -}; diff --git a/app/setting/util/createViews.js b/app/setting/util/createViews.js new file mode 100644 index 0000000..4a04cbf --- /dev/null +++ b/app/setting/util/createViews.js @@ -0,0 +1,84 @@ +import { Buffer } from 'buffer'; +import { + GOOGLE_API_CLIENT_ID, + GOOGLE_API_CLIENT_SECRET, + GOOGLE_API_REDIRECT_URI, +} from '../../google-api-constants'; +import { Input } from '../components/textInput'; +import { PrimaryButton } from '../components/button'; +import { shareFilesWithEmail, requestGoogleAuthData } from '../util/google'; +import { useSettings } from '../context/SettingsContext'; + +export const createAuthView = () => { + const settings = useSettings(); + const authView = Auth({ + label: PrimaryButton({ + label: 'Sign in', + }), + authorizeUrl: 'https://accounts.google.com/o/oauth2/v2/auth', + requestTokenUrl: 'https://oauth2.googleapis.com/token', + scope: 'openid email https://www.googleapis.com/auth/drive', + clientId: GOOGLE_API_CLIENT_ID, + clientSecret: GOOGLE_API_CLIENT_SECRET, + oAuthParams: { + redirect_uri: GOOGLE_API_REDIRECT_URI, + response_type: 'code', + include_granted_scopes: 'true', + access_type: 'offline', + prompt: 'consent', + }, + onAccessToken: (token) => { + console.log('onAccessToken', token); + }, + onReturn: async (authBody) => { + console.log('onReturn', authBody); + const authData = await requestGoogleAuthData(authBody); + console.log('authData', authData); + authData.requested_at = new Date(); + authData.expires_at = new Date( + authData.requested_at.getTime() + authData.expires_in * 1000, + ); + const id = JSON.parse( + Buffer.from(authData.id_token.split('.')[1], 'base64'), + ); + authData.email = id.email; + settings.setSetting('googleAuthData', JSON.stringify(authData)); + console.log('authData', authData); + }, + }); + return authView; +}; + +export const createShareEmailInput = () => { + const settings = useSettings(); + const email = settings.getEmail(); + const contactsList = settings.getSetting(`contactsList_${email}`) || {}; + const shareEmailInput = Input( + 'Share with others', + 'Enter email address...', + async (value) => { + console.log('emailInput', value); + const result = await shareFilesWithEmail(value, settings.getAuthToken()); + if (!result.success) { + settings.setSetting('shareError', true); + return; + } + contactsList[value] = result.permissionId; + settings.setSetting(`contactsList_${email}`, contactsList); + }, + ); + return shareEmailInput; +}; + +export const createSignOutButton = () => { + const settings = useSettings(); + const signOutBtn = PrimaryButton({ + label: 'Sign out', + onClick: () => { + settings.setSetting('googleAuthData', null); + settings.setSetting('googleAuthCode', null); + console.log('Signed out'); + }, + }); + return signOutBtn; +}; diff --git a/app/setting/util/google.js b/app/setting/util/google.js new file mode 100644 index 0000000..c950703 --- /dev/null +++ b/app/setting/util/google.js @@ -0,0 +1,84 @@ +import { + GOOGLE_API_CLIENT_ID, + GOOGLE_API_CLIENT_SECRET, + GOOGLE_API_REDIRECT_URI, +} from '../../google-api-constants'; + +/** + * Request Google Auth Data from Google API after receiving auth code + * @param authResponse the auth code from Google API + * @returns access token and other data for using API + */ +export const requestGoogleAuthData = async (authResponse) => { + const params = { + grant_type: 'authorization_code', + client_id: GOOGLE_API_CLIENT_ID, + client_secret: GOOGLE_API_CLIENT_SECRET, + redirect_uri: GOOGLE_API_REDIRECT_URI, + code: authResponse.code, + }; + + const body = Object.keys(params) + .map( + (key) => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`, + ) + .join('&'); + + const data = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: body, + }); + return await data.json(); +}; + +export const shareFilesWithEmail = async (address, accessToken) => { + const fileId = '1e40yZOhM5_Wd5IQkwVJpPh23pohGgRiN3Ayp4fxYtzU'; // todo remove hardcode, share with folder + const body = JSON.stringify({ + role: 'reader', + type: 'user', + value: address, + }); + + try { + const response = await fetch( + `https://www.googleapis.com/drive/v2/files/${fileId}/permissions`, + { + method: 'POST', + body, + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + if (!response.ok) { + throw new Error(`Failed to share file: ${response.statusText}`); + } + const result = await response.json(); + return { success: true, permissionId: result.id }; + } catch (error) { + return { success: false }; + } +}; + +export const removeFilePermissionById = async (permissionId, accessToken) => { + const fileId = '1e40yZOhM5_Wd5IQkwVJpPh23pohGgRiN3Ayp4fxYtzU'; // TODO: Remove hardcoding + + const response = await fetch( + `https://www.googleapis.com/drive/v2/files/${fileId}/permissions/${permissionId}`, + { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + throw new Error(`Failed to remove permission: ${response.statusText}`); + } + return { success: true }; +}; diff --git a/package.json b/package.json index 4553a74..bd73145 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "@silver-zepp/easy-storage": "^1.6.7-b", - "@zeppos/zml": "^0.0.29" + "@zeppos/zml": "^0.0.29", + "buffer": "^4.9.2" }, "scripts": { "prettier:format": "prettier --write \"**/*.{ts,tsx,js,md,mdx,css,yaml}\"",