diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index a311d997..143675e2 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -45,7 +45,5 @@ jobs: uses: skynetlabs/deploy-to-skynet-action@v2 with: upload-dir: packages/website/public - portal-url: https://skynetpro.net - skynet-jwt: ${{ secrets.SKYNET_JWT }} github-token: ${{ secrets.GITHUB_TOKEN }} registry-seed: ${{ github.event_name == 'push' && github.ref == 'refs/heads/master' && secrets.WEBSITE_REGISTRY_SEED || '' }} diff --git a/.github/workflows/nginx-lua-unit-tests.yml b/.github/workflows/nginx-lua-unit-tests.yml index 514459fa..b86a4e04 100644 --- a/.github/workflows/nginx-lua-unit-tests.yml +++ b/.github/workflows/nginx-lua-unit-tests.yml @@ -4,8 +4,15 @@ name: Nginx Lua Unit Tests on: + push: + branches: + - "master" + paths: + - ".github/workflows/nginx-lua-unit-tests.yml" + - "docker/nginx/libs/**.lua" pull_request: paths: + - ".github/workflows/nginx-lua-unit-tests.yml" - "docker/nginx/libs/**.lua" jobs: @@ -25,9 +32,22 @@ jobs: hererocks env --lua=5.1 -rlatest source env/bin/activate luarocks install busted + luarocks install luacov luarocks install hasher + luarocks install luacheck + + - name: Lint code + run: | + source env/bin/activate + luacheck docker/nginx/libs --std ngx_lua+busted - name: Unit Tests run: | source env/bin/activate - busted --verbose --pattern=spec --directory=docker/nginx/libs . + busted --verbose --coverage --pattern=spec --directory=docker/nginx/libs . + cd docker/nginx/libs && luacov + + - uses: codecov/codecov-action@v2 + with: + directory: docker/nginx/libs + flags: nginx-lua diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile index 53944d8e..2093872c 100644 --- a/docker/nginx/Dockerfile +++ b/docker/nginx/Dockerfile @@ -16,6 +16,7 @@ COPY nginx.conf /usr/local/openresty/nginx/conf/nginx.conf CMD [ "bash", "-c", \ "./mo < /etc/nginx/conf.d.templates/server.account.conf > /etc/nginx/conf.d/server.account.conf ; \ ./mo < /etc/nginx/conf.d.templates/server.api.conf > /etc/nginx/conf.d/server.api.conf; \ + ./mo < /etc/nginx/conf.d.templates/server.dnslink.conf > /etc/nginx/conf.d/server.dnslink.conf; \ ./mo < /etc/nginx/conf.d.templates/server.hns.conf > /etc/nginx/conf.d/server.hns.conf; \ ./mo < /etc/nginx/conf.d.templates/server.skylink.conf > /etc/nginx/conf.d/server.skylink.conf ; \ while :; do sleep 6h & wait ${!}; /usr/local/openresty/bin/openresty -s reload; done & \ diff --git a/docker/nginx/conf.d/server.dnslink.conf b/docker/nginx/conf.d.templates/server.dnslink.conf similarity index 51% rename from docker/nginx/conf.d/server.dnslink.conf rename to docker/nginx/conf.d.templates/server.dnslink.conf index c35536ea..d42ee245 100644 --- a/docker/nginx/conf.d/server.dnslink.conf +++ b/docker/nginx/conf.d.templates/server.dnslink.conf @@ -12,5 +12,14 @@ server { ssl_certificate /etc/ssl/local-certificate.crt; ssl_certificate_key /etc/ssl/local-certificate.key; + set_by_lua_block $skynet_portal_domain { return "{{PORTAL_DOMAIN}}" } + set_by_lua_block $skynet_server_domain { + -- fall back to portal domain if server domain is not defined + if "{{SERVER_DOMAIN}}" == "" then + return "{{PORTAL_DOMAIN}}" + end + return "{{SERVER_DOMAIN}}" + } + include /etc/nginx/conf.d/server/server.dnslink; } diff --git a/docker/nginx/conf.d/include/track-download b/docker/nginx/conf.d/include/track-download index 408e4150..4e12fd41 100644 --- a/docker/nginx/conf.d/include/track-download +++ b/docker/nginx/conf.d/include/track-download @@ -16,7 +16,8 @@ log_by_lua_block { }) if err or (res and res.status ~= ngx.HTTP_NO_CONTENT) then - ngx.log(ngx.ERR, "Failed accounts service request /track/download/" .. skylink .. ": ", err or ("[HTTP " .. res.status .. "] " .. res.body)) + local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body) + ngx.log(ngx.ERR, "Failed accounts service request /track/download/" .. skylink .. ": ", error_response) end end @@ -40,7 +41,8 @@ log_by_lua_block { }) if err or (res and res.status ~= ngx.HTTP_OK) then - ngx.log(ngx.ERR, "Failed malware-scanner request /scan/" .. skylink .. ": ", err or ("[HTTP " .. res.status .. "] " .. res.body)) + local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body) + ngx.log(ngx.ERR, "Failed malware-scanner request /scan/" .. skylink .. ": ", error_response) end end diff --git a/docker/nginx/conf.d/include/track-registry b/docker/nginx/conf.d/include/track-registry index 8e8ae1d4..2c840491 100644 --- a/docker/nginx/conf.d/include/track-registry +++ b/docker/nginx/conf.d/include/track-registry @@ -19,7 +19,8 @@ log_by_lua_block { }) if err or (res and res.status ~= ngx.HTTP_NO_CONTENT) then - ngx.log(ngx.ERR, "Failed accounts service request /track/registry/" .. registry_action .. ": ", err or ("[HTTP " .. res.status .. "] " .. res.body)) + local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body) + ngx.log(ngx.ERR, "Failed accounts service request /track/registry/" .. registry_action .. ": ", error_response) end end diff --git a/docker/nginx/conf.d/include/track-upload b/docker/nginx/conf.d/include/track-upload index edca6bd7..36b12b9e 100644 --- a/docker/nginx/conf.d/include/track-upload +++ b/docker/nginx/conf.d/include/track-upload @@ -15,7 +15,8 @@ log_by_lua_block { }) if err or (res and res.status ~= ngx.HTTP_NO_CONTENT) then - ngx.log(ngx.ERR, "Failed accounts service request /track/upload/" .. skylink .. ": ", err or ("[HTTP " .. res.status .. "] " .. res.body)) + local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body) + ngx.log(ngx.ERR, "Failed accounts service request /track/upload/" .. skylink .. ": ", error_response) end end @@ -40,7 +41,8 @@ log_by_lua_block { }) if err or (res and res.status ~= ngx.HTTP_OK) then - ngx.log(ngx.ERR, "Failed malware-scanner request /scan/" .. skylink .. ": ", err or ("[HTTP " .. res.status .. "] " .. res.body)) + local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body) + ngx.log(ngx.ERR, "Failed malware-scanner request /scan/" .. skylink .. ": ", error_response) end end diff --git a/docker/nginx/libs/basexx.lua b/docker/nginx/libs/basexx.lua index b077ee9a..b53c7337 100644 --- a/docker/nginx/libs/basexx.lua +++ b/docker/nginx/libs/basexx.lua @@ -21,7 +21,7 @@ local function divide_string( str, max ) return result end - + local function number_to_bit( num, length ) local bits = {} @@ -144,7 +144,7 @@ function basexx.to_basexx( str, alphabet, bits, pad ) end table.insert( result, pad ) - return table.concat( result ) + return table.concat( result ) end -------------------------------------------------------------------------------- @@ -225,16 +225,16 @@ local function length_error( len, d ) end local z85Decoder = { 0x00, 0x44, 0x00, 0x54, 0x53, 0x52, 0x48, 0x00, - 0x4B, 0x4C, 0x46, 0x41, 0x00, 0x3F, 0x3E, 0x45, - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, - 0x08, 0x09, 0x40, 0x00, 0x49, 0x42, 0x4A, 0x47, - 0x51, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, - 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, - 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, - 0x3B, 0x3C, 0x3D, 0x4D, 0x00, 0x4E, 0x43, 0x00, - 0x00, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, - 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, - 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, + 0x4B, 0x4C, 0x46, 0x41, 0x00, 0x3F, 0x3E, 0x45, + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x40, 0x00, 0x49, 0x42, 0x4A, 0x47, + 0x51, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2A, + 0x2B, 0x2C, 0x2D, 0x2E, 0x2F, 0x30, 0x31, 0x32, + 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, + 0x3B, 0x3C, 0x3D, 0x4D, 0x00, 0x4E, 0x43, 0x00, + 0x00, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, + 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, + 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F, 0x20, 0x21, 0x22, 0x23, 0x4F, 0x00, 0x50, 0x00, 0x00 } function basexx.from_z85( str, ignore ) diff --git a/docker/nginx/libs/skynet/account.lua b/docker/nginx/libs/skynet/account.lua index 5e0db371..6fa2c4d2 100644 --- a/docker/nginx/libs/skynet/account.lua +++ b/docker/nginx/libs/skynet/account.lua @@ -82,7 +82,8 @@ function _M.get_account_limits() -- fail gracefully in case /user/limits failed if err or (res and res.status ~= ngx.HTTP_OK) then - ngx.log(ngx.ERR, "Failed accounts service request /user/limits?unit=byte: ", err or ("[HTTP " .. res.status .. "] " .. res.body)) + local error_response = err or ("[HTTP " .. res.status .. "] " .. res.body) + ngx.log(ngx.ERR, "Failed accounts service request /user/limits?unit=byte: ", error_response) ngx.var.account_limits = cjson.encode(anon_limits) elseif res and res.status == ngx.HTTP_OK then ngx.var.account_limits = res.body @@ -109,7 +110,7 @@ function _M.has_subscription() end function _M.is_auth_required() - -- authentication is required if mode is set to "authenticated" + -- authentication is required if mode is set to "authenticated" -- or "subscription" (require active subscription to a premium plan) return os.getenv("ACCOUNTS_LIMIT_ACCESS") == "authenticated" or _M.is_subscription_required() end @@ -118,7 +119,7 @@ function _M.is_subscription_required() return os.getenv("ACCOUNTS_LIMIT_ACCESS") == "subscription" end -function is_access_always_allowed() +local is_access_always_allowed = function () -- options requests do not attach cookies - should always be available -- requests should not be limited based on accounts if accounts are not enabled return ngx.req.get_method() == "OPTIONS" or not _M.accounts_enabled() diff --git a/docker/nginx/libs/skynet/skylink.lua b/docker/nginx/libs/skynet/skylink.lua index adcf0b70..86d1c4bc 100644 --- a/docker/nginx/libs/skynet/skylink.lua +++ b/docker/nginx/libs/skynet/skylink.lua @@ -27,7 +27,7 @@ function _M.hash(skylink) -- parse with blake2b with key length of 32 local blake2bHashed = hasher.blake2b(rawMerkleRoot, 32) - + -- hex encode the blake hash local hexHashed = basexx.to_hex(blake2bHashed) diff --git a/docker/nginx/libs/skynet/skylink.spec.lua b/docker/nginx/libs/skynet/skylink.spec.lua index 0502a833..9977d7c8 100644 --- a/docker/nginx/libs/skynet/skylink.spec.lua +++ b/docker/nginx/libs/skynet/skylink.spec.lua @@ -7,7 +7,7 @@ describe("parse", function() it("should return unchanged base64 skylink", function() assert.is.same(skynet_skylink.parse(base64), base64) end) - + it("should transform base32 skylink into base64", function() assert.is.same(skynet_skylink.parse(base32), base64) end) diff --git a/docker/nginx/libs/utils.spec.lua b/docker/nginx/libs/utils.spec.lua index 8dd68e6e..c853c8cd 100644 --- a/docker/nginx/libs/utils.spec.lua +++ b/docker/nginx/libs/utils.spec.lua @@ -15,31 +15,31 @@ describe("extract_cookie", function() it("should return nil if cookie string is nil", function() local cookie = utils.extract_cookie_value(nil, "aaa") - + assert.is_nil(cookie) end) it("should return nil if cookie name is not found", function() local cookie = utils.extract_cookie(cookie_string, "foo") - + assert.is_nil(cookie) end) it("should return cookie if cookie_string starts with that cookie name", function() local cookie = utils.extract_cookie(cookie_string, "aaa") - + assert.are.equals(cookie, "aaa=bbb") end) it("should return cookie if cookie_string ends with that cookie name", function() local cookie = utils.extract_cookie(cookie_string, "xxx") - + assert.are.equals(cookie, "xxx=yyy") end) it("should return cookie with custom matcher", function() local cookie = utils.extract_cookie(cookie_string, "skynet[-]jwt") - + assert.are.equals(cookie, "skynet-jwt=MTY0NzUyr8jD-ytiWtspm0tGabKfooxeIDuWcXhJ3lnY0eEw==") end) end) @@ -49,31 +49,31 @@ describe("extract_cookie_value", function() it("should return nil if cookie string is nil", function() local value = utils.extract_cookie_value(nil, "aaa") - + assert.is_nil(value) end) it("should return nil if cookie name is not found", function() local value = utils.extract_cookie_value(cookie_string, "foo") - + assert.is_nil(value) end) it("should return value if cookie_string starts with that cookie name", function() local value = utils.extract_cookie_value(cookie_string, "aaa") - + assert.are.equals(value, "bbb") end) it("should return cookie if cookie_string ends with that cookie name", function() local value = utils.extract_cookie_value(cookie_string, "xxx") - + assert.are.equals(value, "yyy") end) it("should return cookie with custom matcher", function() local value = utils.extract_cookie_value(cookie_string, "skynet[-]jwt") - + assert.are.equals(value, "MTY0NzUyr8jD-ytiWtspm0tGabKfooxeIDuWcXhJ3lnY0eEw==") end) end) diff --git a/packages/dashboard-v2/README.md b/packages/dashboard-v2/README.md index ba9db568..ab0421f8 100644 --- a/packages/dashboard-v2/README.md +++ b/packages/dashboard-v2/README.md @@ -8,3 +8,18 @@ This is a Gatsby application. To run it locally, all you need is: - `yarn install` - `yarn start` + +## Accessing remote APIs + +To be able to log in on a local environment with your production credentials, you'll need to make the browser believe you're actually on the same domain, otherwise the browser will block the session cookie. + +To do the trick, edit your `/etc/hosts` file and add a record like this: + +``` +127.0.0.1 local.skynetpro.net +``` + +then run `yarn develop:secure` -- it will run `gatsby develop` with `--https --host=local.skynetpro.net -p=443` options. +If you're on macOS, you may need to `sudo` the command to successfully bind to port `443`. + +> **NOTE:** This should become easier once we have a docker image for the new dashboard. diff --git a/packages/dashboard-v2/gatsby-browser.js b/packages/dashboard-v2/gatsby-browser.js index a71e49c3..a39bdb48 100644 --- a/packages/dashboard-v2/gatsby-browser.js +++ b/packages/dashboard-v2/gatsby-browser.js @@ -6,8 +6,14 @@ import "@fontsource/sora/600.css"; // semibold import "@fontsource/source-sans-pro/400.css"; // normal import "@fontsource/source-sans-pro/600.css"; // semibold import "./src/styles/global.css"; +import { MODAL_ROOT_ID } from "./src/components/Modal"; export function wrapPageElement({ element, props }) { const Layout = element.type.Layout ?? React.Fragment; - return {element}; + return ( + + {element} +
+ + ); } diff --git a/packages/dashboard-v2/gatsby-config.js b/packages/dashboard-v2/gatsby-config.js index 017a4dfc..ce35de3a 100644 --- a/packages/dashboard-v2/gatsby-config.js +++ b/packages/dashboard-v2/gatsby-config.js @@ -5,6 +5,7 @@ module.exports = { title: `Accounts Dashboard`, siteUrl: `https://www.yourdomain.tld`, }, + trailingSlash: "never", plugins: [ "gatsby-plugin-image", "gatsby-plugin-provide-react", @@ -26,7 +27,7 @@ module.exports = { app.use( "/api/", createProxyMiddleware({ - target: "https://account.siasky.net", + target: "https://account.skynetpro.net", secure: false, // Do not reject self-signed certificates. changeOrigin: true, }) diff --git a/packages/dashboard-v2/gatsby-ssr.js b/packages/dashboard-v2/gatsby-ssr.js index a71e49c3..a39bdb48 100644 --- a/packages/dashboard-v2/gatsby-ssr.js +++ b/packages/dashboard-v2/gatsby-ssr.js @@ -6,8 +6,14 @@ import "@fontsource/sora/600.css"; // semibold import "@fontsource/source-sans-pro/400.css"; // normal import "@fontsource/source-sans-pro/600.css"; // semibold import "./src/styles/global.css"; +import { MODAL_ROOT_ID } from "./src/components/Modal"; export function wrapPageElement({ element, props }) { const Layout = element.type.Layout ?? React.Fragment; - return {element}; + return ( + + {element} +
+ + ); } diff --git a/packages/dashboard-v2/package.json b/packages/dashboard-v2/package.json index 7b561154..44f7595d 100644 --- a/packages/dashboard-v2/package.json +++ b/packages/dashboard-v2/package.json @@ -9,6 +9,7 @@ ], "scripts": { "develop": "gatsby develop", + "develop:secure": "gatsby develop --https --host=local.skynetpro.net -p=443", "start": "gatsby develop", "build": "gatsby build", "serve": "gatsby serve", @@ -24,9 +25,11 @@ "classnames": "^2.3.1", "copy-text-to-clipboard": "^3.0.1", "dayjs": "^1.10.8", + "formik": "^2.2.9", "gatsby": "^4.6.2", "gatsby-plugin-postcss": "^5.7.0", "http-status-codes": "^2.2.0", + "ky": "^0.30.0", "nanoid": "^3.3.1", "path-browserify": "^1.0.1", "postcss": "^8.4.6", @@ -38,7 +41,8 @@ "react-use": "^17.3.2", "skynet-js": "^3.0.2", "swr": "^1.2.2", - "tailwindcss": "^3.0.23" + "tailwindcss": "^3.0.23", + "yup": "^0.32.11" }, "devDependencies": { "@babel/core": "^7.17.4", diff --git a/packages/dashboard-v2/src/components/APIKeyList/APIKey.js b/packages/dashboard-v2/src/components/APIKeyList/APIKey.js new file mode 100644 index 00000000..5cb6680a --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/APIKey.js @@ -0,0 +1,145 @@ +import dayjs from "dayjs"; +import cn from "classnames"; +import { useCallback, useState } from "react"; + +import { Alert } from "../Alert"; +import { Button } from "../Button"; +import { AddSkylinkToAPIKeyForm } from "../forms/AddSkylinkToAPIKeyForm"; +import { CogIcon, TrashIcon } from "../Icons"; +import { Modal } from "../Modal"; + +import { useAPIKeyEdit } from "./useAPIKeyEdit"; +import { useAPIKeyRemoval } from "./useAPIKeyRemoval"; + +export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => { + const { id, name, createdAt, skylinks } = apiKey; + const isPublic = apiKey.public === "true"; + const [error, setError] = useState(null); + + const onSkylinkListEdited = useCallback(() => { + setError(null); + onEdited(); + }, [onEdited]); + + const onSkylinkListEditFailure = (errorMessage) => setError(errorMessage); + + const { + removalError, + removalInitiated, + prompt: promptRemoval, + abort: abortRemoval, + confirm: confirmRemoval, + } = useAPIKeyRemoval({ + key: apiKey, + onSuccess: onRemoved, + onFailure: onRemovalError, + }); + + const { + editInitiated, + prompt: promptEdit, + abort: abortEdit, + addSkylink, + removeSkylink, + } = useAPIKeyEdit({ + key: apiKey, + onSkylinkListUpdate: onSkylinkListEdited, + onSkylinkListUpdateFailure: onSkylinkListEditFailure, + }); + + const closeEditModal = useCallback(() => { + setError(null); + abortEdit(); + }, [abortEdit]); + + const skylinksNumber = skylinks?.length ?? 0; + const isNotConfigured = isPublic && skylinksNumber === 0; + const skylinksPhrasePrefix = skylinksNumber === 0 ? "No" : skylinksNumber; + const skylinksPhrase = `${skylinksPhrasePrefix} ${skylinksNumber === 1 ? "skylink" : "skylinks"} configured`; + + return ( +
  • + + + {name || "unnamed key"} + + + + + {dayjs(createdAt).format("MMM DD, YYYY")} + + {isPublic && ( + + )} + + + + {removalInitiated && ( + +

    Delete API key

    +
    +

    Are you sure you want to delete the following API key?

    +

    {name || id}

    +
    + {removalError && {removalError}} + +
    + + +
    +
    + )} + {editInitiated && ( + +

    Covered skylinks

    + {skylinks?.length > 0 ? ( +
      + {skylinks.map((skylink) => ( +
    • + + {skylink} + + +
    • + ))} +
    + ) : ( + No skylinks here yet. You can add the first one below 🙃 + )} + +
    + {error && {error}} + +
    +
    + +
    +
    + )} +
  • + ); +}; diff --git a/packages/dashboard-v2/src/components/APIKeyList/APIKeyList.js b/packages/dashboard-v2/src/components/APIKeyList/APIKeyList.js new file mode 100644 index 00000000..3d3e504d --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/APIKeyList.js @@ -0,0 +1,14 @@ +import { APIKey } from "./APIKey"; + +export const APIKeyList = ({ keys, reloadKeys, title }) => { + return ( + <> +
    {title}
    +
      + {keys.map((key) => ( + + ))} +
    + + ); +}; diff --git a/packages/dashboard-v2/src/components/APIKeyList/index.js b/packages/dashboard-v2/src/components/APIKeyList/index.js new file mode 100644 index 00000000..8ade7744 --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/index.js @@ -0,0 +1 @@ +export * from "./APIKeyList"; diff --git a/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyEdit.js b/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyEdit.js new file mode 100644 index 00000000..a821ca02 --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyEdit.js @@ -0,0 +1,43 @@ +import { useCallback, useState } from "react"; +import accountsService from "../../services/accountsService"; + +export const useAPIKeyEdit = ({ key, onSkylinkListUpdate, onSkylinkListUpdateFailure }) => { + const [editInitiated, setEditInitiated] = useState(false); + + const prompt = () => setEditInitiated(true); + const abort = () => setEditInitiated(false); + const updateSkylinkList = useCallback( + async (action, skylink) => { + try { + await accountsService.patch(`user/apikeys/${key.id}`, { + json: { + [action]: [skylink], + }, + }); + onSkylinkListUpdate(); + + return true; + } catch (err) { + if (err.response) { + const { message } = await err.response.json(); + onSkylinkListUpdateFailure(message); + } else { + onSkylinkListUpdateFailure("Unknown error occured, please try again."); + } + + return false; + } + }, + [onSkylinkListUpdate, onSkylinkListUpdateFailure, key] + ); + const addSkylink = (skylink) => updateSkylinkList("add", skylink); + const removeSkylink = (skylink) => updateSkylinkList("remove", skylink); + + return { + editInitiated, + prompt, + abort, + addSkylink, + removeSkylink, + }; +}; diff --git a/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyRemoval.js b/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyRemoval.js new file mode 100644 index 00000000..b9c53bd9 --- /dev/null +++ b/packages/dashboard-v2/src/components/APIKeyList/useAPIKeyRemoval.js @@ -0,0 +1,41 @@ +import { useCallback, useState } from "react"; +import accountsService from "../../services/accountsService"; + +export const useAPIKeyRemoval = ({ key, onSuccess }) => { + const [removalInitiated, setRemovalInitiated] = useState(false); + const [removalError, setRemovalError] = useState(null); + + const prompt = () => { + setRemovalError(null); + setRemovalInitiated(true); + }; + const abort = () => setRemovalInitiated(false); + + const confirm = useCallback(async () => { + setRemovalError(null); + try { + await accountsService.delete(`user/apikeys/${key.id}`); + setRemovalInitiated(false); + onSuccess(); + } catch (err) { + let message = "There was an error processing your request. Please try again later."; + + if (err.response) { + const response = await err.response.json(); + if (response.message) { + message = response.message; + } + } + + setRemovalError(message); + } + }, [onSuccess, key]); + + return { + removalInitiated, + removalError, + prompt, + abort, + confirm, + }; +}; diff --git a/packages/dashboard-v2/src/components/Alert/Alert.js b/packages/dashboard-v2/src/components/Alert/Alert.js new file mode 100644 index 00000000..4db72620 --- /dev/null +++ b/packages/dashboard-v2/src/components/Alert/Alert.js @@ -0,0 +1,10 @@ +import styled from "styled-components"; +import cn from "classnames"; + +export const Alert = styled.div.attrs(({ $variant }) => ({ + className: cn("px-3 py-2 sm:px-6 sm:py-4 rounded border", { + "bg-blue-100 border-blue-200 text-palette-400": $variant === "info", + "bg-red-100 border-red-200 text-error": $variant === "error", + "bg-green-100 border-green-200 text-palette-400": $variant === "success", + }), +}))``; diff --git a/packages/dashboard-v2/src/components/Alert/index.js b/packages/dashboard-v2/src/components/Alert/index.js new file mode 100644 index 00000000..b8e17a03 --- /dev/null +++ b/packages/dashboard-v2/src/components/Alert/index.js @@ -0,0 +1 @@ +export * from "./Alert"; diff --git a/packages/dashboard-v2/src/components/Button/Button.js b/packages/dashboard-v2/src/components/Button/Button.js index 48cedb15..328d52cd 100644 --- a/packages/dashboard-v2/src/components/Button/Button.js +++ b/packages/dashboard-v2/src/components/Button/Button.js @@ -5,8 +5,8 @@ import styled from "styled-components"; /** * Primary UI component for user interaction */ -export const Button = styled.button.attrs(({ disabled, $primary }) => ({ - type: "button", +export const Button = styled.button.attrs(({ disabled, $primary, type }) => ({ + type, className: cn("px-6 py-2.5 rounded-full font-sans uppercase text-xs tracking-wide transition-[opacity_filter]", { "bg-primary text-palette-600": $primary, "bg-white border-2 border-black text-palette-600": !$primary, @@ -14,6 +14,7 @@ export const Button = styled.button.attrs(({ disabled, $primary }) => ({ "hover:brightness-90": !disabled, }), }))``; + Button.propTypes = { /** * Is this the principal call to action on the page? @@ -23,9 +24,14 @@ Button.propTypes = { * Prevent interaction on the button */ disabled: PropTypes.bool, + /** + * Type of button (button / submit) + */ + type: PropTypes.oneOf(["button", "submit"]), }; Button.defaultProps = { $primary: false, disabled: false, + type: "button", }; diff --git a/packages/dashboard-v2/src/components/Form/TextField.js b/packages/dashboard-v2/src/components/Form/TextField.js new file mode 100644 index 00000000..6ae35021 --- /dev/null +++ b/packages/dashboard-v2/src/components/Form/TextField.js @@ -0,0 +1,56 @@ +import PropTypes from "prop-types"; +import cn from "classnames"; +import { Field } from "formik"; + +export const TextField = ({ id, label, name, error, touched, className, ...props }) => { + return ( +
    + {label && ( + + )} + + {touched && error && ( +
    + {error} +
    + )} +
    + ); +}; + +/** Besides noted properties, it accepts all props accepted by: + * - a regular element + * - Formik's component + */ +TextField.propTypes = { + /** + * ID for the field. Used to couple