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",