mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
feat(frontend): manage API tokens in Frontend
This commit is contained in:
parent
d0ca2b967e
commit
ac0f9ff5d3
13 changed files with 484 additions and 33 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Route, Routes } from "react-router-dom";
|
import { Route, Routes } from "react-router-dom";
|
||||||
|
import { AuthApiTokens } from "./components/AuthApiTokens";
|
||||||
import { Footer } from "./components/Footer";
|
import { Footer } from "./components/Footer";
|
||||||
import { LoginFailure } from "./components/LoginFailure";
|
import { LoginFailure } from "./components/LoginFailure";
|
||||||
import { LoginLoading } from "./components/LoginLoading";
|
import { LoginLoading } from "./components/LoginLoading";
|
||||||
|
|
@ -36,6 +37,7 @@ export function App() {
|
||||||
<Route path="/reports/top-albums" element={<ReportTopAlbums />} />
|
<Route path="/reports/top-albums" element={<ReportTopAlbums />} />
|
||||||
<Route path="/reports/top-tracks" element={<ReportTopTracks />} />
|
<Route path="/reports/top-tracks" element={<ReportTopTracks />} />
|
||||||
<Route path="/reports/top-genres" element={<ReportTopGenres />} />
|
<Route path="/reports/top-genres" element={<ReportTopGenres />} />
|
||||||
|
<Route path="/auth/api-tokens" element={<AuthApiTokens />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { AxiosInstance } from "axios";
|
import { AxiosInstance } from "axios";
|
||||||
import { formatISO, parseISO } from "date-fns";
|
import { formatISO, parseISO } from "date-fns";
|
||||||
|
import { ApiToken, NewApiToken } from "./entities/api-token";
|
||||||
import { Listen } from "./entities/listen";
|
import { Listen } from "./entities/listen";
|
||||||
import { ListenReportItem } from "./entities/listen-report-item";
|
import { ListenReportItem } from "./entities/listen-report-item";
|
||||||
import { ListenReportOptions } from "./entities/listen-report-options";
|
import { ListenReportOptions } from "./entities/listen-report-options";
|
||||||
|
|
@ -227,3 +228,65 @@ export const getTopGenres = async (
|
||||||
} = res;
|
} = res;
|
||||||
return items;
|
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
frontend/src/api/entities/api-token.ts
Normal file
14
frontend/src/api/entities/api-token.ts
Normal 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;
|
||||||
|
}
|
||||||
197
frontend/src/components/AuthApiTokens.tsx
Normal file
197
frontend/src/components/AuthApiTokens.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import React from "react";
|
import React, { useCallback, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { User } from "../api/entities/user";
|
import { User } from "../api/entities/user";
|
||||||
import { useAuth } from "../hooks/use-auth";
|
import { useAuth } from "../hooks/use-auth";
|
||||||
|
import { useOutsideClick } from "../hooks/useOutsideClick";
|
||||||
|
import { CogwheelIcon } from "../icons/Cogwheel";
|
||||||
import { SpotifyLogo } from "../icons/Spotify";
|
import { SpotifyLogo } from "../icons/Spotify";
|
||||||
|
|
||||||
export const NavBar: React.FC = () => {
|
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 }) => {
|
const NavItem: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
return (
|
return (
|
||||||
<span className="block mt-4 lg:inline-block lg:mt-0 text-green-200 hover:text-white mr-4">
|
<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>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
import { useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
|
createApiToken,
|
||||||
|
getApiTokens,
|
||||||
getListensReport,
|
getListensReport,
|
||||||
getRecentListens,
|
getRecentListens,
|
||||||
getTopAlbums,
|
getTopAlbums,
|
||||||
getTopArtists,
|
getTopArtists,
|
||||||
getTopGenres,
|
getTopGenres,
|
||||||
getTopTracks,
|
getTopTracks,
|
||||||
|
revokeApiToken,
|
||||||
} from "../api/api";
|
} from "../api/api";
|
||||||
import { ListenReportOptions } from "../api/entities/listen-report-options";
|
import { ListenReportOptions } from "../api/entities/listen-report-options";
|
||||||
import { PaginationOptions } from "../api/entities/pagination-options";
|
import { PaginationOptions } from "../api/entities/pagination-options";
|
||||||
|
|
@ -124,3 +127,38 @@ export const useTopGenres = (options: TopGenresOptions) => {
|
||||||
|
|
||||||
return { topGenres, isLoading, error };
|
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 };
|
||||||
|
};
|
||||||
|
|
|
||||||
26
frontend/src/hooks/useOutsideClick.tsx
Normal file
26
frontend/src/hooks/useOutsideClick.tsx
Normal 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]);
|
||||||
|
};
|
||||||
21
frontend/src/icons/Cogwheel.tsx
Normal file
21
frontend/src/icons/Cogwheel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
23
frontend/src/icons/Trashcan.tsx
Normal file
23
frontend/src/icons/Trashcan.tsx
Normal 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;
|
||||||
|
|
@ -3,6 +3,7 @@ import {
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Res,
|
Res,
|
||||||
UseFilters,
|
UseFilters,
|
||||||
|
|
@ -11,13 +12,14 @@ import {
|
||||||
import { ApiBody, ApiTags } from "@nestjs/swagger";
|
import { ApiBody, ApiTags } from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import { User } from "../users/user.entity";
|
import { User } from "../users/user.entity";
|
||||||
import { ApiToken } from "./api-token.entity";
|
|
||||||
import { AuthSession } from "./auth-session.entity";
|
import { AuthSession } from "./auth-session.entity";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { COOKIE_REFRESH_TOKEN } from "./constants";
|
import { COOKIE_REFRESH_TOKEN } from "./constants";
|
||||||
import { AuthAccessToken } from "./decorators/auth-access-token.decorator";
|
import { AuthAccessToken } from "./decorators/auth-access-token.decorator";
|
||||||
import { ReqUser } from "./decorators/req-user.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 { 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 { RefreshAccessTokenResponseDto } from "./dto/refresh-access-token-response.dto";
|
||||||
import { RevokeApiTokenRequestDto } from "./dto/revoke-api-token-request.dto";
|
import { RevokeApiTokenRequestDto } from "./dto/revoke-api-token-request.dto";
|
||||||
import {
|
import {
|
||||||
|
|
@ -68,23 +70,38 @@ export class AuthController {
|
||||||
async createApiToken(
|
async createApiToken(
|
||||||
@ReqUser() user: User,
|
@ReqUser() user: User,
|
||||||
@Body("description") description: string
|
@Body("description") description: string
|
||||||
): Promise<ApiToken> {
|
): Promise<NewApiTokenDto> {
|
||||||
return this.authService.createApiToken(user, description);
|
const apiToken = await this.authService.createApiToken(user, description);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: apiToken.id,
|
||||||
|
description: apiToken.description,
|
||||||
|
token: apiToken.token,
|
||||||
|
createdAt: apiToken.createdAt,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("api-tokens")
|
@Get("api-tokens")
|
||||||
@AuthAccessToken()
|
@AuthAccessToken()
|
||||||
async listApiTokens(@ReqUser() user: User): Promise<ApiToken[]> {
|
async listApiTokens(@ReqUser() user: User): Promise<ApiTokenDto[]> {
|
||||||
return this.authService.listApiTokens(user);
|
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.
|
@Delete("api-tokens/:id")
|
||||||
// Once the token is known, it does not matter which account makes the actual
|
|
||||||
// request to revoke it.
|
|
||||||
@Delete("api-tokens")
|
|
||||||
@ApiBody({ type: RevokeApiTokenRequestDto })
|
@ApiBody({ type: RevokeApiTokenRequestDto })
|
||||||
@AuthAccessToken()
|
@AuthAccessToken()
|
||||||
async revokeApiToken(@Body("token") token: string): Promise<void> {
|
async revokeApiToken(
|
||||||
return this.authService.revokeApiToken(token);
|
@ReqUser() user: User,
|
||||||
|
@Param("id") id: string
|
||||||
|
): Promise<void> {
|
||||||
|
return this.authService.revokeApiToken(user, id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { ForbiddenException, Injectable } from "@nestjs/common";
|
import { ForbiddenException, Injectable } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { JwtService } from "@nestjs/jwt";
|
import { JwtService } from "@nestjs/jwt";
|
||||||
|
import { randomBytes } from "crypto";
|
||||||
import { User } from "../users/user.entity";
|
import { User } from "../users/user.entity";
|
||||||
import { UsersService } from "../users/users.service";
|
import { UsersService } from "../users/users.service";
|
||||||
import { ApiToken } from "./api-token.entity";
|
import { ApiToken } from "./api-token.entity";
|
||||||
|
|
@ -8,7 +9,6 @@ import { ApiTokenRepository } from "./api-token.repository";
|
||||||
import { AuthSession } from "./auth-session.entity";
|
import { AuthSession } from "./auth-session.entity";
|
||||||
import { AuthSessionRepository } from "./auth-session.repository";
|
import { AuthSessionRepository } from "./auth-session.repository";
|
||||||
import { LoginDto } from "./dto/login.dto";
|
import { LoginDto } from "./dto/login.dto";
|
||||||
import { randomBytes } from "crypto";
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
|
|
@ -129,11 +129,13 @@ export class AuthService {
|
||||||
return this.apiTokenRepository.scoped.byUser(user).getMany();
|
return this.apiTokenRepository.scoped.byUser(user).getMany();
|
||||||
}
|
}
|
||||||
|
|
||||||
async revokeApiToken(token: string): Promise<void> {
|
async revokeApiToken(user: User, id: string): Promise<void> {
|
||||||
const apiToken = await this.findApiToken(token);
|
const apiToken = await this.apiTokenRepository.findOneBy({ user, id });
|
||||||
|
|
||||||
apiToken.revokedAt = new Date();
|
if (apiToken && apiToken.revokedAt == null) {
|
||||||
await this.apiTokenRepository.save(apiToken);
|
apiToken.revokedAt = new Date();
|
||||||
|
await this.apiTokenRepository.save(apiToken);
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
src/auth/dto/api-token.dto.ts
Normal file
7
src/auth/dto/api-token.dto.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
export interface ApiTokenDto {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
prefix: string;
|
||||||
|
createdAt: Date;
|
||||||
|
revokedAt: Date | null;
|
||||||
|
}
|
||||||
6
src/auth/dto/new-api-token.dto.ts
Normal file
6
src/auth/dto/new-api-token.dto.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
export interface NewApiTokenDto {
|
||||||
|
id: string;
|
||||||
|
description: string;
|
||||||
|
token: string;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue