Merge pull request #1996 from SkynetLabs/dashboard-v2-copy-changes
Dashboard v2 - addressing ops team feedback + DX improvements
This commit is contained in:
commit
162e6de77a
|
@ -11,15 +11,20 @@ This is a Gatsby application. To run it locally, all you need is:
|
|||
|
||||
## 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:
|
||||
|
||||
```
|
||||
127.0.0.1 local.skynetpro.net
|
||||
```env
|
||||
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.
|
||||
If you're on macOS, you may need to `sudo` the command to successfully bind to port `443`.
|
||||
> It's recommended to keep the 2LD the same, so any cookies dispatched by the API work without issues.
|
||||
|
||||
> **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.
|
||||
|
|
|
@ -1,9 +1,15 @@
|
|||
require("dotenv").config({
|
||||
path: `.env.${process.env.NODE_ENV}`,
|
||||
});
|
||||
|
||||
const { createProxyMiddleware } = require("http-proxy-middleware");
|
||||
|
||||
const { GATSBY_PORTAL_DOMAIN } = process.env;
|
||||
|
||||
module.exports = {
|
||||
siteMetadata: {
|
||||
title: "Skynet Account",
|
||||
siteUrl: `https://account.${process.env.GATSBY_PORTAL_DOMAIN}/`,
|
||||
title: `Account Dashboard`,
|
||||
siteUrl: `https://account.${GATSBY_PORTAL_DOMAIN}`,
|
||||
},
|
||||
trailingSlash: "never",
|
||||
plugins: [
|
||||
|
@ -24,13 +30,27 @@ module.exports = {
|
|||
},
|
||||
],
|
||||
developMiddleware: (app) => {
|
||||
// Proxy Accounts service API requests:
|
||||
app.use(
|
||||
"/api/",
|
||||
createProxyMiddleware({
|
||||
target: "https://account.skynetpro.net",
|
||||
target: `https://account.${GATSBY_PORTAL_DOMAIN}`,
|
||||
secure: false, // Do not reject self-signed certificates.
|
||||
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": "",
|
||||
},
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
|
@ -9,7 +9,7 @@
|
|||
],
|
||||
"scripts": {
|
||||
"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",
|
||||
"build": "gatsby build",
|
||||
"serve": "gatsby serve",
|
||||
|
@ -60,6 +60,8 @@
|
|||
"babel-loader": "^8.2.3",
|
||||
"babel-plugin-preval": "^5.1.0",
|
||||
"babel-plugin-styled-components": "^2.0.2",
|
||||
"dotenv": "^16.0.0",
|
||||
"dotenv-cli": "^5.1.0",
|
||||
"eslint": "^8.9.0",
|
||||
"eslint-config-react-app": "^7.0.0",
|
||||
"eslint-plugin-storybook": "^0.5.6",
|
||||
|
|
|
@ -4,7 +4,7 @@ import { useCallback, useState } from "react";
|
|||
|
||||
import { Alert } from "../Alert";
|
||||
import { Button } from "../Button";
|
||||
import { AddSkylinkToAPIKeyForm } from "../forms/AddSkylinkToAPIKeyForm";
|
||||
import { AddSkylinkToSponsorKeyForm } from "../forms/AddSkylinkToSponsorKeyForm";
|
||||
import { CogIcon, TrashIcon } from "../Icons";
|
||||
import { Modal } from "../Modal";
|
||||
|
||||
|
@ -13,7 +13,7 @@ import { useAPIKeyRemoval } from "./useAPIKeyRemoval";
|
|||
|
||||
export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
|
||||
const { id, name, createdAt, skylinks } = apiKey;
|
||||
const isPublic = apiKey.public === "true";
|
||||
const isSponsorKey = apiKey.public === "true";
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const onSkylinkListEdited = useCallback(() => {
|
||||
|
@ -53,9 +53,9 @@ export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
|
|||
}, [abortEdit]);
|
||||
|
||||
const skylinksNumber = skylinks?.length ?? 0;
|
||||
const isNotConfigured = isPublic && skylinksNumber === 0;
|
||||
const isNotConfigured = isSponsorKey && skylinksNumber === 0;
|
||||
const skylinksPhrasePrefix = skylinksNumber === 0 ? "No" : skylinksNumber;
|
||||
const skylinksPhrase = `${skylinksPhrasePrefix} ${skylinksNumber === 1 ? "skylink" : "skylinks"} configured`;
|
||||
const skylinksPhrase = `${skylinksPhrasePrefix} ${skylinksNumber === 1 ? "skylink" : "skylinks"} sponsored`;
|
||||
|
||||
return (
|
||||
<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="flex flex-col">
|
||||
<span className={cn("truncate", { "text-palette-300": !name })}>{name || "unnamed key"}</span>
|
||||
<button
|
||||
onClick={promptEdit}
|
||||
className={cn("text-xs hover:underline decoration-dotted", {
|
||||
"text-error": isNotConfigured,
|
||||
"text-palette-400": !isNotConfigured,
|
||||
})}
|
||||
>
|
||||
{skylinksPhrase}
|
||||
</button>
|
||||
{isSponsorKey && (
|
||||
<button
|
||||
onClick={promptEdit}
|
||||
className={cn("text-xs hover:underline decoration-dotted", {
|
||||
"text-error": isNotConfigured,
|
||||
"text-palette-400": !isNotConfigured,
|
||||
})}
|
||||
>
|
||||
{skylinksPhrase}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<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="flex items-center justify-end">
|
||||
{isPublic && (
|
||||
{isSponsorKey && (
|
||||
<button
|
||||
title="Add or remove skylinks"
|
||||
aria-label="Add or remove skylinks"
|
||||
|
@ -119,7 +121,7 @@ export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
|
|||
)}
|
||||
{editInitiated && (
|
||||
<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 ? (
|
||||
<ul className="text-xs flex flex-col gap-2">
|
||||
{skylinks.map((skylink) => (
|
||||
|
@ -143,7 +145,7 @@ export const APIKey = ({ apiKey, onRemoved, onEdited, onRemovalError }) => {
|
|||
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <Alert $variant="error">{error}</Alert>}
|
||||
<AddSkylinkToAPIKeyForm addSkylink={addSkylink} />
|
||||
<AddSkylinkToSponsorKeyForm addSkylink={addSkylink} />
|
||||
</div>
|
||||
<div className="flex gap-4 justify-center mt-4">
|
||||
<Button onClick={closeEditModal}>Close</Button>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useUser } from "../../contexts/user";
|
||||
import { SimpleUploadIcon } from "../Icons";
|
||||
// import { SimpleUploadIcon } from "../Icons";
|
||||
|
||||
const AVATAR_PLACEHOLDER = "/images/avatar-placeholder.svg";
|
||||
|
||||
|
@ -20,6 +20,7 @@ export const AvatarUploader = (props) => {
|
|||
>
|
||||
<img src={imageUrl} className="w-[160px]" alt="" />
|
||||
</div>
|
||||
{/* TODO: uncomment when avatar uploads work
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
{/* TODO: actual uploading */}
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import dayjs from "dayjs";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
|
||||
import { useUser } from "../../contexts/user";
|
||||
import useActivePlan from "../../hooks/useActivePlan";
|
||||
|
@ -28,17 +29,20 @@ const CurrentPlan = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-col h-full">
|
||||
<h4>{activePlan.name}</h4>
|
||||
<div className="text-palette-400">
|
||||
{activePlan.price === 0 && <p>100GB without paying a dime! 🎉</p>}
|
||||
<div className="text-palette-400 justify-between flex flex-col grow">
|
||||
{activePlan.price === 0 && activePlan.limits && (
|
||||
<p>{prettyBytes(activePlan.limits.storageLimit, { binary: true })} without paying a dime! 🎉</p>
|
||||
)}
|
||||
{activePlan.price !== 0 &&
|
||||
(user.subscriptionCancelAtPeriodEnd ? (
|
||||
<p>Your subscription expires {dayjs(user.subscribedUntil).fromNow()}</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} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -44,7 +44,7 @@ const useUsageData = () => {
|
|||
};
|
||||
|
||||
const size = (bytes) => {
|
||||
const text = fileSize(bytes ?? 0, { maximumFractionDigits: 0 });
|
||||
const text = fileSize(bytes ?? 0, { maximumFractionDigits: 0, binary: true });
|
||||
const [value, unit] = text.split(" ");
|
||||
|
||||
return {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { useMemo } from "react";
|
||||
import prettyBytes from "pretty-bytes";
|
||||
import dayjs from "dayjs";
|
||||
import { DATE_FORMAT } from "../../lib/config";
|
||||
|
||||
const parseFileName = (fileName) => {
|
||||
const lastDotIndex = Math.max(0, fileName.lastIndexOf(".")) || Infinity;
|
||||
|
@ -10,7 +11,7 @@ const parseFileName = (fileName) => {
|
|||
|
||||
const formatItem = ({ size, name: rawFileName, uploadedOn, downloadedOn, ...rest }) => {
|
||||
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 {
|
||||
...rest,
|
||||
|
|
|
@ -1,8 +1,19 @@
|
|||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
|
||||
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 = () => (
|
||||
<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>
|
||||
);
|
||||
|
|
|
@ -2,5 +2,5 @@ import { Link } from "gatsby";
|
|||
import styled from "styled-components";
|
||||
|
||||
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",
|
||||
})``;
|
||||
|
|
|
@ -3,13 +3,13 @@ import useSWR from "swr";
|
|||
|
||||
import { Table, TableBody, TableCell, TableRow } from "../Table";
|
||||
import { ContainerLoadingIndicator } from "../LoadingIndicator";
|
||||
import useFormattedFilesData from "../FileList/useFormattedFilesData";
|
||||
|
||||
import { ViewAllLink } from "./ViewAllLink";
|
||||
import useFormattedActivityData from "./useFormattedActivityData";
|
||||
|
||||
export default function ActivityTable({ type }) {
|
||||
const { data, error } = useSWR(`user/${type}?pageSize=3`);
|
||||
const items = useFormattedActivityData(data?.items || []);
|
||||
const items = useFormattedFilesData(data?.items || []);
|
||||
|
||||
if (!items.length) {
|
||||
return (
|
||||
|
|
|
@ -1,23 +1,13 @@
|
|||
import * as React from "react";
|
||||
|
||||
import { Panel } from "../Panel";
|
||||
import { Tab, TabPanel, Tabs } from "../Tabs";
|
||||
|
||||
import ActivityTable from "./ActivityTable";
|
||||
|
||||
export default function LatestActivity() {
|
||||
return (
|
||||
<Panel title="Latest activity">
|
||||
<Tabs>
|
||||
<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 title="Latest uploads">
|
||||
<ActivityTable type="uploads" />
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
|||
export * from "./Tooltip";
|
|
@ -118,7 +118,7 @@ const Uploader = ({ mode }) => {
|
|||
</div>
|
||||
|
||||
{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) => (
|
||||
<UploaderItem key={upload.id} onUploadStateChange={onUploadStateChange} upload={upload} />
|
||||
))}
|
||||
|
|
|
@ -34,8 +34,9 @@ export const AccountRemovalForm = ({ abort, onSuccess }) => {
|
|||
<Form className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h4>Delete account</h4>
|
||||
<p>This will completely delete your account.</p>
|
||||
<p>
|
||||
This will completely delete your account. <strong>This process can't be undone.</strong>
|
||||
<strong>This process cannot be undone.</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import * as Yup from "yup";
|
|||
import { forwardRef, useImperativeHandle, useState } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Formik, Form } from "formik";
|
||||
import cn from "classnames";
|
||||
|
||||
import accountsService from "../../services/accountsService";
|
||||
|
||||
|
@ -9,7 +10,6 @@ import { Alert } from "../Alert";
|
|||
import { Button } from "../Button";
|
||||
import { CopyButton } from "../CopyButton";
|
||||
import { TextField } from "../Form/TextField";
|
||||
import { CircledProgressIcon, PlusIcon } from "../Icons";
|
||||
|
||||
const newAPIKeySchema = Yup.object().shape({
|
||||
name: Yup.string(),
|
||||
|
@ -22,7 +22,7 @@ const State = {
|
|||
};
|
||||
|
||||
export const APIKeyType = {
|
||||
Public: "public",
|
||||
Sponsor: "sponsor",
|
||||
General: "general",
|
||||
};
|
||||
|
||||
|
@ -37,10 +37,10 @@ export const AddAPIKeyForm = forwardRef(({ onSuccess, type }, ref) => {
|
|||
return (
|
||||
<div ref={ref} className="flex flex-col gap-4">
|
||||
{state === State.Success && (
|
||||
<Alert $variant="success" className="text-center">
|
||||
<Alert $variant="success">
|
||||
<strong>Success!</strong>
|
||||
<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">
|
||||
{generatedKey}
|
||||
</code>
|
||||
|
@ -62,8 +62,8 @@ export const AddAPIKeyForm = forwardRef(({ onSuccess, type }, ref) => {
|
|||
.post("user/apikeys", {
|
||||
json: {
|
||||
name,
|
||||
public: type === APIKeyType.Public ? "true" : "false",
|
||||
skylinks: type === APIKeyType.Public ? [] : null,
|
||||
public: type === APIKeyType.Sponsor ? "true" : "false",
|
||||
skylinks: type === APIKeyType.Sponsor ? [] : null,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
@ -78,26 +78,20 @@ export const AddAPIKeyForm = forwardRef(({ onSuccess, type }, ref) => {
|
|||
}}
|
||||
>
|
||||
{({ errors, touched, isSubmitting }) => (
|
||||
<Form className="grid grid-cols-[1fr_min-content] w-full gap-y-2 gap-x-4 items-start">
|
||||
<div className="flex items-center">
|
||||
<TextField
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
label="New API Key Name"
|
||||
placeholder="my_applications_statistics"
|
||||
error={errors.name}
|
||||
touched={touched.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex mt-5 justify-center">
|
||||
{isSubmitting ? (
|
||||
<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>
|
||||
)}
|
||||
<Form className="flex flex-col gap-4">
|
||||
<TextField
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
label="New API Key Label"
|
||||
placeholder="my_applications_statistics"
|
||||
error={errors.name}
|
||||
touched={touched.name}
|
||||
/>
|
||||
<div className="flex justify-center">
|
||||
<Button type="submit" disabled={isSubmitting} className={cn({ "cursor-wait": isSubmitting })}>
|
||||
{isSubmitting ? "Generating your API key..." : "Generate your API key"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
|
@ -110,5 +104,5 @@ AddAPIKeyForm.displayName = "AddAPIKeyForm";
|
|||
|
||||
AddAPIKeyForm.propTypes = {
|
||||
onSuccess: PropTypes.func.isRequired,
|
||||
type: PropTypes.oneOf([APIKeyType.Public, APIKeyType.General]).isRequired,
|
||||
type: PropTypes.oneOf([APIKeyType.Sponsor, APIKeyType.General]).isRequired,
|
||||
};
|
||||
|
|
|
@ -19,7 +19,7 @@ const newSkylinkSchema = Yup.object().shape({
|
|||
}),
|
||||
});
|
||||
|
||||
export const AddSkylinkToAPIKeyForm = ({ addSkylink }) => (
|
||||
export const AddSkylinkToSponsorKeyForm = ({ addSkylink }) => (
|
||||
<Formik
|
||||
initialValues={{
|
||||
skylink: "",
|
||||
|
@ -58,6 +58,6 @@ export const AddSkylinkToAPIKeyForm = ({ addSkylink }) => (
|
|||
</Formik>
|
||||
);
|
||||
|
||||
AddSkylinkToAPIKeyForm.propTypes = {
|
||||
AddSkylinkToSponsorKeyForm.propTypes = {
|
||||
addSkylink: PropTypes.func.isRequired,
|
||||
};
|
|
@ -25,7 +25,7 @@ const skylinkValidator = (optional) => (value) => {
|
|||
}
|
||||
};
|
||||
|
||||
const newPublicAPIKeySchema = Yup.object().shape({
|
||||
const newSponsorKeySchema = Yup.object().shape({
|
||||
name: Yup.string(),
|
||||
skylinks: Yup.array().of(Yup.string().test("skylink", "Provide a valid Skylink", skylinkValidator(false))),
|
||||
nextSkylink: Yup.string().when("skylinks", {
|
||||
|
@ -41,7 +41,7 @@ const State = {
|
|||
Failure: "FAILURE",
|
||||
};
|
||||
|
||||
export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, ref) => {
|
||||
export const AddSponsorKeyForm = forwardRef(({ onSuccess }, ref) => {
|
||||
const [state, setState] = useState(State.Pure);
|
||||
const [generatedKey, setGeneratedKey] = useState(null);
|
||||
|
||||
|
@ -52,10 +52,10 @@ export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, ref) => {
|
|||
return (
|
||||
<div ref={ref} className="flex flex-col gap-4">
|
||||
{state === State.Success && (
|
||||
<Alert $variant="success" className="text-center">
|
||||
<Alert $variant="success">
|
||||
<strong>Success!</strong>
|
||||
<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">
|
||||
{generatedKey}
|
||||
</code>
|
||||
|
@ -72,7 +72,7 @@ export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, ref) => {
|
|||
skylinks: [],
|
||||
nextSkylink: "",
|
||||
}}
|
||||
validationSchema={newPublicAPIKeySchema}
|
||||
validationSchema={newSponsorKeySchema}
|
||||
onSubmit={async ({ name, skylinks, nextSkylink }, { resetForm }) => {
|
||||
try {
|
||||
const { key } = await accountsService
|
||||
|
@ -101,14 +101,14 @@ export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, ref) => {
|
|||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
label="Public API Key Name"
|
||||
label="Sponsor API Key Name"
|
||||
placeholder="my_applications_statistics"
|
||||
error={errors.name}
|
||||
touched={touched.name}
|
||||
/>
|
||||
</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
|
||||
name="skylinks"
|
||||
render={({ push, remove }) => {
|
||||
|
@ -182,7 +182,7 @@ export const AddPublicAPIKeyForm = forwardRef(({ onSuccess }, ref) => {
|
|||
className={cn("px-2.5", { "cursor-wait": isSubmitting })}
|
||||
disabled={!isValid || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Generating" : "Generate"} your public key
|
||||
{isSubmitting ? "Generating your sponsor key..." : "Generate your sponsor key"}
|
||||
</Button>
|
||||
</div>
|
||||
</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,
|
||||
};
|
|
@ -32,14 +32,16 @@ export const SignUpForm = ({ onSuccess, onFailure }) => (
|
|||
validationSchema={registrationSchema}
|
||||
onSubmit={async ({ email, password }, { setErrors }) => {
|
||||
try {
|
||||
await accountsService.post("user", {
|
||||
json: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
});
|
||||
const user = await accountsService
|
||||
.post("user", {
|
||||
json: {
|
||||
email,
|
||||
password,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
|
||||
onSuccess();
|
||||
onSuccess(user);
|
||||
} catch (err) {
|
||||
let isFormErrorSet = false;
|
||||
|
||||
|
|
|
@ -16,8 +16,8 @@ const Sidebar = () => (
|
|||
<SidebarLink activeClassName="!border-l-primary" to="/settings/export">
|
||||
Export
|
||||
</SidebarLink>
|
||||
<SidebarLink activeClassName="!border-l-primary" to="/settings/api-keys">
|
||||
API Keys
|
||||
<SidebarLink activeClassName="!border-l-primary" to="/settings/developer-settings">
|
||||
Developer settings
|
||||
</SidebarLink>
|
||||
</nav>
|
||||
</aside>
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
export const DATE_FORMAT = "MMM D, YYYY HH:MM";
|
|
@ -1,5 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { navigate } from "gatsby";
|
||||
import { useCallback, useState } from "react";
|
||||
import bytes from "pretty-bytes";
|
||||
|
||||
import AuthLayout from "../../layouts/AuthLayout";
|
||||
|
@ -10,6 +9,7 @@ import { SignUpForm } from "../../components/forms/SignUpForm";
|
|||
import { usePortalSettings } from "../../contexts/portal-settings";
|
||||
import { PlansProvider, usePlans } from "../../contexts/plans";
|
||||
import { Metadata } from "../../components/Metadata";
|
||||
import { useUser } from "../../contexts/user";
|
||||
|
||||
const FreePortalHeader = () => {
|
||||
const { plans } = usePlans();
|
||||
|
@ -47,14 +47,14 @@ const State = {
|
|||
const SignUpPage = () => {
|
||||
const [state, setState] = useState(State.Pure);
|
||||
const { settings } = usePortalSettings();
|
||||
const { mutate: refreshUserState } = useUser();
|
||||
|
||||
useEffect(() => {
|
||||
if (state === State.Success) {
|
||||
const timer = setTimeout(() => navigate(settings.isSubscriptionRequired ? "/upgrade" : "/"), 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [state, settings.isSubscriptionRequired]);
|
||||
const onUserCreated = useCallback(
|
||||
(newUser) => {
|
||||
refreshUserState(newUser);
|
||||
},
|
||||
[refreshUserState]
|
||||
);
|
||||
|
||||
return (
|
||||
<PlansProvider>
|
||||
|
@ -70,7 +70,7 @@ const SignUpPage = () => {
|
|||
|
||||
{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">
|
||||
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 && (
|
||||
<p className="text-error text-center">Something went wrong, please try again later.</p>
|
||||
)}
|
||||
|
|
|
@ -1,32 +1,19 @@
|
|||
import * as React from "react";
|
||||
import { useSearchParam } from "react-use";
|
||||
|
||||
import DashboardLayout from "../layouts/DashboardLayout";
|
||||
|
||||
import { Panel } from "../components/Panel";
|
||||
import { Tab, TabPanel, Tabs } from "../components/Tabs";
|
||||
import { Metadata } from "../components/Metadata";
|
||||
import FileList from "../components/FileList/FileList";
|
||||
|
||||
const FilesPage = () => {
|
||||
const defaultTab = useSearchParam("tab");
|
||||
|
||||
return (
|
||||
<>
|
||||
<Metadata>
|
||||
<title>My Files</title>
|
||||
<title>Files</title>
|
||||
</Metadata>
|
||||
<Panel title="Files">
|
||||
<Tabs defaultTab={defaultTab || "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>
|
||||
<FileList type="uploads" />
|
||||
</Panel>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -6,13 +6,14 @@ import UserSettingsLayout from "../../layouts/UserSettingsLayout";
|
|||
import { AddAPIKeyForm, APIKeyType } from "../../components/forms/AddAPIKeyForm";
|
||||
import { APIKeyList } from "../../components/APIKeyList/APIKeyList";
|
||||
import { Alert } from "../../components/Alert";
|
||||
import { AddPublicAPIKeyForm } from "../../components/forms/AddPublicAPIKeyForm";
|
||||
import { AddSponsorKeyForm } from "../../components/forms/AddSponsorKeyForm";
|
||||
import { Metadata } from "../../components/Metadata";
|
||||
import HighlightedLink from "../../components/HighlightedLink";
|
||||
|
||||
const APIKeysPage = () => {
|
||||
const { data: apiKeys = [], mutate: reloadKeys, error } = useSWR("user/apikeys");
|
||||
const generalKeys = apiKeys.filter(({ public: isPublic }) => isPublic === "false");
|
||||
const publicKeys = apiKeys.filter(({ public: isPublic }) => isPublic === "true");
|
||||
const DeveloperSettingsPage = () => {
|
||||
const { data: allKeys = [], mutate: reloadKeys, error } = useSWR("user/apikeys");
|
||||
const apiKeys = allKeys.filter(({ public: isPublic }) => isPublic === "false");
|
||||
const sponsorKeys = allKeys.filter(({ public: isPublic }) => isPublic === "true");
|
||||
|
||||
const publicFormRef = useRef();
|
||||
const generalFormRef = useRef();
|
||||
|
@ -31,53 +32,57 @@ const APIKeysPage = () => {
|
|||
return (
|
||||
<>
|
||||
<Metadata>
|
||||
<title>API Keys</title>
|
||||
<title>Developer settings</title>
|
||||
</Metadata>
|
||||
<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>
|
||||
<h4>API Keys</h4>
|
||||
<p className="leading-relaxed">There are two types of API keys that you can generate for your account.</p>
|
||||
<p>Make sure to use the appropriate type.</p>
|
||||
<h4>Developer settings</h4>
|
||||
<p>API keys allow developers and applications to extend the functionality of your portal account.</p>
|
||||
<p>Skynet uses two types of API keys, explained below.</p>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h5>Public keys</h5>
|
||||
<p className="text-palette-500">
|
||||
Public keys provide read access to a selected list of skylinks. You can share them publicly.
|
||||
<h5>Sponsor keys</h5>
|
||||
<div className="text-palette-500"></div>
|
||||
<p>
|
||||
Sponsor keys allow users without an account on this portal to download skylinks covered by the API key.
|
||||
</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">
|
||||
<AddPublicAPIKeyForm ref={publicFormRef} onSuccess={refreshState} />
|
||||
<AddSponsorKeyForm ref={publicFormRef} onSuccess={refreshState} />
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<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>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
{publicKeys?.length > 0 ? (
|
||||
<APIKeyList title="Your public keys" keys={publicKeys} reloadKeys={() => refreshState(true)} />
|
||||
{sponsorKeys?.length > 0 ? (
|
||||
<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>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<hr />
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<h5>General keys</h5>
|
||||
<h5>API keys</h5>
|
||||
<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 className="underline">
|
||||
This type of API keys needs to be kept secret and should never be shared with anyone.
|
||||
</p>
|
||||
|
||||
<div className="mt-4">
|
||||
<AddAPIKeyForm ref={generalFormRef} onSuccess={refreshState} type={APIKeyType.General} />
|
||||
</div>
|
||||
|
@ -88,10 +93,10 @@ const APIKeysPage = () => {
|
|||
</Alert>
|
||||
) : (
|
||||
<div className="mt-4">
|
||||
{generalKeys?.length > 0 ? (
|
||||
<APIKeyList title="Your general keys" keys={generalKeys} reloadKeys={() => refreshState(true)} />
|
||||
{apiKeys?.length > 0 ? (
|
||||
<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>
|
||||
)}
|
||||
|
@ -105,6 +110,6 @@ const APIKeysPage = () => {
|
|||
);
|
||||
};
|
||||
|
||||
APIKeysPage.Layout = UserSettingsLayout;
|
||||
DeveloperSettingsPage.Layout = UserSettingsLayout;
|
||||
|
||||
export default APIKeysPage;
|
||||
export default DeveloperSettingsPage;
|
|
@ -38,8 +38,8 @@ const ExportPage = () => {
|
|||
<section>
|
||||
<h4>Export</h4>
|
||||
<p>
|
||||
Et quidem exercitus quid ex eo delectu rerum, quem modo ista sis aequitate. Probabo, inquit, modo dixi,
|
||||
constituto.
|
||||
Select the items you want to export. You can use this data to migrate your account to another Skynet
|
||||
portal.
|
||||
</p>
|
||||
</section>
|
||||
<hr />
|
||||
|
|
|
@ -8,6 +8,10 @@ import { Modal } from "../../components/Modal/Modal";
|
|||
import { AccountRemovalForm } from "../../components/forms/AccountRemovalForm";
|
||||
import { Alert } from "../../components/Alert";
|
||||
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 = {
|
||||
Pure: "PURE",
|
||||
|
@ -19,10 +23,16 @@ const AccountPage = () => {
|
|||
const { user, mutate: reloadUser } = useUser();
|
||||
const [state, setState] = useState(State.Pure);
|
||||
const [removalInitiated, setRemovalInitiated] = useState(false);
|
||||
const isLargeScreen = useMedia(`(min-width: ${theme.screens.xl})`);
|
||||
|
||||
const prompt = () => setRemovalInitiated(true);
|
||||
const abort = () => setRemovalInitiated(false);
|
||||
|
||||
const onAccountRemoved = useCallback(async () => {
|
||||
await reloadUser(null);
|
||||
await navigate("/auth/login");
|
||||
}, [reloadUser]);
|
||||
|
||||
const onSettingsUpdated = useCallback(
|
||||
async (updatedState) => {
|
||||
try {
|
||||
|
@ -45,14 +55,7 @@ const AccountPage = () => {
|
|||
</Metadata>
|
||||
<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]">
|
||||
<section>
|
||||
<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 />
|
||||
<h4>Account</h4>
|
||||
<section className="flex flex-col gap-8">
|
||||
{state === State.Failure && (
|
||||
<Alert $variant="error">There was an error processing your request. Please try again later.</Alert>
|
||||
|
@ -63,7 +66,23 @@ const AccountPage = () => {
|
|||
<hr />
|
||||
<section>
|
||||
<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
|
||||
type="button"
|
||||
onClick={prompt}
|
||||
|
@ -73,9 +92,12 @@ const AccountPage = () => {
|
|||
</button>
|
||||
</section>
|
||||
</div>
|
||||
<div className="flex w-full justify-start xl:justify-end">
|
||||
{isLargeScreen && <AvatarUploader className="flex flex-col gap-4" />}
|
||||
</div>
|
||||
{removalInitiated && (
|
||||
<Modal onClose={abort} className="text-center">
|
||||
<AccountRemovalForm abort={abort} onSuccess={() => navigate("/auth/login")} />
|
||||
<AccountRemovalForm abort={abort} onSuccess={onAccountRemoved} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
@ -18,18 +18,13 @@ const NotificationsPage = () => {
|
|||
<section>
|
||||
{/* TODO: saves on change */}
|
||||
<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>
|
||||
</section>
|
||||
<hr />
|
||||
<section>
|
||||
<h6 className="text-palette-300">Statistics</h6>
|
||||
{/* TODO: proper content :) */}
|
||||
<p>
|
||||
Si sine causa, nollem me tamen laudandis maioribus meis corrupisti nec in malis. Si sine causa, mox
|
||||
videro.
|
||||
</p>
|
||||
|
||||
<p>Check below to be notified by email when your usage approaches your plan's limits.</p>
|
||||
<ul className="mt-7 flex flex-col gap-2">
|
||||
<li>
|
||||
{/* TODO: saves on change */}
|
||||
|
@ -37,7 +32,7 @@ const NotificationsPage = () => {
|
|||
</li>
|
||||
<li>
|
||||
{/* TODO: saves on change */}
|
||||
<Switch onChange={console.info.bind(console)}>File limit</Switch>
|
||||
<Switch onChange={console.info.bind(console)}>Files limit</Switch>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
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}`);
|
||||
|
|
|
@ -6735,11 +6735,31 @@ dot-prop@^5.2.0:
|
|||
dependencies:
|
||||
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:
|
||||
version "5.1.0"
|
||||
resolved "https://registry.yarnpkg.com/dotenv-expand/-/dotenv-expand-5.1.0.tgz#3fbaf020bfd794884072ea26b1e9791d45a629f0"
|
||||
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:
|
||||
version "8.6.0"
|
||||
resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.6.0.tgz#061af664d19f7f4d8fc6e4ff9b584ce237adcb8b"
|
||||
|
|
Reference in New Issue