Merge pull request #1996 from SkynetLabs/dashboard-v2-copy-changes

Dashboard v2 - addressing ops team feedback + DX improvements
This commit is contained in:
Karol Wypchło 2022-04-14 13:14:13 +02:00 committed by GitHub
commit 162e6de77a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 242 additions and 212 deletions

View File

@ -11,15 +11,20 @@ This is a Gatsby application. To run it locally, all you need is:
## Accessing remote APIs ## 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 have a fully functioning local environment, you'll need to make the browser believe you're actually on the same domain as a working API (i.e. a remote dev or production server) -- otherwise the browser will block the session cookie.
To do the trick, configure proper environment variables in the `.env.development` file.
This file allows to easily control which domain name you want to use locally and which API you'd like to access.
To do the trick, edit your `/etc/hosts` file and add a record like this: Example:
``` ```env
127.0.0.1 local.skynetpro.net GATSBY_PORTAL_DOMAIN=skynetfree.net # Use skynetfree.net APIs
GATSBY_HOST=local.skynetfree.net # Address of your local build
``` ```
then run `yarn develop:secure` -- it will run `gatsby develop` with `--https --host=local.skynetpro.net -p=443` options. > It's recommended to keep the 2LD the same, so any cookies dispatched by the API work without issues.
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. With the file configured, run `yarn develop:secure` -- it will run `gatsby develop` with `--https -p=443` options.
If you're on macOS, you may need to `sudo` the command to successfully bind to port `443` (https).
Gatsby will automatically add a proper entry to your `/etc/hosts` file and clean it up when process exits.

View File

@ -1,9 +1,15 @@
require("dotenv").config({
path: `.env.${process.env.NODE_ENV}`,
});
const { createProxyMiddleware } = require("http-proxy-middleware"); const { createProxyMiddleware } = require("http-proxy-middleware");
const { GATSBY_PORTAL_DOMAIN } = process.env;
module.exports = { module.exports = {
siteMetadata: { siteMetadata: {
title: "Skynet Account", title: `Account Dashboard`,
siteUrl: `https://account.${process.env.GATSBY_PORTAL_DOMAIN}/`, siteUrl: `https://account.${GATSBY_PORTAL_DOMAIN}`,
}, },
trailingSlash: "never", trailingSlash: "never",
plugins: [ plugins: [
@ -24,13 +30,27 @@ module.exports = {
}, },
], ],
developMiddleware: (app) => { developMiddleware: (app) => {
// Proxy Accounts service API requests:
app.use( app.use(
"/api/", "/api/",
createProxyMiddleware({ createProxyMiddleware({
target: "https://account.skynetpro.net", target: `https://account.${GATSBY_PORTAL_DOMAIN}`,
secure: false, // Do not reject self-signed certificates. secure: false, // Do not reject self-signed certificates.
changeOrigin: true, changeOrigin: true,
}) })
); );
// Proxy /skynet requests (e.g. uploads)
app.use(
["/skynet", "/__internal/"],
createProxyMiddleware({
target: `https://${GATSBY_PORTAL_DOMAIN}`,
secure: false, // Do not reject self-signed certificates.
changeOrigin: true,
pathRewrite: {
"^/skynet": "",
},
})
);
}, },
}; };

View File

@ -9,7 +9,7 @@
], ],
"scripts": { "scripts": {
"develop": "gatsby develop", "develop": "gatsby develop",
"develop:secure": "gatsby develop --https --host=local.skynetpro.net -p=443", "develop:secure": "dotenv -e .env.development -- gatsby develop --https -p=443",
"start": "gatsby develop", "start": "gatsby develop",
"build": "gatsby build", "build": "gatsby build",
"serve": "gatsby serve", "serve": "gatsby serve",
@ -60,6 +60,8 @@
"babel-loader": "^8.2.3", "babel-loader": "^8.2.3",
"babel-plugin-preval": "^5.1.0", "babel-plugin-preval": "^5.1.0",
"babel-plugin-styled-components": "^2.0.2", "babel-plugin-styled-components": "^2.0.2",
"dotenv": "^16.0.0",
"dotenv-cli": "^5.1.0",
"eslint": "^8.9.0", "eslint": "^8.9.0",
"eslint-config-react-app": "^7.0.0", "eslint-config-react-app": "^7.0.0",
"eslint-plugin-storybook": "^0.5.6", "eslint-plugin-storybook": "^0.5.6",

View File

@ -4,7 +4,7 @@ import { useCallback, useState } from "react";
import { Alert } from "../Alert"; import { Alert } from "../Alert";
import { Button } from "../Button"; import { Button } from "../Button";
import { AddSkylinkToAPIKeyForm } from "../forms/AddSkylinkToAPIKeyForm"; import { AddSkylinkToSponsorKeyForm } from "../forms/AddSkylinkToSponsorKeyForm";
import { CogIcon, TrashIcon } from "../Icons"; import { CogIcon, TrashIcon } from "../Icons";
import { Modal } from "../Modal"; import { Modal } from "../Modal";
@ -13,7 +13,7 @@ import { useAPIKeyRemoval } from "./useAPIKeyRemoval";
export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => { export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
const { id, name, createdAt, skylinks } = apiKey; const { id, name, createdAt, skylinks } = apiKey;
const isPublic = apiKey.public === "true"; const isSponsorKey = apiKey.public === "true";
const [error, setError] = useState(null); const [error, setError] = useState(null);
const onSkylinkListEdited = useCallback(() => { const onSkylinkListEdited = useCallback(() => {
@ -53,9 +53,9 @@ export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
}, [abortEdit]); }, [abortEdit]);
const skylinksNumber = skylinks?.length ?? 0; const skylinksNumber = skylinks?.length ?? 0;
const isNotConfigured = isPublic && skylinksNumber === 0; const isNotConfigured = isSponsorKey && skylinksNumber === 0;
const skylinksPhrasePrefix = skylinksNumber === 0 ? "No" : skylinksNumber; const skylinksPhrasePrefix = skylinksNumber === 0 ? "No" : skylinksNumber;
const skylinksPhrase = `${skylinksPhrasePrefix} ${skylinksNumber === 1 ? "skylink" : "skylinks"} configured`; const skylinksPhrase = `${skylinksPhrasePrefix} ${skylinksNumber === 1 ? "skylink" : "skylinks"} sponsored`;
return ( return (
<li <li
@ -66,21 +66,23 @@ export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
<span className="col-span-2 sm:col-span-1 flex items-center"> <span className="col-span-2 sm:col-span-1 flex items-center">
<span className="flex flex-col"> <span className="flex flex-col">
<span className={cn("truncate", { "text-palette-300": !name })}>{name || "unnamed key"}</span> <span className={cn("truncate", { "text-palette-300": !name })}>{name || "unnamed key"}</span>
<button {isSponsorKey && (
onClick={promptEdit} <button
className={cn("text-xs hover:underline decoration-dotted", { onClick={promptEdit}
"text-error": isNotConfigured, className={cn("text-xs hover:underline decoration-dotted", {
"text-palette-400": !isNotConfigured, "text-error": isNotConfigured,
})} "text-palette-400": !isNotConfigured,
> })}
{skylinksPhrase} >
</button> {skylinksPhrase}
</button>
)}
</span> </span>
</span> </span>
<span className="col-span-2 my-4 border-t border-t-palette-200/50 sm:hidden" /> <span className="col-span-2 my-4 border-t border-t-palette-200/50 sm:hidden" />
<span className="text-palette-400">{dayjs(createdAt).format("MMM DD, YYYY")}</span> <span className="text-palette-400">{dayjs(createdAt).format("MMM DD, YYYY")}</span>
<span className="flex items-center justify-end"> <span className="flex items-center justify-end">
{isPublic && ( {isSponsorKey && (
<button <button
title="Add or remove skylinks" title="Add or remove skylinks"
aria-label="Add or remove skylinks" aria-label="Add or remove skylinks"
@ -119,7 +121,7 @@ export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
)} )}
{editInitiated && ( {editInitiated && (
<Modal onClose={closeEditModal} className="flex flex-col gap-4 text-center sm:px-8 sm:py-6"> <Modal onClose={closeEditModal} className="flex flex-col gap-4 text-center sm:px-8 sm:py-6">
<h4>Covered skylinks</h4> <h4>Sponsored skylinks</h4>
{skylinks?.length > 0 ? ( {skylinks?.length > 0 ? (
<ul className="text-xs flex flex-col gap-2"> <ul className="text-xs flex flex-col gap-2">
{skylinks.map((skylink) => ( {skylinks.map((skylink) => (
@ -143,7 +145,7 @@ export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
{error && <Alert $variant="error">{error}</Alert>} {error && <Alert $variant="error">{error}</Alert>}
<AddSkylinkToAPIKeyForm addSkylink={addSkylink} /> <AddSkylinkToSponsorKeyForm addSkylink={addSkylink} />
</div> </div>
<div className="flex gap-4 justify-center mt-4"> <div className="flex gap-4 justify-center mt-4">
<Button onClick={closeEditModal}>Close</Button> <Button onClick={closeEditModal}>Close</Button>

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useUser } from "../../contexts/user"; import { useUser } from "../../contexts/user";
import { SimpleUploadIcon } from "../Icons"; // import { SimpleUploadIcon } from "../Icons";
const AVATAR_PLACEHOLDER = "/images/avatar-placeholder.svg"; const AVATAR_PLACEHOLDER = "/images/avatar-placeholder.svg";
@ -20,6 +20,7 @@ export const AvatarUploader = (props) => {
> >
<img src={imageUrl} className="w-[160px]" alt="" /> <img src={imageUrl} className="w-[160px]" alt="" />
</div> </div>
{/* TODO: uncomment when avatar uploads work
<div className="flex justify-center"> <div className="flex justify-center">
<button <button
className="flex items-center gap-4 hover:underline decoration-1 decoration-dashed underline-offset-2 decoration-gray-400" className="flex items-center gap-4 hover:underline decoration-1 decoration-dashed underline-offset-2 decoration-gray-400"
@ -28,8 +29,8 @@ export const AvatarUploader = (props) => {
> >
<SimpleUploadIcon size={20} className="shrink-0" /> Upload profile picture <SimpleUploadIcon size={20} className="shrink-0" /> Upload profile picture
</button> </button>
{/* TODO: actual uploading */}
</div> </div>
*/}
</div> </div>
); );
}; };

View File

@ -1,5 +1,6 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime"; import relativeTime from "dayjs/plugin/relativeTime";
import prettyBytes from "pretty-bytes";
import { useUser } from "../../contexts/user"; import { useUser } from "../../contexts/user";
import useActivePlan from "../../hooks/useActivePlan"; import useActivePlan from "../../hooks/useActivePlan";
@ -28,17 +29,20 @@ const CurrentPlan = () => {
} }
return ( return (
<div> <div className="flex flex-col h-full">
<h4>{activePlan.name}</h4> <h4>{activePlan.name}</h4>
<div className="text-palette-400"> <div className="text-palette-400 justify-between flex flex-col grow">
{activePlan.price === 0 && <p>100GB without paying a dime! 🎉</p>} {activePlan.price === 0 && activePlan.limits && (
<p>{prettyBytes(activePlan.limits.storageLimit, { binary: true })} without paying a dime! 🎉</p>
)}
{activePlan.price !== 0 && {activePlan.price !== 0 &&
(user.subscriptionCancelAtPeriodEnd ? ( (user.subscriptionCancelAtPeriodEnd ? (
<p>Your subscription expires {dayjs(user.subscribedUntil).fromNow()}</p> <p>Your subscription expires {dayjs(user.subscribedUntil).fromNow()}</p>
) : ( ) : (
<p className="first-letter:uppercase">{dayjs(user.subscribedUntil).fromNow(true)} until the next payment</p> <p className="first-letter:uppercase">{dayjs(user.subscribedUntil).fromNow(true)} until the next payment</p>
))} ))}
<LatestPayment user={user} />
{user.subscriptionStatus && <LatestPayment user={user} />}
<SuggestedPlan plans={plans} activePlan={activePlan} /> <SuggestedPlan plans={plans} activePlan={activePlan} />
</div> </div>
</div> </div>

View File

@ -44,7 +44,7 @@ const useUsageData = () => {
}; };
const size = (bytes) => { const size = (bytes) => {
const text = fileSize(bytes ?? 0, { maximumFractionDigits: 0 }); const text = fileSize(bytes ?? 0, { maximumFractionDigits: 0, binary: true });
const [value, unit] = text.split(" "); const [value, unit] = text.split(" ");
return { return {

View File

@ -1,6 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import prettyBytes from "pretty-bytes"; import prettyBytes from "pretty-bytes";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { DATE_FORMAT } from "../../lib/config";
const parseFileName = (fileName) => { const parseFileName = (fileName) => {
const lastDotIndex = Math.max(0, fileName.lastIndexOf(".")) || Infinity; const lastDotIndex = Math.max(0, fileName.lastIndexOf(".")) || Infinity;
@ -10,7 +11,7 @@ const parseFileName = (fileName) => {
const formatItem = ({ size, name: rawFileName, uploadedOn, downloadedOn, ...rest }) => { const formatItem = ({ size, name: rawFileName, uploadedOn, downloadedOn, ...rest }) => {
const [name, type] = parseFileName(rawFileName); const [name, type] = parseFileName(rawFileName);
const date = dayjs(uploadedOn || downloadedOn).format("MM/DD/YYYY; HH:MM"); const date = dayjs(uploadedOn || downloadedOn).format(DATE_FORMAT);
return { return {
...rest, ...rest,

View File

@ -1,8 +1,19 @@
import * as React from "react"; import * as React from "react";
import styled from "styled-components";
import { PageContainer } from "../PageContainer"; import { PageContainer } from "../PageContainer";
const FooterLink = styled.a.attrs({
className: "text-palette-400 underline decoration-dotted decoration-offset-4 decoration-1",
rel: "noreferrer",
target: "_blank",
})``;
export const Footer = () => ( export const Footer = () => (
<PageContainer className="font-content text-palette-300 py-4"> <PageContainer className="font-content text-palette-300 py-4">
<p>© Skynet Labs Inc. All rights reserved.</p> <p>
Made by <FooterLink href="https://skynetlabs.com">Skynet Labs</FooterLink>. Open-sourced{" "}
<FooterLink href="https://github.com/SkynetLabs/skynet-webportal">on Github</FooterLink>.
</p>
</PageContainer> </PageContainer>
); );

View File

@ -2,5 +2,5 @@ import { Link } from "gatsby";
import styled from "styled-components"; import styled from "styled-components";
export default styled(Link).attrs({ export default styled(Link).attrs({
className: "text-primary underline-offset-3 decoration-dotted hover:text-primary-light hover:underline", className: "text-primary underline-offset-2 decoration-1 decoration-dotted hover:text-primary-light hover:underline",
})``; })``;

View File

@ -3,13 +3,13 @@ import useSWR from "swr";
import { Table, TableBody, TableCell, TableRow } from "../Table"; import { Table, TableBody, TableCell, TableRow } from "../Table";
import { ContainerLoadingIndicator } from "../LoadingIndicator"; import { ContainerLoadingIndicator } from "../LoadingIndicator";
import useFormattedFilesData from "../FileList/useFormattedFilesData";
import { ViewAllLink } from "./ViewAllLink"; import { ViewAllLink } from "./ViewAllLink";
import useFormattedActivityData from "./useFormattedActivityData";
export default function ActivityTable({ type }) { export default function ActivityTable({ type }) {
const { data, error } = useSWR(`user/${type}?pageSize=3`); const { data, error } = useSWR(`user/${type}?pageSize=3`);
const items = useFormattedActivityData(data?.items || []); const items = useFormattedFilesData(data?.items || []);
if (!items.length) { if (!items.length) {
return ( return (

View File

@ -1,23 +1,13 @@
import * as React from "react"; import * as React from "react";
import { Panel } from "../Panel"; import { Panel } from "../Panel";
import { Tab, TabPanel, Tabs } from "../Tabs";
import ActivityTable from "./ActivityTable"; import ActivityTable from "./ActivityTable";
export default function LatestActivity() { export default function LatestActivity() {
return ( return (
<Panel title="Latest activity"> <Panel title="Latest uploads">
<Tabs> <ActivityTable type="uploads" />
<Tab id="uploads" title="Uploads" />
<Tab id="downloads" title="Downloads" />
<TabPanel tabId="uploads" className="pt-4">
<ActivityTable type="uploads" />
</TabPanel>
<TabPanel tabId="downloads" className="pt-4">
<ActivityTable type="downloads" />
</TabPanel>
</Tabs>
</Panel> </Panel>
); );
} }

View File

@ -1,26 +0,0 @@
import { useMemo } from "react";
import prettyBytes from "pretty-bytes";
import dayjs from "dayjs";
const parseFileName = (fileName) => {
const lastDotIndex = Math.max(0, fileName.lastIndexOf(".")) || Infinity;
return [fileName.substr(0, lastDotIndex), fileName.substr(lastDotIndex)];
};
const formatItem = ({ size, name: rawFileName, uploadedOn, downloadedOn, ...rest }) => {
const [name, type] = parseFileName(rawFileName);
const date = dayjs(uploadedOn || downloadedOn).format("MM/DD/YYYY; HH:MM");
return {
...rest,
date,
size: prettyBytes(size),
type,
name,
};
};
const useFormattedActivityData = (items) => useMemo(() => items.map(formatItem), [items]);
export default useFormattedActivityData;

View File

@ -0,0 +1 @@
export * from "./Tooltip";

View File

@ -118,7 +118,7 @@ const Uploader = ({ mode }) => {
</div> </div>
{uploads.length > 0 && ( {uploads.length > 0 && (
<div className="flex flex-col space-y-4 py-10"> <div className="flex flex-col space-y-4 pt-6 pb-10">
{uploads.map((upload) => ( {uploads.map((upload) => (
<UploaderItem key={upload.id} onUploadStateChange={onUploadStateChange} upload={upload} /> <UploaderItem key={upload.id} onUploadStateChange={onUploadStateChange} upload={upload} />
))} ))}

View File

@ -34,8 +34,9 @@ export const AccountRemovalForm = ({ abort, onSuccess }) => {
<Form className="flex flex-col gap-4"> <Form className="flex flex-col gap-4">
<div> <div>
<h4>Delete account</h4> <h4>Delete account</h4>
<p>This will completely delete your account.</p>
<p> <p>
This will completely delete your account. <strong>This process can't be undone.</strong> <strong>This process cannot be undone.</strong>
</p> </p>
</div> </div>

View File

@ -2,6 +2,7 @@ import * as Yup from "yup";
import { forwardRef, useImperativeHandle, useState } from "react"; import { forwardRef, useImperativeHandle, useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Formik, Form } from "formik"; import { Formik, Form } from "formik";
import cn from "classnames";
import accountsService from "../../services/accountsService"; import accountsService from "../../services/accountsService";
@ -9,7 +10,6 @@ import { Alert } from "../Alert";
import { Button } from "../Button"; import { Button } from "../Button";
import { CopyButton } from "../CopyButton"; import { CopyButton } from "../CopyButton";
import { TextField } from "../Form/TextField"; import { TextField } from "../Form/TextField";
import { CircledProgressIcon, PlusIcon } from "../Icons";
const newAPIKeySchema = Yup.object().shape({ const newAPIKeySchema = Yup.object().shape({
name: Yup.string(), name: Yup.string(),
@ -22,7 +22,7 @@ const State = {
}; };
export const APIKeyType = { export const APIKeyType = {
Public: "public", Sponsor: "sponsor",
General: "general", General: "general",
}; };
@ -37,10 +37,10 @@ export const AddAPIKeyForm = forwardRef(({ onSuccess, type }, ref) => {
return ( return (
<div ref={ref} className="flex flex-col gap-4"> <div ref={ref} className="flex flex-col gap-4">
{state === State.Success && ( {state === State.Success && (
<Alert $variant="success" className="text-center"> <Alert $variant="success">
<strong>Success!</strong> <strong>Success!</strong>
<p>Please copy your new API key below. We'll never show it again!</p> <p>Please copy your new API key below. We'll never show it again!</p>
<div className="flex items-center gap-2 mt-4 justify-center"> <div className="flex items-center gap-2 mt-4">
<code className="p-2 rounded border border-palette-200 text-xs selection:bg-primary/30 truncate"> <code className="p-2 rounded border border-palette-200 text-xs selection:bg-primary/30 truncate">
{generatedKey} {generatedKey}
</code> </code>
@ -62,8 +62,8 @@ export const AddAPIKeyForm = forwardRef(({ onSuccess, type }, ref) => {
.post("user/apikeys", { .post("user/apikeys", {
json: { json: {
name, name,
public: type === APIKeyType.Public ? "true" : "false", public: type === APIKeyType.Sponsor ? "true" : "false",
skylinks: type === APIKeyType.Public ? [] : null, skylinks: type === APIKeyType.Sponsor ? [] : null,
}, },
}) })
.json(); .json();
@ -78,26 +78,20 @@ export const AddAPIKeyForm = forwardRef(({ onSuccess, type }, ref) => {
}} }}
> >
{({ errors, touched, isSubmitting }) => ( {({ errors, touched, isSubmitting }) => (
<Form className="grid grid-cols-[1fr_min-content] w-full gap-y-2 gap-x-4 items-start"> <Form className="flex flex-col gap-4">
<div className="flex items-center"> <TextField
<TextField type="text"
type="text" id="name"
id="name" name="name"
name="name" label="New API Key Label"
label="New API Key Name" placeholder="my_applications_statistics"
placeholder="my_applications_statistics" error={errors.name}
error={errors.name} touched={touched.name}
touched={touched.name} />
/> <div className="flex justify-center">
</div> <Button type="submit" disabled={isSubmitting} className={cn({ "cursor-wait": isSubmitting })}>
<div className="flex mt-5 justify-center"> {isSubmitting ? "Generating your API key..." : "Generate your API key"}
{isSubmitting ? ( </Button>
<CircledProgressIcon size={38} className="text-palette-300 animate-[spin_3s_linear_infinite]" />
) : (
<Button type="submit" className="px-2.5" aria-label="Create general API key">
<PlusIcon size={14} />
</Button>
)}
</div> </div>
</Form> </Form>
)} )}
@ -110,5 +104,5 @@ AddAPIKeyForm.displayName = "AddAPIKeyForm";
AddAPIKeyForm.propTypes = { AddAPIKeyForm.propTypes = {
onSuccess: PropTypes.func.isRequired, onSuccess: PropTypes.func.isRequired,
type: PropTypes.oneOf([APIKeyType.Public, APIKeyType.General]).isRequired, type: PropTypes.oneOf([APIKeyType.Sponsor, APIKeyType.General]).isRequired,
}; };

View File

@ -19,7 +19,7 @@ const newSkylinkSchema = Yup.object().shape({
}), }),
}); });
export const AddSkylinkToAPIKeyForm = ({ addSkylink }) => ( export const AddSkylinkToSponsorKeyForm = ({ addSkylink }) => (
<Formik <Formik
initialValues={{ initialValues={{
skylink: "", skylink: "",
@ -58,6 +58,6 @@ export const AddSkylinkToAPIKeyForm = ({ addSkylink }) => (
</Formik> </Formik>
); );
AddSkylinkToAPIKeyForm.propTypes = { AddSkylinkToSponsorKeyForm.propTypes = {
addSkylink: PropTypes.func.isRequired, addSkylink: PropTypes.func.isRequired,
}; };

View File

@ -25,7 +25,7 @@ const skylinkValidator = (optional) => (value) => {
} }
}; };
const newPublicAPIKeySchema = Yup.object().shape({ const newSponsorKeySchema = Yup.object().shape({
name: Yup.string(), name: Yup.string(),
skylinks: Yup.array().of(Yup.string().test("skylink", "Provide a valid Skylink", skylinkValidator(false))), skylinks: Yup.array().of(Yup.string().test("skylink", "Provide a valid Skylink", skylinkValidator(false))),
nextSkylink: Yup.string().when("skylinks", { nextSkylink: Yup.string().when("skylinks", {
@ -41,7 +41,7 @@ const State = {
Failure: "FAILURE", Failure: "FAILURE",
}; };
export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, ref) => { export const AddSponsorKeyForm = forwardRef(({ onSuccess }, ref) => {
const [state, setState] = useState(State.Pure); const [state, setState] = useState(State.Pure);
const [generatedKey, setGeneratedKey] = useState(null); const [generatedKey, setGeneratedKey] = useState(null);
@ -52,10 +52,10 @@ export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, ref) => {
return ( return (
<div ref={ref} className="flex flex-col gap-4"> <div ref={ref} className="flex flex-col gap-4">
{state === State.Success && ( {state === State.Success && (
<Alert $variant="success" className="text-center"> <Alert $variant="success">
<strong>Success!</strong> <strong>Success!</strong>
<p>Please copy your new API key below. We'll never show it again!</p> <p>Please copy your new API key below. We'll never show it again!</p>
<div className="flex items-center gap-2 mt-4 justify-center"> <div className="flex items-center gap-2 mt-4">
<code className="p-2 rounded border border-palette-200 text-xs selection:bg-primary/30 truncate"> <code className="p-2 rounded border border-palette-200 text-xs selection:bg-primary/30 truncate">
{generatedKey} {generatedKey}
</code> </code>
@ -72,7 +72,7 @@ export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, ref) => {
skylinks: [], skylinks: [],
nextSkylink: "", nextSkylink: "",
}} }}
validationSchema={newPublicAPIKeySchema} validationSchema={newSponsorKeySchema}
onSubmit={async ({ name, skylinks, nextSkylink }, { resetForm }) => { onSubmit={async ({ name, skylinks, nextSkylink }, { resetForm }) => {
try { try {
const { key } = await accountsService const { key } = await accountsService
@ -101,14 +101,14 @@ export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, ref) => {
type="text" type="text"
id="name" id="name"
name="name" name="name"
label="Public API Key Name" label="Sponsor API Key Name"
placeholder="my_applications_statistics" placeholder="my_applications_statistics"
error={errors.name} error={errors.name}
touched={touched.name} touched={touched.name}
/> />
</div> </div>
<div> <div>
<h6 className="text-palette-300 mb-2">Skylinks accessible with the new key</h6> <h6 className="text-palette-300 mb-2">Skylinks sponsored by the new key</h6>
<FieldArray <FieldArray
name="skylinks" name="skylinks"
render={({ push, remove }) => { render={({ push, remove }) => {
@ -182,7 +182,7 @@ export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, ref) => {
className={cn("px-2.5", { "cursor-wait": isSubmitting })} className={cn("px-2.5", { "cursor-wait": isSubmitting })}
disabled={!isValid || isSubmitting} disabled={!isValid || isSubmitting}
> >
{isSubmitting ? "Generating" : "Generate"} your public key {isSubmitting ? "Generating your sponsor key..." : "Generate your sponsor key"}
</Button> </Button>
</div> </div>
</Form> </Form>
@ -192,8 +192,8 @@ export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, ref) => {
); );
}); });
AddPublicAPIKeyForm.displayName = "AddAPIKeyForm"; AddSponsorKeyForm.displayName = "AddSponsorKeyForm";
AddPublicAPIKeyForm.propTypes = { AddSponsorKeyForm.propTypes = {
onSuccess: PropTypes.func.isRequired, onSuccess: PropTypes.func.isRequired,
}; };

View File

@ -32,14 +32,16 @@ export const SignUpForm = ({ onSuccess, onFailure }) => (
validationSchema={registrationSchema} validationSchema={registrationSchema}
onSubmit={async ({ email, password }, { setErrors }) => { onSubmit={async ({ email, password }, { setErrors }) => {
try { try {
await accountsService.post("user", { const user = await accountsService
json: { .post("user", {
email, json: {
password, email,
}, password,
}); },
})
.json();
onSuccess(); onSuccess(user);
} catch (err) { } catch (err) {
let isFormErrorSet = false; let isFormErrorSet = false;

View File

@ -16,8 +16,8 @@ const Sidebar = () => (
<SidebarLink activeClassName="!border-l-primary" to="/settings/export"> <SidebarLink activeClassName="!border-l-primary" to="/settings/export">
Export Export
</SidebarLink> </SidebarLink>
<SidebarLink activeClassName="!border-l-primary" to="/settings/api-keys"> <SidebarLink activeClassName="!border-l-primary" to="/settings/developer-settings">
API Keys Developer settings
</SidebarLink> </SidebarLink>
</nav> </nav>
</aside> </aside>

View File

@ -0,0 +1 @@
export const DATE_FORMAT = "MMM D, YYYY HH:MM";

View File

@ -1,5 +1,4 @@
import { useEffect, useState } from "react"; import { useCallback, useState } from "react";
import { navigate } from "gatsby";
import bytes from "pretty-bytes"; import bytes from "pretty-bytes";
import AuthLayout from "../../layouts/AuthLayout"; import AuthLayout from "../../layouts/AuthLayout";
@ -10,6 +9,7 @@ import { SignUpForm } from "../../components/forms/SignUpForm";
import { usePortalSettings } from "../../contexts/portal-settings"; import { usePortalSettings } from "../../contexts/portal-settings";
import { PlansProvider, usePlans } from "../../contexts/plans"; import { PlansProvider, usePlans } from "../../contexts/plans";
import { Metadata } from "../../components/Metadata"; import { Metadata } from "../../components/Metadata";
import { useUser } from "../../contexts/user";
const FreePortalHeader = () => { const FreePortalHeader = () => {
const { plans } = usePlans(); const { plans } = usePlans();
@ -47,14 +47,14 @@ const State = {
const SignUpPage = () => { const SignUpPage = () => {
const [state, setState] = useState(State.Pure); const [state, setState] = useState(State.Pure);
const { settings } = usePortalSettings(); const { settings } = usePortalSettings();
const { mutate: refreshUserState } = useUser();
useEffect(() => { const onUserCreated = useCallback(
if (state === State.Success) { (newUser) => {
const timer = setTimeout(() => navigate(settings.isSubscriptionRequired ? "/upgrade" : "/"), 3000); refreshUserState(newUser);
},
return () => clearTimeout(timer); [refreshUserState]
} );
}, [state, settings.isSubscriptionRequired]);
return ( return (
<PlansProvider> <PlansProvider>
@ -70,7 +70,7 @@ const SignUpPage = () => {
{state !== State.Success && ( {state !== State.Success && (
<> <>
<SignUpForm onSuccess={() => setState(State.Success)} onFailure={() => setState(State.Failure)} /> <SignUpForm onSuccess={onUserCreated} onFailure={() => setState(State.Failure)} />
<p className="text-sm text-center mt-8"> <p className="text-sm text-center mt-8">
Already have an account? <HighlightedLink to="/auth/login">Sign in</HighlightedLink> Already have an account? <HighlightedLink to="/auth/login">Sign in</HighlightedLink>
@ -78,14 +78,6 @@ const SignUpPage = () => {
</> </>
)} )}
{state === State.Success && (
<div>
<p className="text-primary font-semibold">Please check your inbox and confirm your email address.</p>
<p>You will be redirected to your dashboard shortly.</p>
<HighlightedLink to="/">Click here to go there now.</HighlightedLink>
</div>
)}
{state === State.Failure && ( {state === State.Failure && (
<p className="text-error text-center">Something went wrong, please try again later.</p> <p className="text-error text-center">Something went wrong, please try again later.</p>
)} )}

View File

@ -1,32 +1,19 @@
import * as React from "react"; import * as React from "react";
import { useSearchParam } from "react-use";
import DashboardLayout from "../layouts/DashboardLayout"; import DashboardLayout from "../layouts/DashboardLayout";
import { Panel } from "../components/Panel"; import { Panel } from "../components/Panel";
import { Tab, TabPanel, Tabs } from "../components/Tabs";
import { Metadata } from "../components/Metadata"; import { Metadata } from "../components/Metadata";
import FileList from "../components/FileList/FileList"; import FileList from "../components/FileList/FileList";
const FilesPage = () => { const FilesPage = () => {
const defaultTab = useSearchParam("tab");
return ( return (
<> <>
<Metadata> <Metadata>
<title>My Files</title> <title>Files</title>
</Metadata> </Metadata>
<Panel title="Files"> <Panel title="Files">
<Tabs defaultTab={defaultTab || "uploads"}> <FileList type="uploads" />
<Tab id="uploads" title="Uploads" />
<Tab id="downloads" title="Downloads" />
<TabPanel tabId="uploads" className="pt-4">
<FileList type="uploads" />
</TabPanel>
<TabPanel tabId="downloads" className="pt-4">
<FileList type="downloads" />
</TabPanel>
</Tabs>
</Panel> </Panel>
</> </>
); );

View File

@ -6,13 +6,14 @@ import UserSettingsLayout from "../../layouts/UserSettingsLayout";
import { AddAPIKeyForm, APIKeyType } from "../../components/forms/AddAPIKeyForm"; import { AddAPIKeyForm, APIKeyType } from "../../components/forms/AddAPIKeyForm";
import { APIKeyList } from "../../components/APIKeyList/APIKeyList"; import { APIKeyList } from "../../components/APIKeyList/APIKeyList";
import { Alert } from "../../components/Alert"; import { Alert } from "../../components/Alert";
import { AddPublicAPIKeyForm } from "../../components/forms/AddPublicAPIKeyForm"; import { AddSponsorKeyForm } from "../../components/forms/AddSponsorKeyForm";
import { Metadata } from "../../components/Metadata"; import { Metadata } from "../../components/Metadata";
import HighlightedLink from "../../components/HighlightedLink";
const APIKeysPage = () => { const DeveloperSettingsPage = () => {
const { data: apiKeys = [], mutate: reloadKeys, error } = useSWR("user/apikeys"); const { data: allKeys = [], mutate: reloadKeys, error } = useSWR("user/apikeys");
const generalKeys = apiKeys.filter(({ public: isPublic }) => isPublic === "false"); const apiKeys = allKeys.filter(({ public: isPublic }) => isPublic === "false");
const publicKeys = apiKeys.filter(({ public: isPublic }) => isPublic === "true"); const sponsorKeys = allKeys.filter(({ public: isPublic }) => isPublic === "true");
const publicFormRef = useRef(); const publicFormRef = useRef();
const generalFormRef = useRef(); const generalFormRef = useRef();
@ -31,53 +32,57 @@ const APIKeysPage = () => {
return ( return (
<> <>
<Metadata> <Metadata>
<title>API Keys</title> <title>Developer settings</title>
</Metadata> </Metadata>
<div className="flex flex-col xl:flex-row"> <div className="flex flex-col xl:flex-row">
<div className="flex flex-col gap-10 lg:shrink-0 lg:max-w-[576px] xl:max-w-[524px]"> <div className="flex flex-col gap-10 lg:shrink-0 lg:max-w-[576px] xl:max-w-[524px] leading-relaxed">
<div> <div>
<h4>API Keys</h4> <h4>Developer settings</h4>
<p className="leading-relaxed">There are two types of API keys that you can generate for your account.</p> <p>API keys allow developers and applications to extend the functionality of your portal account.</p>
<p>Make sure to use the appropriate type.</p> <p>Skynet uses two types of API keys, explained below.</p>
</div> </div>
<hr /> <hr />
<section className="flex flex-col gap-2"> <section className="flex flex-col gap-2">
<h5>Public keys</h5> <h5>Sponsor keys</h5>
<p className="text-palette-500"> <div className="text-palette-500"></div>
Public keys provide read access to a selected list of skylinks. You can share them publicly. <p>
Sponsor keys allow users without an account on this portal to download skylinks covered by the API key.
</p> </p>
<p>
Learn more about sponsoring content with Sponsor API Keys{" "}
<HighlightedLink as="a" href="#">
here
</HighlightedLink>
.
</p>{" "}
{/* TODO: missing documentation link */}
<div className="mt-4"> <div className="mt-4">
<AddPublicAPIKeyForm ref={publicFormRef} onSuccess={refreshState} /> <AddSponsorKeyForm ref={publicFormRef} onSuccess={refreshState} />
</div> </div>
{error ? ( {error ? (
<Alert $variant="error" className="mt-4"> <Alert $variant="error" className="mt-4">
An error occurred while loading your API keys. Please try again later. An error occurred while loading your sponsor keys. Please try again later.
</Alert> </Alert>
) : ( ) : (
<div className="mt-4"> <div className="mt-4">
{publicKeys?.length > 0 ? ( {sponsorKeys?.length > 0 ? (
<APIKeyList title="Your public keys" keys={publicKeys} reloadKeys={() => refreshState(true)} /> <APIKeyList title="Your public keys" keys={sponsorKeys} reloadKeys={() => refreshState(true)} />
) : ( ) : (
<Alert $variant="info">No public API keys found.</Alert> <Alert $variant="info">No sponsor keys found.</Alert>
)} )}
</div> </div>
)} )}
</section> </section>
<hr /> <hr />
<section className="flex flex-col gap-2"> <section className="flex flex-col gap-2">
<h5>General keys</h5> <h5>API keys</h5>
<p className="text-palette-500"> <p className="text-palette-500">
These keys provide full access to <b>Accounts</b> service and are equivalent to using a JWT token. These keys allow uploading and downloading skyfiles, as well as reading and writing to the registry.
</p> </p>
<p className="underline">
This type of API keys needs to be kept secret and should never be shared with anyone.
</p>
<div className="mt-4"> <div className="mt-4">
<AddAPIKeyForm ref={generalFormRef} onSuccess={refreshState} type={APIKeyType.General} /> <AddAPIKeyForm ref={generalFormRef} onSuccess={refreshState} type={APIKeyType.General} />
</div> </div>
@ -88,10 +93,10 @@ const APIKeysPage = () => {
</Alert> </Alert>
) : ( ) : (
<div className="mt-4"> <div className="mt-4">
{generalKeys?.length > 0 ? ( {apiKeys?.length > 0 ? (
<APIKeyList title="Your general keys" keys={generalKeys} reloadKeys={() => refreshState(true)} /> <APIKeyList title="Your API keys" keys={apiKeys} reloadKeys={() => refreshState(true)} />
) : ( ) : (
<Alert $variant="info">No general API keys found.</Alert> <Alert $variant="info">No API keys found.</Alert>
)} )}
</div> </div>
)} )}
@ -105,6 +110,6 @@ const APIKeysPage = () => {
); );
}; };
APIKeysPage.Layout = UserSettingsLayout; DeveloperSettingsPage.Layout = UserSettingsLayout;
export default APIKeysPage; export default DeveloperSettingsPage;

View File

@ -38,8 +38,8 @@ const ExportPage = () => {
<section> <section>
<h4>Export</h4> <h4>Export</h4>
<p> <p>
Et quidem exercitus quid ex eo delectu rerum, quem modo ista sis aequitate. Probabo, inquit, modo dixi, Select the items you want to export. You can use this data to migrate your account to another Skynet
constituto. portal.
</p> </p>
</section> </section>
<hr /> <hr />

View File

@ -8,6 +8,10 @@ import { Modal } from "../../components/Modal/Modal";
import { AccountRemovalForm } from "../../components/forms/AccountRemovalForm"; import { AccountRemovalForm } from "../../components/forms/AccountRemovalForm";
import { Alert } from "../../components/Alert"; import { Alert } from "../../components/Alert";
import { Metadata } from "../../components/Metadata"; import { Metadata } from "../../components/Metadata";
import HighlightedLink from "../../components/HighlightedLink";
import { AvatarUploader } from "../../components/AvatarUploader/AvatarUploader";
import { useMedia } from "react-use";
import theme from "../../lib/theme";
const State = { const State = {
Pure: "PURE", Pure: "PURE",
@ -19,10 +23,16 @@ const AccountPage = () => {
const { user, mutate: reloadUser } = useUser(); const { user, mutate: reloadUser } = useUser();
const [state, setState] = useState(State.Pure); const [state, setState] = useState(State.Pure);
const [removalInitiated, setRemovalInitiated] = useState(false); const [removalInitiated, setRemovalInitiated] = useState(false);
const isLargeScreen = useMedia(`(min-width: ${theme.screens.xl})`);
const prompt = () => setRemovalInitiated(true); const prompt = () => setRemovalInitiated(true);
const abort = () => setRemovalInitiated(false); const abort = () => setRemovalInitiated(false);
const onAccountRemoved = useCallback(async () => {
await reloadUser(null);
await navigate("/auth/login");
}, [reloadUser]);
const onSettingsUpdated = useCallback( const onSettingsUpdated = useCallback(
async (updatedState) => { async (updatedState) => {
try { try {
@ -45,14 +55,7 @@ const AccountPage = () => {
</Metadata> </Metadata>
<div className="flex flex-col xl:flex-row"> <div className="flex flex-col xl:flex-row">
<div className="flex flex-col gap-10 lg:shrink-0 lg:max-w-[576px] xl:max-w-[524px]"> <div className="flex flex-col gap-10 lg:shrink-0 lg:max-w-[576px] xl:max-w-[524px]">
<section> <h4>Account</h4>
<h4>Account</h4>
<p>
Tum dicere exorsus est laborum et quasi involuta aperiri, altera prompta et expedita. Primum igitur,
inquit, modo ista sis aequitate.
</p>
</section>
<hr />
<section className="flex flex-col gap-8"> <section className="flex flex-col gap-8">
{state === State.Failure && ( {state === State.Failure && (
<Alert $variant="error">There was an error processing your request. Please try again later.</Alert> <Alert $variant="error">There was an error processing your request. Please try again later.</Alert>
@ -63,7 +66,23 @@ const AccountPage = () => {
<hr /> <hr />
<section> <section>
<h6 className="text-palette-400">Delete account</h6> <h6 className="text-palette-400">Delete account</h6>
<p>This will completely delete your account. This process can't be undone.</p> <div className="my-4">
<p>
This action will delete your account and <strong>cannot be undone</strong>.
</p>
<p>
Your uploaded files will remain accessible while any portal continues to{" "}
<HighlightedLink
as="a"
href="https://support.skynetlabs.com/key-concepts/faqs#what-is-pinning"
target="_blank"
rel="noreferrer"
>
pin
</HighlightedLink>{" "}
them to Skynet.
</p>
</div>
<button <button
type="button" type="button"
onClick={prompt} onClick={prompt}
@ -73,9 +92,12 @@ const AccountPage = () => {
</button> </button>
</section> </section>
</div> </div>
<div className="flex w-full justify-start xl:justify-end">
{isLargeScreen && <AvatarUploader className="flex flex-col gap-4" />}
</div>
{removalInitiated && ( {removalInitiated && (
<Modal onClose={abort} className="text-center"> <Modal onClose={abort} className="text-center">
<AccountRemovalForm abort={abort} onSuccess={() => navigate("/auth/login")} /> <AccountRemovalForm abort={abort} onSuccess={onAccountRemoved} />
</Modal> </Modal>
)} )}
</div> </div>

View File

@ -18,18 +18,13 @@ const NotificationsPage = () => {
<section> <section>
{/* TODO: saves on change */} {/* TODO: saves on change */}
<Switch onChange={console.info.bind(console)} labelClassName="!items-start flex-col md:flex-row"> <Switch onChange={console.info.bind(console)} labelClassName="!items-start flex-col md:flex-row">
I agreee to get the latest news, updates and special offers delivered to my email inbox. I agree to receive emails of the latest news, updates and offers.
</Switch> </Switch>
</section> </section>
<hr /> <hr />
<section> <section>
<h6 className="text-palette-300">Statistics</h6> <h6 className="text-palette-300">Statistics</h6>
{/* TODO: proper content :) */} <p>Check below to be notified by email when your usage approaches your plan's limits.</p>
<p>
Si sine causa, nollem me tamen laudandis maioribus meis corrupisti nec in malis. Si sine causa, mox
videro.
</p>
<ul className="mt-7 flex flex-col gap-2"> <ul className="mt-7 flex flex-col gap-2">
<li> <li>
{/* TODO: saves on change */} {/* TODO: saves on change */}
@ -37,7 +32,7 @@ const NotificationsPage = () => {
</li> </li>
<li> <li>
{/* TODO: saves on change */} {/* TODO: saves on change */}
<Switch onChange={console.info.bind(console)}>File limit</Switch> <Switch onChange={console.info.bind(console)}>Files limit</Switch>
</li> </li>
</ul> </ul>
</section> </section>

View File

@ -1,3 +1,3 @@
import { SkynetClient } from "skynet-js"; import { SkynetClient } from "skynet-js";
export default new SkynetClient("https://skynetpro.net"); // TODO: proper API url export default new SkynetClient(`https://${process.env.GATSBY_PORTAL_DOMAIN}`);

View File

@ -6735,11 +6735,31 @@ dot-prop@^5.2.0:
dependencies: dependencies:
is-obj "^2.0.0" is-obj "^2.0.0"
dotenv-cli@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/dotenv-cli/-/dotenv-cli-5.1.0.tgz#0d2942b089082da0157f9b26bd6c5c4dd51ef48e"
integrity sha512-NoEZAlKo9WVrG0b3i9mBxdD6INdDuGqdgR74t68t8084QcI077/1MnPerRW1odl+9uULhcdnQp2U0pYVppKHOA==
dependencies:
cross-spawn "^7.0.3"
dotenv "^16.0.0"
dotenv-expand "^8.0.1"
minimist "^1.2.5"
dotenv-expand@^5.1.0: dotenv-expand@^5.1.0:
version "5.1.0" version "5.1.0"
resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0" resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0"
integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA== integrity sha512-YXQl1DSa4/PQyRfgrv6aoNjhasp/p4qs9FjJ4q4cQk+8m4r6k4ZSiEyytKG8f8W9gi8WsQtIObNmKd+tMzNTmA==
dotenv-expand@^8.0.1:
version "8.0.3"
resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-8.0.3.tgz#29016757455bcc748469c83a19b36aaf2b83dd6e"
integrity sha512-SErOMvge0ZUyWd5B0NXMQlDkN+8r+HhVUsxgOO7IoPDOdDRD2JjExpN6y3KnFR66jsJMwSn1pqIivhU5rcJiNg==
dotenv@^16.0.0:
version "16.0.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.0.tgz#c619001253be89ebb638d027b609c75c26e47411"
integrity sha512-qD9WU0MPM4SWLPJy/r2Be+2WgQj8plChsyrCNQzW/0WjvcJQiKQJ9mH3ZgB3fxbUUxgc/11ZJ0Fi5KiimWGz2Q==
dotenv@^8.0.0, dotenv@^8.6.0: dotenv@^8.0.0, dotenv@^8.6.0:
version "8.6.0" version "8.6.0"
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b" resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b"