diff --git a/.env b/.env
index ba74042..ab29ada 100644
--- a/.env
+++ b/.env
@@ -1,5 +1,12 @@
+# Server env
POSTGRES_USER="postgres"
POSTGRES_PASSWORD="localpw"
POSTGRES_URI="localhost"
POSTGRES_PORT="5432"
-POSTGRES_DB_NAME="forc_pub"
\ No newline at end of file
+POSTGRES_DB_NAME="forc_pub"
+
+# Local env
+CORS_HTTP_ORIGIN="http://localhost:3000"
+
+# Diesel CLI env
+DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_URI}/${POSTGRES_DB_NAME}"
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index 19dea3c..fb82881 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -438,11 +438,14 @@ dependencies = [
"dotenvy",
"hex",
"nanoid",
+ "rand",
"regex",
"reqwest",
"rocket",
"serde",
"serde_json",
+ "serial_test",
+ "sha2",
"thiserror",
"tokio",
"uuid",
@@ -480,6 +483,7 @@ checksum = "38390104763dc37a5145a53c29c63c1290b5d316d6086ec32c293f6736051bb0"
dependencies = [
"futures-channel",
"futures-core",
+ "futures-executor",
"futures-io",
"futures-sink",
"futures-task",
@@ -502,6 +506,17 @@ version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04909a7a7e4633ae6c4a9ab280aeb86da1236243a77b694a49eacd659a4bd3ac"
+[[package]]
+name = "futures-executor"
+version = "0.3.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7acc85df6714c176ab5edf386123fafe217be88c0840ec11f199441134a074e2"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
[[package]]
name = "futures-io"
version = "0.3.25"
@@ -1580,6 +1595,15 @@ version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde"
+[[package]]
+name = "scc"
+version = "2.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec96560eea317a9cc4e0bb1f6a2c93c09a19b8c4fc5cb3fcc0ec1c094cd783e2"
+dependencies = [
+ "sdd",
+]
+
[[package]]
name = "schannel"
version = "0.1.23"
@@ -1620,6 +1644,12 @@ dependencies = [
"untrusted",
]
+[[package]]
+name = "sdd"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b84345e4c9bd703274a082fb80caaa99b7612be48dfaa1dd9266577ec412309d"
+
[[package]]
name = "security-framework"
version = "2.10.0"
@@ -1695,11 +1725,36 @@ dependencies = [
"serde",
]
+[[package]]
+name = "serial_test"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b4b487fe2acf240a021cf57c6b2b4903b1e78ca0ecd862a71b71d2a51fed77d"
+dependencies = [
+ "futures",
+ "log",
+ "once_cell",
+ "parking_lot",
+ "scc",
+ "serial_test_derive",
+]
+
+[[package]]
+name = "serial_test_derive"
+version = "3.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82fe9db325bcef1fbcde82e078a5cc4efdf787e96b3b9cf45b50b529f2083d67"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.57",
+]
+
[[package]]
name = "sha2"
-version = "0.10.6"
+version = "0.10.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82e6b795fe2e3b1e845bafcb27aa35405c4d47cdfc92af5fc8d3002f76cebdc0"
+checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
dependencies = [
"cfg-if",
"cpufeatures",
diff --git a/Cargo.toml b/Cargo.toml
index 72cd0b5..d128183 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -21,3 +21,6 @@ diesel = { version = "2.1.6", features = ["postgres", "uuid", "r2d2"] }
dotenvy = "0.15"
uuid = "1.8.0"
diesel_migrations = "2.1.0"
+rand = "0.8.5"
+sha2 = "0.10.8"
+serial_test = "3.1.1"
diff --git a/app/package-lock.json b/app/package-lock.json
index 8f9d421..f289321 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -20,10 +20,14 @@
"@types/node": "^16.18.91",
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
+ "axios": "^1.6.8",
"react": "^18.2.0",
+ "react-cookie": "^7.1.4",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"react-scripts": "5.0.1",
+ "react-use-cookie": "^1.5.0",
+ "typed-axios-instance": "^3.3.1",
"typescript": "^4.9.5",
"usehooks-ts": "^3.0.2",
"web-vitals": "^2.1.4"
@@ -4520,6 +4524,11 @@
"@types/node": "*"
}
},
+ "node_modules/@types/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="
+ },
"node_modules/@types/eslint": {
"version": "8.56.6",
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.6.tgz",
@@ -4573,6 +4582,15 @@
"@types/node": "*"
}
},
+ "node_modules/@types/hoist-non-react-statics": {
+ "version": "3.3.5",
+ "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz",
+ "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==",
+ "dependencies": {
+ "@types/react": "*",
+ "hoist-non-react-statics": "^3.3.0"
+ }
+ },
"node_modules/@types/html-minifier-terser": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz",
@@ -5343,6 +5361,17 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/ansi-escapes/node_modules/type-fest": {
+ "version": "0.21.3",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
+ "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/ansi-html-community": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
@@ -5678,6 +5707,29 @@
"node": ">=4"
}
},
+ "node_modules/axios": {
+ "version": "1.6.8",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz",
+ "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==",
+ "dependencies": {
+ "follow-redirects": "^1.15.6",
+ "form-data": "^4.0.0",
+ "proxy-from-env": "^1.1.0"
+ }
+ },
+ "node_modules/axios/node_modules/form-data": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+ "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/axobject-query": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz",
@@ -15075,6 +15127,11 @@
"node": ">= 0.10"
}
},
+ "node_modules/proxy-from-env": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+ },
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
@@ -15224,6 +15281,19 @@
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg=="
},
+ "node_modules/react-cookie": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/react-cookie/-/react-cookie-7.1.4.tgz",
+ "integrity": "sha512-wDxxa/HYaSXSMlyWJvJ5uZTzIVtQTPf1gMksFgwAz/2/W3lCtY8r4OChCXMPE7wax0PAdMY97UkNJedGv7KnDw==",
+ "dependencies": {
+ "@types/hoist-non-react-statics": "^3.3.5",
+ "hoist-non-react-statics": "^3.3.2",
+ "universal-cookie": "^7.0.0"
+ },
+ "peerDependencies": {
+ "react": ">= 16.3.0"
+ }
+ },
"node_modules/react-dev-utils": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-12.0.1.tgz",
@@ -15488,6 +15558,18 @@
"react-dom": ">=16.6.0"
}
},
+ "node_modules/react-use-cookie": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/react-use-cookie/-/react-use-cookie-1.5.0.tgz",
+ "integrity": "sha512-zPEAmAYbRLXzpi3VD3rjYHszTo8BonuiaiLH/jYixHr6qE+Yukm2lA6AsinX1uL7/9nFSVeKBLqI4oOZYdhghQ==",
+ "engines": {
+ "node": ">=8",
+ "npm": ">=5"
+ },
+ "peerDependencies": {
+ "react": ">=16.8"
+ }
+ },
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -17390,11 +17472,12 @@
}
},
"node_modules/type-fest": {
- "version": "0.21.3",
- "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
- "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.18.1.tgz",
+ "integrity": "sha512-qXhgeNsX15bM63h5aapNFcQid9jRF/l3ojDoDFmekDQEUufZ9U4ErVt6SjDxnHp48Ltrw616R8yNc3giJ3KvVQ==",
+ "peer": true,
"engines": {
- "node": ">=10"
+ "node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@@ -17481,6 +17564,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/typed-axios-instance": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/typed-axios-instance/-/typed-axios-instance-3.3.1.tgz",
+ "integrity": "sha512-7psbeu3yncZZGJFduGXCuq0HIxLbUltPiOsInTa9Wo7k3K6kdZNd9Q/QKB61g6N4eVXiVT8Ey7WpS1aozXkniA==",
+ "peerDependencies": {
+ "axios": ">=1.0.0",
+ "type-fest": ">=3.0.0"
+ }
+ },
"node_modules/typedarray-to-buffer": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
@@ -17567,6 +17659,15 @@
"node": ">=8"
}
},
+ "node_modules/universal-cookie": {
+ "version": "7.1.4",
+ "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.1.4.tgz",
+ "integrity": "sha512-Q+DVJsdykStWRMtXr2Pdj3EF98qZHUH/fXv/gwFz/unyToy1Ek1w5GsWt53Pf38tT8Gbcy5QNsj61Xe9TggP4g==",
+ "dependencies": {
+ "@types/cookie": "^0.6.0",
+ "cookie": "^0.6.0"
+ }
+ },
"node_modules/universalify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
diff --git a/app/package.json b/app/package.json
index 569de55..5021513 100644
--- a/app/package.json
+++ b/app/package.json
@@ -15,10 +15,13 @@
"@types/node": "^16.18.91",
"@types/react": "^18.2.67",
"@types/react-dom": "^18.2.22",
+ "axios": "^1.6.8",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.3",
"react-scripts": "5.0.1",
+ "react-use-cookie": "^1.5.0",
+ "typed-axios-instance": "^3.3.1",
"typescript": "^4.9.5",
"usehooks-ts": "^3.0.2",
"web-vitals": "^2.1.4"
diff --git a/app/src/features/tokens/components/CopyableToken.tsx b/app/src/features/tokens/components/CopyableToken.tsx
new file mode 100644
index 0000000..5664f6d
--- /dev/null
+++ b/app/src/features/tokens/components/CopyableToken.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import IconButton from '@mui/material/IconButton';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+
+export interface CopyableProps {
+ token: string;
+}
+
+async function handleCopy(value: string) {
+ await navigator.clipboard.writeText(value);
+}
+
+function CopyableToken({ token }: CopyableProps) {
+ return (
+
+
+
+ handleCopy(token)} aria-label='copy'>
+
+
+
+
+ );
+}
+
+export default CopyableToken;
diff --git a/app/src/features/tokens/components/TokenCard.tsx b/app/src/features/tokens/components/TokenCard.tsx
new file mode 100644
index 0000000..86c0b7a
--- /dev/null
+++ b/app/src/features/tokens/components/TokenCard.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { Button } from '@mui/material';
+import { Token } from '../hooks/useApiTokens';
+import CopyableToken from './CopyableToken';
+
+export interface TokenCardProps {
+ token: Token;
+ handleRevoke: () => Promise;
+}
+
+function TokenCard({ token, handleRevoke }: TokenCardProps) {
+ return (
+
+
+
{token.name}
+
+
+
+
+ {`Created ${token.createdAt.toLocaleString()}`}
+
+ {token.token && (
+ <>
+
+ {
+ 'Make sure to copy your API token now. You won’t be able to see it again!'
+ }
+
+
+
+ >
+ )}
+
+ );
+}
+
+export default TokenCard;
diff --git a/app/src/features/tokens/hooks/useApiTokens.ts b/app/src/features/tokens/hooks/useApiTokens.ts
new file mode 100644
index 0000000..803f258
--- /dev/null
+++ b/app/src/features/tokens/hooks/useApiTokens.ts
@@ -0,0 +1,72 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useGithubAuth } from '../../toolbar/hooks/useGithubAuth';
+import HTTP, {
+ CreateTokenResponse,
+ RawToken,
+} from '../../../utils/http';
+
+export interface Token {
+ id: string;
+ name: string;
+ token?: string;
+ createdAt: Date;
+}
+
+function rawTokenToToken(rawToken: RawToken): Token {
+ return {
+ id: rawToken.id,
+ name: rawToken.name,
+ token: rawToken.token,
+ createdAt: new Date(rawToken.createdAt),
+ };
+}
+
+export function useApiTokens(): {
+ newToken: Token | null;
+ tokens: Token[];
+ createToken: (name: string) => Promise;
+ revokeToken: (id: string) => Promise;
+} {
+ const [githubUser] = useGithubAuth();
+ const [tokens, setTokens] = useState([]);
+ const [newToken, setNewToken] = useState(null);
+
+ const createToken = useCallback(
+ async (name: string) => {
+ const { data } = await HTTP.post(`/new_token`, { name });
+ if (data.token) {
+ setNewToken(rawTokenToToken(data.token));
+ }
+ return data;
+ },
+ [setNewToken]
+ );
+
+ const revokeToken = useCallback(
+ async (id: string) => {
+ HTTP.delete(`/token/${id}`).then(() => {
+ setTokens([...tokens.filter((token) => token.id !== id)]);
+ if (newToken?.id === id) {
+ setNewToken(null);
+ }
+ });
+ },
+ [setTokens, tokens, newToken, setNewToken]
+ );
+
+ useEffect(() => {
+ if (!githubUser) {
+ return;
+ }
+
+ HTTP.get(`/tokens`).then(({data}) => {
+ setTokens([
+ ...data.tokens
+ .filter((token) => token.id !== newToken?.id)
+ .map(rawTokenToToken),
+ ]);
+ });
+ }, [setTokens, githubUser, newToken]);
+
+ return { newToken, tokens, createToken, revokeToken };
+}
diff --git a/app/src/features/toolbar/components/UserButton.tsx b/app/src/features/toolbar/components/UserButton.tsx
index 42b650f..0a03291 100644
--- a/app/src/features/toolbar/components/UserButton.tsx
+++ b/app/src/features/toolbar/components/UserButton.tsx
@@ -6,7 +6,6 @@ import Menu from '@mui/material/Menu/Menu';
import MenuItem from '@mui/material/MenuItem/MenuItem';
import { useNavigate } from 'react-router-dom';
import { useGithubAuth } from '../hooks/useGithubAuth';
-import { useLocalSession } from '../../../utils/localStorage';
import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
import { REDIRECT_URI } from '../../../constants';
@@ -18,7 +17,6 @@ const StyledWrapper = styled.div`
`;
function UserButton() {
- const { clearSessionId, sessionId } = useLocalSession();
const navigate = useNavigate();
const [user, logout] = useGithubAuth();
const [anchorEl, setAnchorEl] = React.useState(null);
@@ -43,12 +41,11 @@ function UserButton() {
);
const handleLogout = useCallback(() => {
- clearSessionId();
logout();
handleNavigate('/');
- }, [handleNavigate, logout, clearSessionId]);
+ }, [handleNavigate, logout]);
- if (user && sessionId) {
+ if (!!user) {
return (
}>
@@ -65,10 +62,10 @@ function UserButton() {
keepMounted
open={Boolean(anchorEl)}
onClose={handleClose}>
-