feat(frontend): manage API tokens in Frontend

This commit is contained in:
Julian Tölle 2023-02-20 23:50:57 +01:00
parent d0ca2b967e
commit ac0f9ff5d3
13 changed files with 484 additions and 33 deletions

View file

@ -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";
@ -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>

View file

@ -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";
@ -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}`);
}
}
};

View file

@ -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;
}

View file

@ -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>
);
};

View file

@ -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 = () => {
@ -56,21 +58,6 @@ export const NavBar: React.FC = () => {
);
};
const NavUserInfo: React.FC<{ user: User }> = ({ user }) => {
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>
);
};
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">
@ -78,3 +65,51 @@ const NavItem: React.FC<{ children: React.ReactNode }> = ({ 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 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 NavUserInfoMenu: React.FC<{ closeMenu: () => void }> = ({
closeMenu,
}) => {
return (
<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>
);
};

View file

@ -1,11 +1,14 @@
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import {
createApiToken,
getApiTokens,
getListensReport,
getRecentListens,
getTopAlbums,
getTopArtists,
getTopGenres,
getTopTracks,
revokeApiToken,
} from "../api/api";
import { ListenReportOptions } from "../api/entities/listen-report-options";
import { PaginationOptions } from "../api/entities/pagination-options";
@ -124,3 +127,38 @@ export const useTopGenres = (options: TopGenresOptions) => {
return { topGenres, isLoading, error };
};
export const useApiTokens = () => {
const { client } = useApiClient();
const fetchData = useMemo(() => () => getApiTokens(client), [client]);
const {
value: apiTokens,
pending: isLoading,
error,
reload,
} = useAsync(fetchData, INITIAL_EMPTY_ARRAY);
const createToken = useCallback(
async (description: string) => {
const apiToken = await createApiToken(description, client);
console.log("apiToken created", apiToken);
await reload();
console.log("reloaded data");
return apiToken;
},
[client, reload]
);
const revokeToken = useCallback(
async (id: string) => {
await revokeApiToken(id, client);
await reload();
},
[client, reload]
);
return { apiTokens, isLoading, error, createToken, revokeToken };
};

View file

@ -0,0 +1,26 @@
import React, { useEffect } from "react";
/**
* Hook that alerts clicks outside of the passed ref
*/
export const useOutsideClick = (
ref: React.MutableRefObject<any>,
callback: () => void
) => {
useEffect(() => {
/**
* Alert if clicked on outside of element
*/
const handleClickOutside = (event: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target)) {
callback();
}
};
// Bind the event listener
document.addEventListener("mousedown", handleClickOutside);
return () => {
// Unbind the event listener on clean up
document.removeEventListener("mousedown", handleClickOutside);
};
}, [ref, callback]);
};

View file

@ -0,0 +1,21 @@
import React from "react";
export const CogwheelIcon: React.FC<React.SVGProps<SVGSVGElement>> = (
props
) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
x="0"
y="0"
viewBox="0 0 512 512"
fill="#fff"
{...props}
>
<g fill="fill">
<path d="M301.9 501h-91.7c-11.3 0-20.4-9.1-20.4-20.4v-34.7c-7.2-2.5-14.2-5.4-21.1-8.8l-24.6 24.6c-8 8-20.9 8-28.9 0l-64.8-64.8c-8-8-8-20.9 0-28.9L75 343.4c-3.3-6.9-6.3-13.9-8.8-21.1H31.4c-11.3 0-20.4-9.1-20.4-20.4v-91.7c0-11.3 9.1-20.4 20.4-20.4h34.7c2.5-7.2 5.4-14.2 8.8-21.1L50.4 144c-8-8-8-20.9 0-28.9l64.8-64.8c8-8 20.9-8 28.9 0l24.6 24.6c6.9-3.3 13.9-6.3 21.1-8.8V31.4c0-11.3 9.1-20.4 20.4-20.4h91.7c11.3 0 20.4 9.1 20.4 20.4v34.7c7.2 2.5 14.2 5.4 21.1 8.8L368 50.4c8-8 20.9-8 28.9 0l64.8 64.8c3.8 3.8 6 9 6 14.4s-2.2 10.6-6 14.4l-24.6 24.6c3.3 6.9 6.3 13.9 8.8 21.1h34.7c11.3 0 20.4 9.1 20.4 20.4v91.7c0 11.3-9.1 20.4-20.4 20.4h-34.7c-2.5 7.2-5.4 14.2-8.8 21.1l24.6 24.6c3.8 3.8 6 9 6 14.4s-2.2 10.6-6 14.4l-64.8 64.8c-8 8-20.9 8-28.9 0l-24.6-24.6c-6.9 3.3-13.9 6.3-21.1 8.8v34.7c0 11.5-9.2 20.6-20.4 20.6zm-71.3-40.8h50.8v-29.4c0-9.3 6.3-17.4 15.3-19.8 14-3.7 27.5-9.3 40.1-16.7 8-4.7 18.2-3.4 24.7 3.2l20.8 20.8 35.9-35.9-20.8-20.8c-6.6-6.6-7.9-16.7-3.2-24.8 7.4-12.6 13-26.1 16.7-40.1 2.3-9 10.5-15.2 19.7-15.2H460v-50.8h-29.4c-9.3 0-17.4-6.3-19.7-15.2-3.7-14-9.3-27.5-16.7-40.1-4.7-8-3.4-18.2 3.2-24.7l20.8-20.8L382.3 94l-20.8 20.8c-6.6 6.6-16.7 7.9-24.7 3.2-12.6-7.4-26.1-13-40.1-16.7-9-2.4-15.3-10.5-15.3-19.8V51.8h-50.8v29.4c0 9.3-6.3 17.4-15.3 19.8-14 3.7-27.5 9.3-40.1 16.7-8 4.7-18.2 3.4-24.7-3.2l-20.8-20.8-35.9 35.9 20.8 20.8c6.6 6.6 7.9 16.7 3.2 24.7-7.4 12.6-13 26.1-16.7 40.1-2.4 9-10.5 15.3-19.8 15.3H51.9v50.8h29.4c9.3 0 17.4 6.3 19.8 15.3 3.7 14 9.3 27.5 16.7 40.1 4.7 8 3.4 18.2-3.2 24.7l-20.8 20.8 35.9 35.9 20.8-20.8c6.6-6.6 16.8-7.9 24.7-3.2 12.6 7.4 26.1 13 40.1 16.7 9 2.4 15.3 10.5 15.3 19.8v29.6z"></path>
<path d="M256 376.2c-66.3 0-120.2-53.9-120.2-120.2S189.7 135.8 256 135.8 376.3 189.7 376.3 256s-54 120.2-120.3 120.2zm0-199.6c-43.8 0-79.4 35.6-79.4 79.4s35.6 79.4 79.4 79.4c43.8 0 79.4-35.6 79.4-79.4s-35.6-79.4-79.4-79.4z"></path>
</g>
</svg>
);
};

View file

@ -0,0 +1,23 @@
import React from "react";
export const TrashcanIcon: React.FC<React.SVGProps<SVGSVGElement>> = (
props
) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
x="0"
y="0"
viewBox="0 0 60.167 60.167"
fill="#fff"
{...props}
>
<path
fill="fill"
d="M54.5 11.667H39.88V3.91A3.914 3.914 0 0035.97 0H24.196a3.914 3.914 0 00-3.91 3.91v7.756H5.667a1 1 0 000 2h2.042v40.5c0 3.309 2.691 6 6 6h32.75c3.309 0 6-2.691 6-6v-40.5H54.5a1 1 0 000-1.999zM22.286 3.91c0-1.053.857-1.91 1.91-1.91H35.97c1.053 0 1.91.857 1.91 1.91v7.756H22.286V3.91zm28.172 50.257c0 2.206-1.794 4-4 4h-32.75c-2.206 0-4-1.794-4-4v-40.5h40.75v40.5zm-12.203-8.014V22.847a1 1 0 012 0v23.306a1 1 0 01-2 0zm-9.172 0V22.847a1 1 0 012 0v23.306a1 1 0 01-2 0zm-9.172 0V22.847a1 1 0 012 0v23.306a1 1 0 01-2 0z"
></path>
</svg>
);
};
export default TrashcanIcon;

View file

@ -3,6 +3,7 @@ import {
Controller,
Delete,
Get,
Param,
Post,
Res,
UseFilters,
@ -11,13 +12,14 @@ import {
import { ApiBody, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { User } from "../users/user.entity";
import { ApiToken } from "./api-token.entity";
import { AuthSession } from "./auth-session.entity";
import { AuthService } from "./auth.service";
import { COOKIE_REFRESH_TOKEN } from "./constants";
import { AuthAccessToken } from "./decorators/auth-access-token.decorator";
import { ReqUser } from "./decorators/req-user.decorator";
import { ApiTokenDto } from "./dto/api-token.dto";
import { CreateApiTokenRequestDto } from "./dto/create-api-token-request.dto";
import { NewApiTokenDto } from "./dto/new-api-token.dto";
import { RefreshAccessTokenResponseDto } from "./dto/refresh-access-token-response.dto";
import { RevokeApiTokenRequestDto } from "./dto/revoke-api-token-request.dto";
import {
@ -68,23 +70,38 @@ export class AuthController {
async createApiToken(
@ReqUser() user: User,
@Body("description") description: string
): Promise<ApiToken> {
return this.authService.createApiToken(user, description);
): Promise<NewApiTokenDto> {
const apiToken = await this.authService.createApiToken(user, description);
return {
id: apiToken.id,
description: apiToken.description,
token: apiToken.token,
createdAt: apiToken.createdAt,
};
}
@Get("api-tokens")
@AuthAccessToken()
async listApiTokens(@ReqUser() user: User): Promise<ApiToken[]> {
return this.authService.listApiTokens(user);
async listApiTokens(@ReqUser() user: User): Promise<ApiTokenDto[]> {
const apiTokens = await this.authService.listApiTokens(user);
return apiTokens.map((apiToken) => ({
id: apiToken.id,
description: apiToken.description,
prefix: apiToken.token.slice(0, 12),
createdAt: apiToken.createdAt,
revokedAt: apiToken.revokedAt,
}));
}
// This endpoint does not validate that the token belongs to the logged in user.
// Once the token is known, it does not matter which account makes the actual
// request to revoke it.
@Delete("api-tokens")
@Delete("api-tokens/:id")
@ApiBody({ type: RevokeApiTokenRequestDto })
@AuthAccessToken()
async revokeApiToken(@Body("token") token: string): Promise<void> {
return this.authService.revokeApiToken(token);
async revokeApiToken(
@ReqUser() user: User,
@Param("id") id: string
): Promise<void> {
return this.authService.revokeApiToken(user, id);
}
}

View file

@ -1,6 +1,7 @@
import { ForbiddenException, Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { JwtService } from "@nestjs/jwt";
import { randomBytes } from "crypto";
import { User } from "../users/user.entity";
import { UsersService } from "../users/users.service";
import { ApiToken } from "./api-token.entity";
@ -8,7 +9,6 @@ import { ApiTokenRepository } from "./api-token.repository";
import { AuthSession } from "./auth-session.entity";
import { AuthSessionRepository } from "./auth-session.repository";
import { LoginDto } from "./dto/login.dto";
import { randomBytes } from "crypto";
@Injectable()
export class AuthService {
@ -129,11 +129,13 @@ export class AuthService {
return this.apiTokenRepository.scoped.byUser(user).getMany();
}
async revokeApiToken(token: string): Promise<void> {
const apiToken = await this.findApiToken(token);
async revokeApiToken(user: User, id: string): Promise<void> {
const apiToken = await this.apiTokenRepository.findOneBy({ user, id });
apiToken.revokedAt = new Date();
await this.apiTokenRepository.save(apiToken);
if (apiToken && apiToken.revokedAt == null) {
apiToken.revokedAt = new Date();
await this.apiTokenRepository.save(apiToken);
}
return;
}

View file

@ -0,0 +1,7 @@
export interface ApiTokenDto {
id: string;
description: string;
prefix: string;
createdAt: Date;
revokedAt: Date | null;
}

View file

@ -0,0 +1,6 @@
export interface NewApiTokenDto {
id: string;
description: string;
token: string;
createdAt: Date;
}