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