Skip to content

Commit

Permalink
feat(frontend): manage API tokens in Frontend
Browse files Browse the repository at this point in the history
  • Loading branch information
apricote committed Feb 21, 2023
1 parent d0ca2b9 commit ac0f9ff
Show file tree
Hide file tree
Showing 13 changed files with 482 additions and 31 deletions.
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from "react";
import { Route, Routes } from "react-router-dom";
import { AuthApiTokens } from "./components/AuthApiTokens";
import { Footer } from "./components/Footer";
import { LoginFailure } from "./components/LoginFailure";
import { LoginLoading } from "./components/LoginLoading";
Expand Down Expand Up @@ -36,6 +37,7 @@ export function App() {
<Route path="/reports/top-albums" element={<ReportTopAlbums />} />
<Route path="/reports/top-tracks" element={<ReportTopTracks />} />
<Route path="/reports/top-genres" element={<ReportTopGenres />} />
<Route path="/auth/api-tokens" element={<AuthApiTokens />} />
</Routes>
</main>
<footer>
Expand Down
63 changes: 63 additions & 0 deletions frontend/src/api/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AxiosInstance } from "axios";
import { formatISO, parseISO } from "date-fns";
import { ApiToken, NewApiToken } from "./entities/api-token";
import { Listen } from "./entities/listen";
import { ListenReportItem } from "./entities/listen-report-item";
import { ListenReportOptions } from "./entities/listen-report-options";
Expand Down Expand Up @@ -227,3 +228,65 @@ export const getTopGenres = async (
} = res;
return items;
};

export const getApiTokens = async (
client: AxiosInstance
): Promise<ApiToken[]> => {
const res = await client.get<ApiToken[]>(`/api/v1/auth/api-tokens`);

switch (res.status) {
case 200: {
break;
}
case 401: {
throw new UnauthenticatedError(`No token or token expired`);
}
default: {
throw new Error(`Unable to getApiTokens: ${res.status}`);
}
}

return res.data;
};

export const createApiToken = async (
description: string,
client: AxiosInstance
): Promise<NewApiToken> => {
const res = await client.post<NewApiToken>(`/api/v1/auth/api-tokens`, {
description,
});

switch (res.status) {
case 201: {
break;
}
case 401: {
throw new UnauthenticatedError(`No token or token expired`);
}
default: {
throw new Error(`Unable to createApiToken: ${res.status}`);
}
}

return res.data;
};

export const revokeApiToken = async (
id: string,
client: AxiosInstance
): Promise<void> => {
const res = await client.delete<NewApiToken>(`/api/v1/auth/api-tokens/${id}`);

switch (res.status) {
case 200: {
break;
}
case 401: {
throw new UnauthenticatedError(`No token or token expired`);
}
default: {
throw new Error(`Unable to revokeApiToken: ${res.status}`);
}
}
};
14 changes: 14 additions & 0 deletions frontend/src/api/entities/api-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export interface ApiToken {
id: string;
description: string;
prefix: string;
createdAt: string;
revokedAt: string | null;
}

export interface NewApiToken {
id: string;
description: string;
token: string;
createdAt: string;
}
197 changes: 197 additions & 0 deletions frontend/src/components/AuthApiTokens.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
import { format, formatDistanceToNow } from "date-fns";
import React, { FormEvent, useCallback, useMemo, useState } from "react";
import { ApiToken, NewApiToken } from "../api/entities/api-token";
import { useApiTokens } from "../hooks/use-api";
import { useAuthProtection } from "../hooks/use-auth-protection";
import { SpinnerIcon } from "../icons/Spinner";
import TrashcanIcon from "../icons/Trashcan";
import { Spinner } from "./Spinner";

export const AuthApiTokens: React.FC = () => {
const { requireUser } = useAuthProtection();

const { apiTokens, isLoading, createToken, revokeToken } = useApiTokens();
const sortedTokens = useMemo(
() => apiTokens.sort((a, b) => (a.createdAt > b.createdAt ? -1 : 1)),
[apiTokens]
);

requireUser();

return (
<div className="md:flex md:justify-center p-4 text-gray-700 dark:text-gray-400">
<div className="md:shrink-0 min-w-full xl:min-w-0 xl:w-2/3 max-w-screen-lg">
<div className="flex justify-between">
<p className="text-2xl font-normal">API Tokens</p>
</div>
<div className="shadow-xl bg-gray-100 dark:bg-gray-800 rounded p-4 m-2">
<p className="mb-4">
You can use API Tokens to access the Listory API directly. You can
find the API docs{" "}
<a href="/api/docs" target="_blank">
here
</a>
.
</p>
<div className="mb-4">
<NewTokenForm createToken={createToken} />
</div>
<div>
<h3 className="text-xl">Manage Existing Tokens</h3>
{isLoading && <Spinner className="m-8" />}
{sortedTokens.length === 0 && (
<div className="text-center m-4">
<p className="">Could not find any api tokens!</p>
</div>
)}
<div>
{sortedTokens.length > 0 && (
<div className="table-auto w-full">
{sortedTokens.map((apiToken) => (
<ApiTokenItem
apiToken={apiToken}
revokeToken={revokeToken}
key={apiToken.id}
/>
))}
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
};

const NewTokenForm: React.FC<{
createToken: (description: string) => Promise<NewApiToken>;
}> = ({ createToken }) => {
const [newTokenDescription, setNewTokenDescription] = useState<string>("");
const [newToken, setNewToken] = useState<NewApiToken | null>(null);
const [isLoading, setLoading] = useState<boolean>(false);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [error, setError] = useState<Error | null>(null);

const submitForm = useCallback(
async (event: FormEvent) => {
event.preventDefault();

setLoading(true);
setError(null);

try {
const newToken = await createToken(newTokenDescription);
setNewToken(newToken);
setNewTokenDescription("");
} catch (err: any) {
setError(err);
}

setLoading(false);
},
[
setLoading,
setError,
newTokenDescription,
createToken,
setNewToken,
setNewTokenDescription,
]
);

return (
<form>
<h3 className="text-xl">Create New Token</h3>
<label htmlFor="description" className="font-bold my-2 mr-2 block">
Description
</label>
<input
name="description"
type="text"
placeholder="Used for XYZ"
value={newTokenDescription}
onChange={(event) => setNewTokenDescription(event.target.value)}
className="shadow appearance-none rounded w-1/3 mb-3 py-2 px-3 outline-none focus:ring ring-green-200 dark:ring-gray-600 bg-gray-200 dark:bg-gray-700"
/>
<button
className="hover:bg-gray-400 dark:hover:bg-gray-600 bg-gray-300 dark:bg-gray-700 font-bold py-2 px-4 rounded block"
onClick={submitForm}
disabled={isLoading}
>
{isLoading ? <Spinner /> : "Create"}
</button>
{newToken ? <NewApiTokenItem apiToken={newToken} /> : null}
</form>
);
};

const NewApiTokenItem: React.FC<{ apiToken: NewApiToken }> = ({ apiToken }) => {
const copyToken = useCallback(() => {
navigator.clipboard.writeText(apiToken.token);
}, [apiToken]);

return (
<div className="bg-gray-200 dark:bg-gray-700 rounded-md p-2 my-4 w-min shadow-md">
Your new API Token:
<pre
className="tracking-widest bg-gray-600 rounded-md p-4 my-2 cursor-pointer w-min shadow-lg text-gray-900"
onClick={copyToken}
>
{apiToken.token}
</pre>
<span>The token will only be visible once, so make sure to save it!</span>
</div>
);
};

const ApiTokenItem: React.FC<{
apiToken: ApiToken;
revokeToken: (id: string) => Promise<void>;
}> = ({ apiToken, revokeToken }) => {
const [isBeingRevoked, setIsBeingRevoked] = useState<boolean>(false);
const revokeTokenButton = useCallback(async () => {
setIsBeingRevoked(true);
await revokeToken(apiToken.id);
setIsBeingRevoked(false);
}, [setIsBeingRevoked, revokeToken, apiToken]);

const description = apiToken.description;
const prefix = apiToken.prefix;
const timeAgo = formatDistanceToNow(new Date(apiToken.createdAt), {
addSuffix: true,
});
const dateTime = format(new Date(apiToken.createdAt), "PP p");

const displayRevokeButton = apiToken.revokedAt == null && !isBeingRevoked;
const displaySpinner = isBeingRevoked;

return (
<div className="hover:bg-gray-100 dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-700/25 md:flex md:justify-around px-2 py-2">
<div className="md:w-1/2 font-bold">{description}</div>
<div className="md:w-1/3">
<span className="tracking-widest font-mono bg-gray-600 rounded-md px-2 text-gray-900">
{prefix}...
</span>
</div>
<div
className="md:w-1/6 text-gray-500 font-extra-light text-sm"
title={dateTime}
>
{timeAgo}
</div>
<div className="md:w-5 h-5 font-extra-light text-sm">
{displayRevokeButton && (
<button onClick={revokeTokenButton}>
<TrashcanIcon className="h-5 w-5 fill-current" />
</button>
)}
{displaySpinner && (
<SpinnerIcon
className={`h-5 w-5 text-gray-300 dark:text-gray-700 fill-green-500`}
/>
)}
</div>
</div>
);
};
63 changes: 49 additions & 14 deletions frontend/src/components/NavBar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import React from "react";
import React, { useCallback, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { User } from "../api/entities/user";
import { useAuth } from "../hooks/use-auth";
import { useOutsideClick } from "../hooks/useOutsideClick";
import { CogwheelIcon } from "../icons/Cogwheel";
import { SpotifyLogo } from "../icons/Spotify";

export const NavBar: React.FC = () => {
Expand Down Expand Up @@ -56,25 +58,58 @@ export const NavBar: React.FC = () => {
);
};

const NavItem: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<span className="block mt-4 lg:inline-block lg:mt-0 text-green-200 hover:text-white mr-4">
{children}
</span>
);
};

const NavUserInfo: React.FC<{ user: User }> = ({ user }) => {
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const closeMenu = useCallback(() => setMenuOpen(false), [setMenuOpen]);

const wrapperRef = useRef(null);
useOutsideClick(wrapperRef, closeMenu);

return (
<div className="flex items-center mr-4 mt-4 lg:mt-0">
<span className="text-green-200 text-sm">{user.displayName}</span>
{user.photo && (
<img
className="w-6 h-6 rounded-full ml-4"
src={user.photo}
alt="Profile of logged in user"
></img>
)}
<div ref={wrapperRef}>
<div
className="flex items-center mr-4 mt-4 lg:mt-0 cursor-pointer"
onClick={() => setMenuOpen(!menuOpen)}
>
<span className="text-green-200 text-sm">{user.displayName}</span>
{user.photo && (
<img
className="w-6 h-6 rounded-full ml-4"
src={user.photo}
alt="Profile of logged in user"
></img>
)}
</div>
{menuOpen ? <NavUserInfoMenu closeMenu={closeMenu} /> : null}
</div>
);
};

const NavItem: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const NavUserInfoMenu: React.FC<{ closeMenu: () => void }> = ({
closeMenu,
}) => {
return (
<span className="block mt-4 lg:inline-block lg:mt-0 text-green-200 hover:text-white mr-4">
{children}
</span>
<div className="relative">
<div className="drop-down w-48 overflow-hidden bg-green-100 dark:bg-gray-700 text-gray-700 dark:text-green-200 rounded-md shadow absolute top-3 right-3">
<ul>
<li className="px-3 py-3 text-sm font-medium flex items-center space-x-2 hover:bg-green-200 hover:text-gray-800 dark:hover:text-white">
<span>
<CogwheelIcon className="w-5 h-5 fill-current" />
</span>
<Link to="/auth/api-tokens" onClick={closeMenu}>
API Tokens
</Link>
</li>
</ul>
</div>
</div>
);
};

0 comments on commit ac0f9ff

Please sign in to comment.