diff --git a/apple/Makefile b/apple/Makefile index 79c0c2661..4a35936ca 100644 --- a/apple/Makefile +++ b/apple/Makefile @@ -7,4 +7,4 @@ graphql_gen: extension_gen: pushd ../pkg/extension/ && yarn build-prod && popd cp -r ../pkg/extension/dist/* Sources/SafariExtension/Resources/. - cat ../pkg/extension/dist/manifest.json | jq '.background.persistent = false' > Sources/SafariExtension/Resources/manifest.json + cat ../pkg/extension/dist/manifest.json | jq '.background.persistent = false' | jq '.permissions = ["activeTab", "storage", "nativeMessaging", "https://*/**", "http://*/**"]' > Sources/SafariExtension/Resources/manifest.json \ No newline at end of file diff --git a/apple/Sources/SafariExtension/Resources/scripts/content/prepare-content.js b/apple/Sources/SafariExtension/Resources/scripts/content/prepare-content.js index 0976f985e..e35ba7142 100644 --- a/apple/Sources/SafariExtension/Resources/scripts/content/prepare-content.js +++ b/apple/Sources/SafariExtension/Resources/scripts/content/prepare-content.js @@ -1 +1 @@ -"use strict";!function(){const e={};function t(t){const n=t.tagName.toLowerCase();if("iframe"===n){const n=e[t.src];if(!n)return;const o=document.createElement("div");o.className="omnivore-instagram-embed",o.innerHTML=n;const r=t.parentNode;if(!r)return;return void r.replaceChild(o,t)}if("img"===n||"image"===n){if(-1===window.getComputedStyle(t).getPropertyValue("filter").indexOf("blur("))return;return void t.remove()}const o=window.getComputedStyle(t),r=o.getPropertyValue("background-image");if(r&&"none"!==r)return;const i=o.getPropertyValue("filter");if(i&&-1!==i.indexOf("blur("))return void t.remove();if(t.src)return;if(t.innerHTML.length>24)return;const a=/url\("(.+?)"\)/gi,c=a.exec(r);a.lastIndex=0;const l=c&&c[1];if(!l)return;const s=document.createElement("img");s.src=l;const p=t.parentNode;p&&p.replaceChild(s,t)}function n(){const e=document.createElement("div");e.style.position="absolute",e.style.left="-2000px",e.style.zIndex="-2000",e.innerHTML=document.body.innerHTML,document.documentElement.appendChild(e),Array.from(e.getElementsByTagName("*")).forEach(t);const n=`${document.head.innerHTML}${e.innerHTML}`;return e.remove(),n}function o(){const e=document.querySelectorAll(".webext-omnivore-backdrop");for(let t=0;t{for(let t=0;t{if(t!==ACTIONS.AddIframeContent)return;const{url:i,content:a}=n;e[i]=a,r({})})),window.prepareContent=async function(){return await async function(){const e=".pdf"===window.location.pathname.slice(-4).toLowerCase(),t=-1!==["application/acrobat","application/pdf","application/x-pdf","applications/vnd.pdf","text/pdf","text/x-pdf"].indexOf(document.contentType);if(!e&&!t)return Promise.resolve(null);const n=document.querySelector("embed");return n&&"application/pdf"!==n.type?Promise.resolve(null):ENV_DOES_NOT_SUPPORT_BLOB_URL_ACCESS&&n.src?Promise.resolve({type:"url",uploadContentObjUrl:n.src}):new Promise(((e,t)=>{const n=new XMLHttpRequest;n.open("GET","",!0),n.responseType="blob",n.onload=function(n){200===this.status?e({type:"pdf",uploadContentObjUrl:URL.createObjectURL(this.response)}):t(n)},n.send()}))}()||(await async function(){const e=document.scrollingElement||document.body,t=e.scrollTop,n=e.scrollHeight;o();const r=function(){const e=document.createElement("div");return e.className="webext-omnivore-backdrop",e.style.cssText="all: initial !important;\n position: fixed !important;\n top: 0 !important;\n right: 0 !important;\n bottom: 0 !important;\n left: 0 !important;\n z-index: 99999 !important;\n background: #fff !important;\n opacity: 0.8 !important;\n transition: opacity 0.3s !important;\n -webkit-backdrop-filter: blur(4px) !important;\n backdrop-filter: blur(4px) !important;\n ",e}();for(document.body.appendChild(r);e.scrollTop<=n-500;){const t=e.scrollTop;if(e.scrollTop+=500,await new Promise((e=>{setTimeout(e,10)})),e.scrollTop===t)break}e.scrollTop=t,await new Promise((e=>{setTimeout(e,10)}))}(),o(),{type:"html",content:n()})}}(); \ No newline at end of file +"use strict";!function(){const e={};function t(t){const n=t.tagName.toLowerCase();if("iframe"===n){const n=e[t.src];if(!n)return;const o=document.createElement("div");o.className="omnivore-instagram-embed",o.innerHTML=n;const r=t.parentNode;if(!r)return;return void r.replaceChild(o,t)}if("img"===n||"image"===n){if(-1===window.getComputedStyle(t).getPropertyValue("filter").indexOf("blur("))return;return void t.remove()}const o=window.getComputedStyle(t),r=o.getPropertyValue("background-image");if(r&&"none"!==r)return;const i=o.getPropertyValue("filter");if(i&&-1!==i.indexOf("blur("))return void t.remove();if(t.src)return;if(t.innerHTML.length>24)return;const a=/url\("(.+?)"\)/gi,c=a.exec(r);a.lastIndex=0;const l=c&&c[1];if(!l)return;const s=document.createElement("img");s.src=l;const p=t.parentNode;p&&p.replaceChild(s,t)}function n(){const e=document.createElement("div");e.style.position="absolute",e.style.left="-2000px",e.style.zIndex="-2000",e.innerHTML=document.body.innerHTML,document.documentElement.appendChild(e),Array.from(e.getElementsByTagName("*")).forEach(t);const n=`${document.head.innerHTML}${e.innerHTML}`;return e.remove(),n}function o(){const e=document.querySelectorAll(".webext-omnivore-backdrop");for(let t=0;t{for(let t=0;t{if(t!==ACTIONS.AddIframeContent)return;const{url:i,content:a}=n;e[i]=a,r({})})),window.prepareContent=async function(){return await async function(){const e=".pdf"===window.location.pathname.slice(-4).toLowerCase(),t=-1!==["application/acrobat","application/pdf","application/x-pdf","applications/vnd.pdf","text/pdf","text/x-pdf"].indexOf(document.contentType);if(!e&&!t)return Promise.resolve(null);const n=document.querySelector("embed");return n&&"application/pdf"!==n.type?Promise.resolve(null):ENV_DOES_NOT_SUPPORT_BLOB_URL_ACCESS&&n.src?Promise.resolve({type:"url",uploadContentObjUrl:n.src}):new Promise(((e,t)=>{const n=new XMLHttpRequest;n.open("GET","",!0),n.responseType="blob",n.onload=function(n){200===this.status?e({type:"pdf",uploadContentObjUrl:URL.createObjectURL(this.response)}):t(n)},n.send()}))}()||(await async function(){const e=document.scrollingElement||document.body,t=e.scrollTop,n=e.scrollHeight;o();const r=function(){const e=document.createElement("div");return e.className="webext-omnivore-backdrop",e.style.cssText="all: initial !important;\n position: fixed !important;\n top: 0 !important;\n right: 0 !important;\n bottom: 0 !important;\n left: 0 !important;\n z-index: 99999 !important;\n background: #fff !important;\n opacity: 0.8 !important;\n transition: opacity 0.3s !important;\n -webkit-backdrop-filter: blur(4px) !important;\n backdrop-filter: blur(4px) !important;\n ",e}();for(document.body.appendChild(r);e.scrollTop<=n-500;){const t=e.scrollTop;if(e.scrollTop+=500,await new Promise((e=>{setTimeout(e,10)})),e.scrollTop===t)break}e.scrollTop=t,await new Promise((e=>{setTimeout(e,10)}))}(),o(),{type:"html",content:n()})}}(); \ No newline at end of file diff --git a/packages/api/src/resolvers/api_key/index.ts b/packages/api/src/resolvers/api_key/index.ts index f6c067e75..5ef001fc4 100644 --- a/packages/api/src/resolvers/api_key/index.ts +++ b/packages/api/src/resolvers/api_key/index.ts @@ -34,7 +34,10 @@ export const apiKeysResolver = authorized( const apiKeys = await getRepository(ApiKey).find({ select: ['id', 'name', 'scopes', 'expiresAt', 'createdAt', 'usedAt'], where: { user: { id: uid } }, - order: { usedAt: 'DESC', createdAt: 'DESC' }, + order: { + usedAt: { direction: 'DESC', nulls: 'last' }, + createdAt: 'DESC', + }, }) return { diff --git a/packages/web/components/elements/FormElements.tsx b/packages/web/components/elements/FormElements.tsx index f7896089f..03f501e7d 100644 --- a/packages/web/components/elements/FormElements.tsx +++ b/packages/web/components/elements/FormElements.tsx @@ -16,6 +16,7 @@ export interface FormInputProps { required?: boolean css?: any options?: string[] + min?: any } export const FormInput = styled('input', { @@ -96,6 +97,7 @@ export function GeneralFormInput(props: FormInputProps): JSX.Element { }, }} name={input.name} + min={input.min} /> ) } diff --git a/packages/web/lib/networking/mutations/generateApiKeyMutation.ts b/packages/web/lib/networking/mutations/generateApiKeyMutation.ts new file mode 100644 index 000000000..d8c531001 --- /dev/null +++ b/packages/web/lib/networking/mutations/generateApiKeyMutation.ts @@ -0,0 +1,47 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' +import { ApiKey } from '../queries/useGetApiKeysQuery' + +export interface GenerateApiKeyInput { + name: string + scopes?: string[] + expiresAt: Date +} + +interface GenerateApiKeyResult { + generateApiKey: GenerateApiKey + errorCodes?: unknown[] +} + +type GenerateApiKey = { + apiKey: ApiKey +} + +export async function generateApiKeyMutation( + input: GenerateApiKeyInput +): Promise { + const mutation = gql` + mutation GenerateApiKey($input: GenerateApiKeyInput!) { + generateApiKey(input: $input) { + ... on GenerateApiKeySuccess { + apiKey { + key + } + } + ... on GenerateApiKeyError { + errorCodes + } + } + } + ` + + try { + const data = (await gqlFetcher(mutation, { + input, + })) as GenerateApiKeyResult + return data.errorCodes ? undefined : data.generateApiKey.apiKey.key + } catch (error) { + console.log('generateApiKeyMutation error', error) + return undefined + } +} diff --git a/packages/web/lib/networking/mutations/revokeApiKeyMutation.ts b/packages/web/lib/networking/mutations/revokeApiKeyMutation.ts new file mode 100644 index 000000000..83f9447f5 --- /dev/null +++ b/packages/web/lib/networking/mutations/revokeApiKeyMutation.ts @@ -0,0 +1,39 @@ +import { gql } from 'graphql-request' +import { gqlFetcher } from '../networkHelpers' +import { ApiKey } from '../queries/useGetApiKeysQuery' + +interface RevokeApiKeyResult { + revokeApiKey: RevokeApiKey + errorCodes?: unknown[] +} + +type RevokeApiKey = { + apiKey: ApiKey +} + +export async function revokeApiKeyMutation( + id: string +): Promise { + const mutation = gql` + mutation { + revokeApiKey(id: "${id}") { + ... on RevokeApiKeySuccess { + apiKey { + id + } + } + ... on RevokeApiKeyError { + errorCodes + } + } + } + ` + + try { + const data = (await gqlFetcher(mutation)) as RevokeApiKeyResult + return data.errorCodes ? undefined : data.revokeApiKey.apiKey.id + } catch (error) { + console.log('revokeApiKeyMutation error', error) + return undefined + } +} diff --git a/packages/web/lib/networking/queries/useGetApiKeysQuery.tsx b/packages/web/lib/networking/queries/useGetApiKeysQuery.tsx new file mode 100644 index 000000000..b13422427 --- /dev/null +++ b/packages/web/lib/networking/queries/useGetApiKeysQuery.tsx @@ -0,0 +1,75 @@ +import { gql } from 'graphql-request' +import useSWR from 'swr' +import { publicGqlFetcher } from '../networkHelpers' + +export interface ApiKey { + id: string + name: string + key?: string + scopes: string[] + createdAt: Date + expiresAt: Date + usedAt?: Date +} + +interface ApiKeysQueryResponse { + isValidating: boolean + apiKeys: ApiKey[] + revalidate: () => void +} + +interface ApiKeysQueryResponseData { + apiKeys: ApiKeysData +} + +interface ApiKeysData { + apiKeys: unknown +} + +export function useGetApiKeysQuery(): ApiKeysQueryResponse { + const query = gql` + query GetApiKeys { + apiKeys { + ... on ApiKeysSuccess { + apiKeys { + id + name + key + scopes + createdAt + expiresAt + usedAt + } + } + ... on ApiKeysError { + errorCodes + } + } + } + ` + + const { data, mutate, error, isValidating } = useSWR(query, publicGqlFetcher) + console.log('api keys data', data) + + try { + if (data) { + const result = data as ApiKeysQueryResponseData + const apiKeys = result.apiKeys.apiKeys as ApiKey[] + return { + isValidating, + apiKeys, + revalidate: () => { + mutate() + }, + } + } + } catch (error) { + console.log('error', error) + } + return { + isValidating: false, + apiKeys: [], + // eslint-disable-next-line @typescript-eslint/no-empty-function + revalidate: () => {}, + } +} diff --git a/packages/web/lib/networking/queries/useGetArticleQuery.tsx b/packages/web/lib/networking/queries/useGetArticleQuery.tsx index 3c318ba15..3aab97174 100644 --- a/packages/web/lib/networking/queries/useGetArticleQuery.tsx +++ b/packages/web/lib/networking/queries/useGetArticleQuery.tsx @@ -149,11 +149,11 @@ export const removeItemFromCache = ( try { const mappedCache = cache as Map mappedCache.forEach((value: any, key) => { - if (typeof value == 'object' && 'articles' in value) { - const articles = value.articles as LibraryItems - const idx = articles.edges.findIndex((edge) => edge.node.id == itemId) + if (typeof value == 'object' && 'search' in value) { + const search = value.search as LibraryItems + const idx = search.edges.findIndex((edge) => edge.node.id == itemId) if (idx > -1) { - value.articles.edges.splice(idx, 1) + value.search.edges.splice(idx, 1) mutate(key, value, false) } } @@ -161,7 +161,7 @@ export const removeItemFromCache = ( mappedCache.forEach((value: any, key) => { if (Array.isArray(value)) { - const idx = value.findIndex((item) => 'articles' in item) + const idx = value.findIndex((item) => 'search' in item) if (idx > -1) { mutate(key, value, false) } diff --git a/packages/web/pages/settings/api.tsx b/packages/web/pages/settings/api.tsx new file mode 100644 index 000000000..38430bf83 --- /dev/null +++ b/packages/web/pages/settings/api.tsx @@ -0,0 +1,147 @@ +import { PrimaryLayout } from '../../components/templates/PrimaryLayout' +import { Toaster } from 'react-hot-toast' +import { Table } from '../../components/elements/Table' +import { applyStoredTheme } from '../../lib/themeUpdater' +import { useMemo, useState } from 'react' +import { useGetApiKeysQuery } from '../../lib/networking/queries/useGetApiKeysQuery' +import { FormInputProps } from '../../components/elements/FormElements' +import { generateApiKeyMutation } from '../../lib/networking/mutations/generateApiKeyMutation' +import { showErrorToast, showSuccessToast } from '../../lib/toastHelpers' +import { FormModal } from '../../components/patterns/FormModal' +import { ConfirmationModal } from '../../components/patterns/ConfirmationModal' +import { revokeApiKeyMutation } from '../../lib/networking/mutations/revokeApiKeyMutation' + +interface ApiKey { + name: string + scopes: string + expiresAt: string + usedAt: string +} + +export default function Api(): JSX.Element { + const { apiKeys, revalidate } = useGetApiKeysQuery() + const [onDeleteId, setOnDeleteId] = useState('') + const [addModelOpen, setAddModelOpen] = useState(false) + const [name, setName] = useState('') + // const [scopes, setScopes] = useState(undefined) + const [expiresAt, setExpiresAt] = useState(new Date()) + const [formInputs, setFormInputs] = useState([]) + const [apiKeyGenerated, setApiKeyGenerated] = useState('') + + const headers = ['Name', 'Scopes', 'Used at', 'Expires on'] + const rows = useMemo(() => { + const rows = new Map() + apiKeys.forEach((apiKey) => + rows.set(apiKey.id, { + name: apiKey.name, + scopes: apiKey.scopes.join(', ') || 'All', + usedAt: apiKey.usedAt + ? new Date(apiKey.usedAt).toISOString() + : 'Never used', + expiresAt: new Date(apiKey.expiresAt).toDateString(), + }) + ) + return rows + }, [apiKeys]) + + // default expiry date is 1 year from now + const defaultExpiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 365) + .toISOString() + .split('T')[0] + + applyStoredTheme(false) + + async function onDelete(id: string): Promise { + const result = await revokeApiKeyMutation(id) + if (result) { + showSuccessToast('API Key deleted', { position: 'bottom-right' }) + } else { + showErrorToast('Failed to delete', { position: 'bottom-right' }) + } + revalidate() + } + + async function onCreate(): Promise { + const result = await generateApiKeyMutation({ name, expiresAt }) + if (result) { + setApiKeyGenerated(result) + showSuccessToast('API key generated', { position: 'bottom-right' }) + } else { + showErrorToast('Failed to add', { position: 'bottom-right' }) + } + revalidate() + } + + return ( + + + + {addModelOpen && ( + + )} + + {apiKeyGenerated && ( + { + await navigator.clipboard.writeText(apiKeyGenerated) + setApiKeyGenerated('') + }} + onOpenChange={() => setApiKeyGenerated('')} + /> + )} + + {onDeleteId && ( + { + await onDelete(onDeleteId) + setOnDeleteId('') + }} + onOpenChange={() => setOnDeleteId('')} + /> + )} + { + setFormInputs([ + { + label: 'Name', + onChange: setName, + name: 'name', + required: true, + }, + { + label: 'Expired on', + name: 'expiredAt', + required: true, + onChange: setExpiresAt, + type: 'date', + min: new Date().toISOString().split('T')[0], // today + value: defaultExpiresAt, + }, + ]) + setName('') + setExpiresAt(new Date(defaultExpiresAt)) + setAddModelOpen(true) + }} + /> + + ) +} diff --git a/pkg/extension/src/manifest.json b/pkg/extension/src/manifest.json index 180c8db5b..17ff70de7 100644 --- a/pkg/extension/src/manifest.json +++ b/pkg/extension/src/manifest.json @@ -2,7 +2,7 @@ "manifest_version": 2, "name": "process.env.EXTENSION_NAME", "short_name": "process.env.EXTENSION_NAME", - "version": "0.1.22", + "version": "0.1.24", "description": "Save articles to your Omnivore library", "author": "Omnivore Media, Inc", "default_locale": "en", @@ -22,7 +22,7 @@ "256": "/images/extension/icon-256.png" }, - "permissions": ["activeTab", "storage", "nativeMessaging", "https://*/**", "http://*/**"], + "permissions": ["activeTab", "storage", "https://*/**", "http://*/**"], "background": { "page": "/views/background.html",