mirror of
https://github.com/apricote/Listory.git
synced 2026-01-13 21:21:02 +00:00
feat: implement long-lived sessions
This commit is contained in:
parent
d0705afca8
commit
44f7e26270
35 changed files with 739 additions and 190 deletions
8
frontend/package-lock.json
generated
8
frontend/package-lock.json
generated
|
|
@ -2964,6 +2964,14 @@
|
||||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.10.1.tgz",
|
||||||
"integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA=="
|
"integrity": "sha512-zg7Hz2k5lI8kb7U32998pRRFin7zJlkfezGJjUc2heaD4Pw2wObakCDVzkKztTm/Ln7eiVvYsjqak0Ed4LkMDA=="
|
||||||
},
|
},
|
||||||
|
"axios": {
|
||||||
|
"version": "0.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz",
|
||||||
|
"integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==",
|
||||||
|
"requires": {
|
||||||
|
"follow-redirects": "^1.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"axobject-query": {
|
"axobject-query": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@
|
||||||
"@types/react-router-dom": "5.1.5",
|
"@types/react-router-dom": "5.1.5",
|
||||||
"@types/recharts": "1.8.15",
|
"@types/recharts": "1.8.15",
|
||||||
"autoprefixer": "9.8.6",
|
"autoprefixer": "9.8.6",
|
||||||
|
"axios": "^0.21.0",
|
||||||
"date-fns": "2.16.1",
|
"date-fns": "2.16.1",
|
||||||
"npm-run-all": "4.1.5",
|
"npm-run-all": "4.1.5",
|
||||||
"postcss-cli": "7.1.1",
|
"postcss-cli": "7.1.1",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
|
import { AxiosInstance } from "axios";
|
||||||
import { formatISO, parseISO } from "date-fns";
|
import { formatISO, parseISO } from "date-fns";
|
||||||
import { qs } from "../util/queryString";
|
|
||||||
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";
|
||||||
|
|
@ -7,58 +7,18 @@ import { Pagination } from "./entities/pagination";
|
||||||
import { PaginationOptions } from "./entities/pagination-options";
|
import { PaginationOptions } from "./entities/pagination-options";
|
||||||
import { TopArtistsItem } from "./entities/top-artists-item";
|
import { TopArtistsItem } from "./entities/top-artists-item";
|
||||||
import { TopArtistsOptions } from "./entities/top-artists-options";
|
import { TopArtistsOptions } from "./entities/top-artists-options";
|
||||||
import { User } from "./entities/user";
|
|
||||||
|
|
||||||
export class UnauthenticatedError extends Error {}
|
export class UnauthenticatedError extends Error {}
|
||||||
|
|
||||||
const getToken = (): string => {
|
|
||||||
const cookieValue = document.cookie.replace(
|
|
||||||
/(?:(?:^|.*;\s*)listory_access_token\s*=\s*([^;]*).*$)|^.*$/,
|
|
||||||
"$1"
|
|
||||||
);
|
|
||||||
|
|
||||||
return cookieValue;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getDefaultHeaders = (): Headers => {
|
|
||||||
const headers = new Headers();
|
|
||||||
|
|
||||||
headers.append("Content-Type", "application/json");
|
|
||||||
headers.append("Authorization", `Bearer ${getToken()}`);
|
|
||||||
|
|
||||||
return headers;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getUsersMe = async (): Promise<User> => {
|
|
||||||
const res = await fetch(`/api/v1/users/me`, { headers: getDefaultHeaders() });
|
|
||||||
|
|
||||||
switch (res.status) {
|
|
||||||
case 200: {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 401: {
|
|
||||||
throw new UnauthenticatedError(`No token or token expired`);
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
throw new Error(`Unable to getUsersMe: ${res.status}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const user: User = await res.json();
|
|
||||||
return user;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getRecentListens = async (
|
export const getRecentListens = async (
|
||||||
options: PaginationOptions = { page: 1, limit: 10 }
|
options: PaginationOptions = { page: 1, limit: 10 },
|
||||||
|
client: AxiosInstance
|
||||||
): Promise<Pagination<Listen>> => {
|
): Promise<Pagination<Listen>> => {
|
||||||
const { page, limit } = options;
|
const { page, limit } = options;
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await client.get<Pagination<Listen>>(`/api/v1/listens`, {
|
||||||
`/api/v1/listens?${qs({ page: page.toString(), limit: limit.toString() })}`,
|
params: { page, limit },
|
||||||
{
|
});
|
||||||
headers: getDefaultHeaders(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
switch (res.status) {
|
switch (res.status) {
|
||||||
case 200: {
|
case 200: {
|
||||||
|
|
@ -72,27 +32,27 @@ export const getRecentListens = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const listens: Pagination<Listen> = await res.json();
|
return res.data;
|
||||||
return listens;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getListensReport = async (
|
export const getListensReport = async (
|
||||||
options: ListenReportOptions
|
options: ListenReportOptions,
|
||||||
|
client: AxiosInstance
|
||||||
): Promise<ListenReportItem[]> => {
|
): Promise<ListenReportItem[]> => {
|
||||||
const {
|
const {
|
||||||
timeFrame,
|
timeFrame,
|
||||||
time: { timePreset, customTimeStart, customTimeEnd },
|
time: { timePreset, customTimeStart, customTimeEnd },
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await client.get<{ items: { count: number; date: string }[] }>(
|
||||||
`/api/v1/reports/listens?${qs({
|
`/api/v1/reports/listens`,
|
||||||
timeFrame,
|
|
||||||
timePreset,
|
|
||||||
customTimeStart: formatISO(customTimeStart),
|
|
||||||
customTimeEnd: formatISO(customTimeEnd),
|
|
||||||
})}`,
|
|
||||||
{
|
{
|
||||||
headers: getDefaultHeaders(),
|
params: {
|
||||||
|
timeFrame,
|
||||||
|
timePreset,
|
||||||
|
customTimeStart: formatISO(customTimeStart),
|
||||||
|
customTimeEnd: formatISO(customTimeEnd),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -108,25 +68,28 @@ export const getListensReport = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawItems: { count: number; date: string }[] = (await res.json()).items;
|
const {
|
||||||
|
data: { items: rawItems },
|
||||||
|
} = res;
|
||||||
return rawItems.map(({ count, date }) => ({ count, date: parseISO(date) }));
|
return rawItems.map(({ count, date }) => ({ count, date: parseISO(date) }));
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getTopArtists = async (
|
export const getTopArtists = async (
|
||||||
options: TopArtistsOptions
|
options: TopArtistsOptions,
|
||||||
|
client: AxiosInstance
|
||||||
): Promise<TopArtistsItem[]> => {
|
): Promise<TopArtistsItem[]> => {
|
||||||
const {
|
const {
|
||||||
time: { timePreset, customTimeStart, customTimeEnd },
|
time: { timePreset, customTimeStart, customTimeEnd },
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await client.get<{ items: TopArtistsItem[] }>(
|
||||||
`/api/v1/reports/top-artists?${qs({
|
`/api/v1/reports/top-artists`,
|
||||||
timePreset,
|
|
||||||
customTimeStart: formatISO(customTimeStart),
|
|
||||||
customTimeEnd: formatISO(customTimeEnd),
|
|
||||||
})}`,
|
|
||||||
{
|
{
|
||||||
headers: getDefaultHeaders(),
|
params: {
|
||||||
|
timePreset,
|
||||||
|
customTimeStart: formatISO(customTimeStart),
|
||||||
|
customTimeEnd: formatISO(customTimeEnd),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -142,6 +105,8 @@ export const getTopArtists = async (
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const items: TopArtistsItem[] = (await res.json()).items;
|
const {
|
||||||
|
data: { items },
|
||||||
|
} = res;
|
||||||
return items;
|
return items;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
56
frontend/src/api/auth-api.ts
Normal file
56
frontend/src/api/auth-api.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
/*
|
||||||
|
* These calls are seperate from the others because they are only
|
||||||
|
* used in the useAuth hook which is used before the useApiClient hook.
|
||||||
|
*
|
||||||
|
* They do not use the apiClient/axios.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { UnauthenticatedError } from "./api";
|
||||||
|
import { RefreshTokenResponse } from "./entities/refresh-token-response";
|
||||||
|
import { User } from "./entities/user";
|
||||||
|
|
||||||
|
export const getUsersMe = async (accessToken: string): Promise<User> => {
|
||||||
|
const res = await fetch(`/api/v1/users/me`, {
|
||||||
|
headers: new Headers({
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${accessToken}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (res.status) {
|
||||||
|
case 200: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 401: {
|
||||||
|
throw new UnauthenticatedError(`No token or token expired`);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error(`Unable to getUsersMe: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const user: User = await res.json();
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const postAuthTokenRefresh = async (): Promise<RefreshTokenResponse> => {
|
||||||
|
const res = await fetch(`/api/v1/auth/token/refresh`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: new Headers({ "Content-Type": "application/json" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (res.status) {
|
||||||
|
case 201: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 401: {
|
||||||
|
throw new UnauthenticatedError(`No Refresh Token or token expired`);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
throw new Error(`Unable to postAuthTokenRefresh: ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refreshTokenResponse: RefreshTokenResponse = await res.json();
|
||||||
|
return refreshTokenResponse;
|
||||||
|
};
|
||||||
3
frontend/src/api/entities/refresh-token-response.ts
Normal file
3
frontend/src/api/entities/refresh-token-response.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export interface RefreshTokenResponse {
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { format, formatDistanceToNow } from "date-fns";
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
import React, { useEffect, useState, useMemo } from "react";
|
import React, { useEffect, useMemo, useState } from "react";
|
||||||
import { Redirect } from "react-router-dom";
|
import { Redirect } from "react-router-dom";
|
||||||
import { getRecentListens } from "../api/api";
|
|
||||||
import { Listen } from "../api/entities/listen";
|
import { Listen } from "../api/entities/listen";
|
||||||
|
import { useRecentListens } from "../hooks/use-api";
|
||||||
import { useAuth } from "../hooks/use-auth";
|
import { useAuth } from "../hooks/use-auth";
|
||||||
import { ReloadIcon } from "../icons/Reload";
|
import { ReloadIcon } from "../icons/Reload";
|
||||||
import { getPaginationItems } from "../util/getPaginationItems";
|
import { getPaginationItems } from "../util/getPaginationItems";
|
||||||
|
|
@ -14,35 +14,18 @@ export const RecentListens: React.FC = () => {
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [listens, setListens] = useState<Listen[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
|
|
||||||
const loadListensForPage = useMemo(
|
const options = useMemo(() => ({ page, limit: LISTENS_PER_PAGE }), [page]);
|
||||||
() => async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
const { recentListens, paginationMeta, isLoading, reload } = useRecentListens(
|
||||||
const listensFromApi = await getRecentListens({
|
options
|
||||||
page,
|
|
||||||
limit: LISTENS_PER_PAGE,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (totalPages !== listensFromApi.meta.totalPages) {
|
|
||||||
setTotalPages(listensFromApi.meta.totalPages);
|
|
||||||
}
|
|
||||||
setListens(listensFromApi.items);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Error while fetching recent listens:", err);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[page, totalPages, setIsLoading, setTotalPages, setListens]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadListensForPage();
|
if (paginationMeta && totalPages !== paginationMeta.totalPages) {
|
||||||
}, [loadListensForPage]);
|
setTotalPages(paginationMeta.totalPages);
|
||||||
|
}
|
||||||
|
}, [totalPages, paginationMeta]);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Redirect to="/" />;
|
return <Redirect to="/" />;
|
||||||
|
|
@ -55,7 +38,7 @@ export const RecentListens: React.FC = () => {
|
||||||
<p className="text-2xl font-normal text-gray-700">Recent listens</p>
|
<p className="text-2xl font-normal text-gray-700">Recent listens</p>
|
||||||
<button
|
<button
|
||||||
className="flex-shrink-0 mx-2 bg-transparent hover:bg-green-500 text-green-500 hover:text-white font-semibold py-2 px-4 border border-green-500 hover:border-transparent rounded"
|
className="flex-shrink-0 mx-2 bg-transparent hover:bg-green-500 text-green-500 hover:text-white font-semibold py-2 px-4 border border-green-500 hover:border-transparent rounded"
|
||||||
onClick={loadListensForPage}
|
onClick={reload}
|
||||||
>
|
>
|
||||||
<ReloadIcon className="w-5 h-5 fill-current" />
|
<ReloadIcon className="w-5 h-5 fill-current" />
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -66,14 +49,14 @@ export const RecentListens: React.FC = () => {
|
||||||
<span>Loading Listens</span>
|
<span>Loading Listens</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{listens.length === 0 && (
|
{recentListens.length === 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p>Could not find any listens!</p>
|
<p>Could not find any listens!</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{listens.length > 0 && (
|
{recentListens.length > 0 && (
|
||||||
<div className="table-auto my-2 w-full text-gray-700">
|
<div className="table-auto my-2 w-full text-gray-700">
|
||||||
{listens.map((listen) => (
|
{recentListens.map((listen) => (
|
||||||
<ListenItem listen={listen} key={listen.id} />
|
<ListenItem listen={listen} key={listen.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,11 @@ import {
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { getListensReport } from "../api/api";
|
|
||||||
import { ListenReportItem } from "../api/entities/listen-report-item";
|
import { ListenReportItem } from "../api/entities/listen-report-item";
|
||||||
import { ListenReportOptions } from "../api/entities/listen-report-options";
|
import { ListenReportOptions } from "../api/entities/listen-report-options";
|
||||||
import { TimeOptions } from "../api/entities/time-options";
|
import { TimeOptions } from "../api/entities/time-options";
|
||||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||||
import { useAsync } from "../hooks/use-async";
|
import { useListensReport } from "../hooks/use-api";
|
||||||
import { useAuth } from "../hooks/use-auth";
|
import { useAuth } from "../hooks/use-auth";
|
||||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||||
|
|
||||||
|
|
@ -33,12 +32,12 @@ export const ReportListens: React.FC = () => {
|
||||||
customTimeEnd: new Date(),
|
customTimeEnd: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchData = useMemo(
|
const reportOptions = useMemo(() => ({ timeFrame, time: timeOptions }), [
|
||||||
() => () => getListensReport({ timeFrame, time: timeOptions }),
|
timeFrame,
|
||||||
[timeFrame, timeOptions]
|
timeOptions,
|
||||||
);
|
]);
|
||||||
|
|
||||||
const { value: report, pending: isLoading } = useAsync(fetchData, []);
|
const { report, isLoading } = useListensReport(reportOptions);
|
||||||
|
|
||||||
const reportHasItems = !isLoading && report.length !== 0;
|
const reportHasItems = !isLoading && report.length !== 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
import React, { useMemo, useState } from "react";
|
import React, { useMemo, useState } from "react";
|
||||||
import { Redirect } from "react-router-dom";
|
import { Redirect } from "react-router-dom";
|
||||||
import { getTopArtists } from "../api/api";
|
|
||||||
import { TimeOptions } from "../api/entities/time-options";
|
import { TimeOptions } from "../api/entities/time-options";
|
||||||
import { TimePreset } from "../api/entities/time-preset.enum";
|
import { TimePreset } from "../api/entities/time-preset.enum";
|
||||||
import { TopArtistsItem } from "../api/entities/top-artists-item";
|
import { useTopArtists } from "../hooks/use-api";
|
||||||
import { useAsync } from "../hooks/use-async";
|
|
||||||
import { useAuth } from "../hooks/use-auth";
|
import { useAuth } from "../hooks/use-auth";
|
||||||
import { ReportTimeOptions } from "./ReportTimeOptions";
|
import { ReportTimeOptions } from "./ReportTimeOptions";
|
||||||
|
|
||||||
const INITIAL_REPORT_DATA: TopArtistsItem[] = [];
|
|
||||||
|
|
||||||
export const ReportTopArtists: React.FC = () => {
|
export const ReportTopArtists: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
|
@ -19,16 +15,16 @@ export const ReportTopArtists: React.FC = () => {
|
||||||
customTimeEnd: new Date(),
|
customTimeEnd: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchData = useMemo(() => () => getTopArtists({ time: timeOptions }), [
|
const options = useMemo(
|
||||||
timeOptions,
|
() => ({
|
||||||
]);
|
time: timeOptions,
|
||||||
|
}),
|
||||||
const { value: report, pending: isLoading } = useAsync(
|
[timeOptions]
|
||||||
fetchData,
|
|
||||||
INITIAL_REPORT_DATA
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const reportHasItems = !isLoading && report.length !== 0;
|
const { topArtists, isLoading } = useTopArtists(options);
|
||||||
|
|
||||||
|
const reportHasItems = !isLoading && topArtists.length !== 0;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return <Redirect to="/" />;
|
return <Redirect to="/" />;
|
||||||
|
|
@ -56,7 +52,7 @@ export const ReportTopArtists: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{reportHasItems &&
|
{reportHasItems &&
|
||||||
report.map(({ artist, count }) => (
|
topArtists.map(({ artist, count }) => (
|
||||||
<div key={artist.id}>
|
<div key={artist.id}>
|
||||||
{count} - {artist.name}
|
{count} - {artist.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
95
frontend/src/hooks/use-api-client.tsx
Normal file
95
frontend/src/hooks/use-api-client.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios";
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { useAuth } from "./use-auth";
|
||||||
|
|
||||||
|
interface ApiClientContext {
|
||||||
|
client: AxiosInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiClientContext = createContext<ApiClientContext>(
|
||||||
|
(undefined as any) as ApiClientContext
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ProvideApiClient: React.FC = ({ children }) => {
|
||||||
|
const auth = useProvideApiClient();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<apiClientContext.Provider value={auth}>
|
||||||
|
{children}
|
||||||
|
</apiClientContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useApiClient() {
|
||||||
|
return useContext(apiClientContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useProvideApiClient(): ApiClientContext {
|
||||||
|
const { accessToken, refreshAccessToken } = useAuth();
|
||||||
|
|
||||||
|
// Wrap value to immediatly update when refreshing access token
|
||||||
|
// and always having access to newest access token in interceptor
|
||||||
|
const localAccessToken = useRef(accessToken);
|
||||||
|
|
||||||
|
// initialState must be passed as function as return value of axios.create()
|
||||||
|
// is also callable and react will call it and then use that result (promise)
|
||||||
|
// as the initial state instead of the axios instace.
|
||||||
|
const [client] = useState<AxiosInstance>(() => axios.create());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localAccessToken.current = accessToken;
|
||||||
|
}, [localAccessToken, accessToken]);
|
||||||
|
|
||||||
|
// TODO Implement lock to avoid multiple parallel refreshes, maybe in useAuth?
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Setup Axios Interceptors
|
||||||
|
const requestInterceptor = client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
if (!config.headers) {
|
||||||
|
config.headers = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
config.headers.Authorization = `Bearer ${localAccessToken.current}`;
|
||||||
|
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(err) => Promise.reject(err)
|
||||||
|
);
|
||||||
|
const responseInterceptor = client.interceptors.response.use(
|
||||||
|
(data) => data,
|
||||||
|
async (err: any) => {
|
||||||
|
if (!err.response || !err.config) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { response, config } = err as {
|
||||||
|
response: AxiosResponse;
|
||||||
|
config: AxiosRequestConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (response && response.status !== 401) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO error handling
|
||||||
|
localAccessToken.current = await refreshAccessToken();
|
||||||
|
|
||||||
|
return client.request(config);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
client.interceptors.request.eject(requestInterceptor);
|
||||||
|
client.interceptors.response.eject(responseInterceptor);
|
||||||
|
};
|
||||||
|
}, [client, localAccessToken, refreshAccessToken]);
|
||||||
|
|
||||||
|
return { client };
|
||||||
|
}
|
||||||
65
frontend/src/hooks/use-api.tsx
Normal file
65
frontend/src/hooks/use-api.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { getListensReport, getRecentListens, getTopArtists } from "../api/api";
|
||||||
|
import { ListenReportOptions } from "../api/entities/listen-report-options";
|
||||||
|
import { PaginationOptions } from "../api/entities/pagination-options";
|
||||||
|
import { TopArtistsOptions } from "../api/entities/top-artists-options";
|
||||||
|
import { useApiClient } from "./use-api-client";
|
||||||
|
import { useAsync } from "./use-async";
|
||||||
|
|
||||||
|
const INITIAL_EMPTY_ARRAY: [] = [];
|
||||||
|
Object.freeze(INITIAL_EMPTY_ARRAY);
|
||||||
|
|
||||||
|
export const useRecentListens = (options: PaginationOptions) => {
|
||||||
|
const { client } = useApiClient();
|
||||||
|
|
||||||
|
const fetchData = useMemo(() => () => getRecentListens(options, client), [
|
||||||
|
options,
|
||||||
|
client,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { value, pending: isLoading, error, reload } = useAsync(
|
||||||
|
fetchData,
|
||||||
|
undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const recentListens = value ? value.items : [];
|
||||||
|
const paginationMeta = value ? value.meta : undefined;
|
||||||
|
|
||||||
|
return { recentListens, paginationMeta, isLoading, error, reload };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useListensReport = (options: ListenReportOptions) => {
|
||||||
|
const { client } = useApiClient();
|
||||||
|
|
||||||
|
const [initialData] = useState(INITIAL_EMPTY_ARRAY);
|
||||||
|
|
||||||
|
const fetchData = useMemo(() => () => getListensReport(options, client), [
|
||||||
|
options,
|
||||||
|
client,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { value: report, pending: isLoading, error } = useAsync(
|
||||||
|
fetchData,
|
||||||
|
initialData
|
||||||
|
);
|
||||||
|
|
||||||
|
return { report, isLoading, error };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTopArtists = (options: TopArtistsOptions) => {
|
||||||
|
const { client } = useApiClient();
|
||||||
|
|
||||||
|
const [initialData] = useState(INITIAL_EMPTY_ARRAY);
|
||||||
|
|
||||||
|
const fetchData = useMemo(() => () => getTopArtists(options, client), [
|
||||||
|
options,
|
||||||
|
client,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { value: topArtists, pending: isLoading, error } = useAsync(
|
||||||
|
fetchData,
|
||||||
|
initialData
|
||||||
|
);
|
||||||
|
|
||||||
|
return { topArtists, isLoading, error };
|
||||||
|
};
|
||||||
|
|
@ -3,7 +3,12 @@ import { useCallback, useEffect, useState } from "react";
|
||||||
type UseAsync = <T>(
|
type UseAsync = <T>(
|
||||||
asyncFunction: () => Promise<T>,
|
asyncFunction: () => Promise<T>,
|
||||||
initialValue: T
|
initialValue: T
|
||||||
) => { pending: boolean; value: T; error: Error | null };
|
) => {
|
||||||
|
pending: boolean;
|
||||||
|
value: T;
|
||||||
|
error: Error | null;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
export const useAsync: UseAsync = <T extends any>(
|
export const useAsync: UseAsync = <T extends any>(
|
||||||
asyncFunction: () => Promise<T>,
|
asyncFunction: () => Promise<T>,
|
||||||
|
|
@ -34,5 +39,5 @@ export const useAsync: UseAsync = <T extends any>(
|
||||||
execute();
|
execute();
|
||||||
}, [execute]);
|
}, [execute]);
|
||||||
|
|
||||||
return { execute, pending, value, error };
|
return { reload: execute, pending, value, error };
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,26 @@
|
||||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
import React, {
|
||||||
import { getUsersMe, UnauthenticatedError } from "../api/api";
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { getUsersMe, postAuthTokenRefresh } from "../api/auth-api";
|
||||||
import { User } from "../api/entities/user";
|
import { User } from "../api/entities/user";
|
||||||
|
|
||||||
|
interface AuthContext {
|
||||||
|
isLoaded: boolean;
|
||||||
|
user: User | null;
|
||||||
|
accessToken: string;
|
||||||
|
error: Error | null;
|
||||||
|
refreshAccessToken: () => Promise<string>;
|
||||||
|
loginWithSpotifyProps: () => { href: string };
|
||||||
|
}
|
||||||
|
|
||||||
const authContext = createContext<AuthContext>(
|
const authContext = createContext<AuthContext>(
|
||||||
(undefined as any) as AuthContext
|
(undefined as any) as AuthContext
|
||||||
);
|
);
|
||||||
|
|
||||||
// Provider component that wraps your app and makes auth object ...
|
|
||||||
// ... available to any child component that calls useAuth().
|
|
||||||
|
|
||||||
interface AuthContext {
|
|
||||||
user: { id: string; displayName: string } | null;
|
|
||||||
isLoaded: boolean;
|
|
||||||
loginWithSpotifyProps: () => { href: string };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProvideAuth: React.FC = ({ children }) => {
|
export const ProvideAuth: React.FC = ({ children }) => {
|
||||||
const auth = useProvideAuth();
|
const auth = useProvideAuth();
|
||||||
|
|
||||||
|
|
@ -28,25 +34,51 @@ export function useAuth() {
|
||||||
function useProvideAuth(): AuthContext {
|
function useProvideAuth(): AuthContext {
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [accessToken, setAccessToken] = useState<string>("");
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
const loginWithSpotifyProps = () => ({ href: "/api/v1/auth/spotify" });
|
const loginWithSpotifyProps = () => ({ href: "/api/v1/auth/spotify" });
|
||||||
|
|
||||||
useEffect(() => {
|
const refreshAccessToken = useCallback(async () => {
|
||||||
(async () => {
|
try {
|
||||||
try {
|
const { accessToken: newAccessToken } = await postAuthTokenRefresh();
|
||||||
const currentUser = await getUsersMe();
|
setAccessToken(newAccessToken);
|
||||||
setUser(currentUser);
|
return newAccessToken;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UnauthenticatedError) {
|
setAccessToken("");
|
||||||
// User is not logged in
|
setUser(null);
|
||||||
} else {
|
setIsLoaded(true);
|
||||||
console.error("Error while checking login state:", err);
|
setError(err);
|
||||||
}
|
|
||||||
} finally {
|
throw err;
|
||||||
setIsLoaded(true);
|
}
|
||||||
}
|
|
||||||
})();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { isLoaded, user, loginWithSpotifyProps };
|
useEffect(() => {
|
||||||
|
refreshAccessToken().catch(() => {
|
||||||
|
console.log("Unable to refresh access token");
|
||||||
|
});
|
||||||
|
}, [refreshAccessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
async function getUser(token: string) {
|
||||||
|
const newUser = await getUsersMe(token);
|
||||||
|
setUser(newUser);
|
||||||
|
setIsLoaded(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
getUser(accessToken);
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoaded,
|
||||||
|
user,
|
||||||
|
accessToken,
|
||||||
|
error,
|
||||||
|
refreshAccessToken,
|
||||||
|
loginWithSpotifyProps,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,18 @@ import React from "react";
|
||||||
import ReactDOM from "react-dom";
|
import ReactDOM from "react-dom";
|
||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import { App } from "./App";
|
import { App } from "./App";
|
||||||
|
import { ProvideApiClient } from "./hooks/use-api-client";
|
||||||
import { ProvideAuth } from "./hooks/use-auth";
|
import { ProvideAuth } from "./hooks/use-auth";
|
||||||
import * as serviceWorker from "./serviceWorker";
|
import * as serviceWorker from "./serviceWorker";
|
||||||
|
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ProvideAuth>
|
<ProvideAuth>
|
||||||
<BrowserRouter>
|
<ProvideApiClient>
|
||||||
<App />
|
<BrowserRouter>
|
||||||
</BrowserRouter>
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</ProvideApiClient>
|
||||||
</ProvideAuth>
|
</ProvideAuth>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
document.getElementById("root")
|
document.getElementById("root")
|
||||||
|
|
|
||||||
18
package-lock.json
generated
18
package-lock.json
generated
|
|
@ -1820,6 +1820,15 @@
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@types/cookie-parser": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-uwcY8m6SDQqciHsqcKDGbo10GdasYsPCYkH3hVegj9qAah6pX5HivOnOuI3WYmyQMnOATV39zv/Ybs0bC/6iVg==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"@types/cookiejar": {
|
"@types/cookiejar": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.1.tgz",
|
||||||
|
|
@ -3635,6 +3644,15 @@
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.0.tgz",
|
||||||
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
|
"integrity": "sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg=="
|
||||||
},
|
},
|
||||||
|
"cookie-parser": {
|
||||||
|
"version": "1.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.5.tgz",
|
||||||
|
"integrity": "sha512-f13bPUj/gG/5mDr+xLmSxxDsB9DQiTIfhJS/sqjrmfAWiAN+x2O4i/XguTL9yDZ+/IFDanJ+5x7hC4CXT9Tdzw==",
|
||||||
|
"requires": {
|
||||||
|
"cookie": "0.4.0",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"cookie-signature": {
|
"cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@
|
||||||
"@nestjs/typeorm": "7.1.0",
|
"@nestjs/typeorm": "7.1.0",
|
||||||
"class-transformer": "0.3.1",
|
"class-transformer": "0.3.1",
|
||||||
"class-validator": "0.12.2",
|
"class-validator": "0.12.2",
|
||||||
|
"cookie-parser": "^1.4.5",
|
||||||
"date-fns": "2.16.1",
|
"date-fns": "2.16.1",
|
||||||
"nestjs-typeorm-paginate": "2.1.1",
|
"nestjs-typeorm-paginate": "2.1.1",
|
||||||
"passport": "0.4.1",
|
"passport": "0.4.1",
|
||||||
|
|
@ -53,6 +54,7 @@
|
||||||
"@nestjs/cli": "7.4.1",
|
"@nestjs/cli": "7.4.1",
|
||||||
"@nestjs/schematics": "7.0.1",
|
"@nestjs/schematics": "7.0.1",
|
||||||
"@nestjs/testing": "7.3.1",
|
"@nestjs/testing": "7.3.1",
|
||||||
|
"@types/cookie-parser": "^1.4.2",
|
||||||
"@types/express": "4.17.8",
|
"@types/express": "4.17.8",
|
||||||
"@types/hapi__joi": "17.1.4",
|
"@types/hapi__joi": "17.1.4",
|
||||||
"@types/jest": "26.0.13",
|
"@types/jest": "26.0.13",
|
||||||
|
|
|
||||||
26
src/auth/auth-session.entity.ts
Normal file
26
src/auth/auth-session.entity.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
} from "typeorm";
|
||||||
|
import { User } from "../users/user.entity";
|
||||||
|
|
||||||
|
@Entity()
|
||||||
|
export class AuthSession {
|
||||||
|
@PrimaryGeneratedColumn("uuid")
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
@ManyToOne((type) => User, { eager: true })
|
||||||
|
user: User;
|
||||||
|
|
||||||
|
@CreateDateColumn()
|
||||||
|
createdAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: "timestamp", default: () => "CURRENT_TIMESTAMP" })
|
||||||
|
lastUsedAt: Date;
|
||||||
|
|
||||||
|
@Column({ type: "timestamp", nullable: true })
|
||||||
|
revokedAt: Date | null;
|
||||||
|
}
|
||||||
23
src/auth/auth-session.repository.ts
Normal file
23
src/auth/auth-session.repository.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// tslint:disable: max-classes-per-file
|
||||||
|
import { EntityRepository, Repository, SelectQueryBuilder } from "typeorm";
|
||||||
|
import { User } from "../users/user.entity";
|
||||||
|
import { AuthSession } from "./auth-session.entity";
|
||||||
|
|
||||||
|
export class AuthSessionScopes extends SelectQueryBuilder<AuthSession> {
|
||||||
|
/**
|
||||||
|
* `byUser` scopes the query to AuthSessions created by the user.
|
||||||
|
* @param currentUser
|
||||||
|
*/
|
||||||
|
byUser(currentUser: User): this {
|
||||||
|
return this.andWhere(`session."userId" = :userID`, {
|
||||||
|
userID: currentUser.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EntityRepository(AuthSession)
|
||||||
|
export class AuthSessionRepository extends Repository<AuthSession> {
|
||||||
|
get scoped(): AuthSessionScopes {
|
||||||
|
return new AuthSessionScopes(this.createQueryBuilder("session"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,10 +1,23 @@
|
||||||
import { Controller, Get, Res, UseFilters, UseGuards } from "@nestjs/common";
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Res,
|
||||||
|
UseFilters,
|
||||||
|
UseGuards,
|
||||||
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
|
||||||
import { Response } from "express";
|
import { Response } from "express";
|
||||||
import { User } from "../users/user.entity";
|
import { User } from "../users/user.entity";
|
||||||
|
import { AuthSession } from "./auth-session.entity";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
|
import { COOKIE_REFRESH_TOKEN } from "./constants";
|
||||||
import { ReqUser } from "./decorators/req-user.decorator";
|
import { ReqUser } from "./decorators/req-user.decorator";
|
||||||
|
import { RefreshAccessTokenResponseDto } from "./dto/refresh-access-token-response.dto";
|
||||||
|
import {
|
||||||
|
RefreshTokenAuthGuard,
|
||||||
|
SpotifyAuthGuard,
|
||||||
|
} from "./guards/auth-strategies.guard";
|
||||||
import { SpotifyAuthFilter } from "./spotify.filter";
|
import { SpotifyAuthFilter } from "./spotify.filter";
|
||||||
|
|
||||||
@Controller("api/v1/auth")
|
@Controller("api/v1/auth")
|
||||||
|
|
@ -15,26 +28,33 @@ export class AuthController {
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get("spotify")
|
@Get("spotify")
|
||||||
@UseGuards(AuthGuard("spotify"))
|
@UseGuards(SpotifyAuthGuard)
|
||||||
spotifyRedirect() {
|
spotifyRedirect() {
|
||||||
// User is redirected by AuthGuard
|
// User is redirected by AuthGuard
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("spotify/callback")
|
@Get("spotify/callback")
|
||||||
@UseFilters(SpotifyAuthFilter)
|
@UseFilters(SpotifyAuthFilter)
|
||||||
@UseGuards(AuthGuard("spotify"))
|
@UseGuards(SpotifyAuthGuard)
|
||||||
async spotifyCallback(@ReqUser() user: User, @Res() res: Response) {
|
async spotifyCallback(@ReqUser() user: User, @Res() res: Response) {
|
||||||
const { accessToken } = await this.authService.createToken(user);
|
const { refreshToken } = await this.authService.createSession(user);
|
||||||
|
|
||||||
// Transmit accessToken to Frontend
|
// Refresh token should not be accessible to frontend to reduce risk
|
||||||
res.cookie("listory_access_token", accessToken, {
|
// of XSS attacks.
|
||||||
maxAge: 24 * 60 * 60 * 1000, // 1 day
|
res.cookie(COOKIE_REFRESH_TOKEN, refreshToken, { httpOnly: true });
|
||||||
|
|
||||||
// Must be readable by SPA
|
|
||||||
httpOnly: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Redirect User to SPA
|
// Redirect User to SPA
|
||||||
res.redirect("/login/success?source=spotify");
|
res.redirect("/login/success?source=spotify");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post("token/refresh")
|
||||||
|
@UseGuards(RefreshTokenAuthGuard)
|
||||||
|
async refreshAccessToken(
|
||||||
|
// With RefreshTokenAuthGuard the session is available instead of user
|
||||||
|
@ReqUser() session: AuthSession
|
||||||
|
): Promise<RefreshAccessTokenResponseDto> {
|
||||||
|
const { accessToken } = await this.authService.createAccessToken(session);
|
||||||
|
|
||||||
|
return { accessToken };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,20 @@
|
||||||
import { Module } from "@nestjs/common";
|
import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { JwtModule } from "@nestjs/jwt";
|
import { JwtModule } from "@nestjs/jwt";
|
||||||
import { PassportModule } from "@nestjs/passport";
|
import { PassportModule } from "@nestjs/passport";
|
||||||
|
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||||
|
import { CookieParserMiddleware } from "../cookie-parser";
|
||||||
import { UsersModule } from "../users/users.module";
|
import { UsersModule } from "../users/users.module";
|
||||||
|
import { AuthSessionRepository } from "./auth-session.repository";
|
||||||
import { AuthController } from "./auth.controller";
|
import { AuthController } from "./auth.controller";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "./auth.service";
|
||||||
import { JwtStrategy } from "./jwt.strategy";
|
import { AccessTokenStrategy } from "./strategies/access-token.strategy";
|
||||||
import { SpotifyStrategy } from "./spotify.strategy";
|
import { RefreshTokenStrategy } from "./strategies/refresh-token.strategy";
|
||||||
|
import { SpotifyStrategy } from "./strategies/spotify.strategy";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([AuthSessionRepository]),
|
||||||
PassportModule.register({ defaultStrategy: "jwt" }),
|
PassportModule.register({ defaultStrategy: "jwt" }),
|
||||||
JwtModule.registerAsync({
|
JwtModule.registerAsync({
|
||||||
useFactory: (config: ConfigService) => ({
|
useFactory: (config: ConfigService) => ({
|
||||||
|
|
@ -23,8 +28,17 @@ import { SpotifyStrategy } from "./spotify.strategy";
|
||||||
}),
|
}),
|
||||||
UsersModule,
|
UsersModule,
|
||||||
],
|
],
|
||||||
providers: [AuthService, SpotifyStrategy, JwtStrategy],
|
providers: [
|
||||||
|
AuthService,
|
||||||
|
SpotifyStrategy,
|
||||||
|
AccessTokenStrategy,
|
||||||
|
RefreshTokenStrategy,
|
||||||
|
],
|
||||||
exports: [PassportModule],
|
exports: [PassportModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule implements NestModule {
|
||||||
|
configure(consumer: MiddlewareConsumer) {
|
||||||
|
consumer.apply(CookieParserMiddleware).forRoutes("api/v1/auth");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,27 @@
|
||||||
import { Injectable, ForbiddenException } 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 { User } from "../users/user.entity";
|
import { User } from "../users/user.entity";
|
||||||
import { UsersService } from "../users/users.service";
|
import { UsersService } from "../users/users.service";
|
||||||
|
import { AuthSession } from "./auth-session.entity";
|
||||||
|
import { AuthSessionRepository } from "./auth-session.repository";
|
||||||
import { LoginDto } from "./dto/login.dto";
|
import { LoginDto } from "./dto/login.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
private readonly userFilter: null | string;
|
private readonly userFilter: null | string;
|
||||||
|
private readonly sessionExpirationTime: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly usersService: UsersService,
|
private readonly usersService: UsersService,
|
||||||
private readonly jwtService: JwtService
|
private readonly jwtService: JwtService,
|
||||||
|
private readonly authSessionRepository: AuthSessionRepository
|
||||||
) {
|
) {
|
||||||
this.userFilter = this.config.get<string>("SPOTIFY_USER_FILTER");
|
this.userFilter = this.config.get<string>("SPOTIFY_USER_FILTER");
|
||||||
|
this.sessionExpirationTime = this.config.get<string>(
|
||||||
|
"SESSION_EXPIRATION_TIME"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async spotifyLogin({
|
async spotifyLogin({
|
||||||
|
|
@ -38,6 +46,67 @@ export class AuthService {
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createSession(
|
||||||
|
user: User
|
||||||
|
): Promise<{
|
||||||
|
session: AuthSession;
|
||||||
|
refreshToken: string;
|
||||||
|
}> {
|
||||||
|
const session = this.authSessionRepository.create();
|
||||||
|
|
||||||
|
session.user = user;
|
||||||
|
await this.authSessionRepository.save(session);
|
||||||
|
|
||||||
|
const [{ refreshToken }] = await Promise.all([
|
||||||
|
this.createRefreshToken(session),
|
||||||
|
this.createAccessToken(session),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { session, refreshToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* createRefreshToken should only be used while creating a new session.
|
||||||
|
* @param session
|
||||||
|
*/
|
||||||
|
private async createRefreshToken(
|
||||||
|
session: AuthSession
|
||||||
|
): Promise<{ refreshToken }> {
|
||||||
|
const payload = {
|
||||||
|
sub: session.user.id,
|
||||||
|
name: session.user.displayName,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = await this.jwtService.signAsync(payload, {
|
||||||
|
jwtid: session.id,
|
||||||
|
// jwtService uses the shorter access token time as a default
|
||||||
|
expiresIn: this.sessionExpirationTime,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { refreshToken: token };
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAccessToken(session: AuthSession): Promise<{ accessToken }> {
|
||||||
|
if (session.revokedAt) {
|
||||||
|
throw new ForbiddenException("SessionIsRevoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
sub: session.user.id,
|
||||||
|
name: session.user.displayName,
|
||||||
|
picture: session.user.photo,
|
||||||
|
};
|
||||||
|
|
||||||
|
const token = await this.jwtService.signAsync(payload);
|
||||||
|
|
||||||
|
return { accessToken: token };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to createAccessToken
|
||||||
|
* @deprecated
|
||||||
|
* @param user
|
||||||
|
*/
|
||||||
async createToken(user: User): Promise<{ accessToken }> {
|
async createToken(user: User): Promise<{ accessToken }> {
|
||||||
const payload = {
|
const payload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
|
|
@ -50,6 +119,10 @@ export class AuthService {
|
||||||
return { accessToken: token };
|
return { accessToken: token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findSession(id: string): Promise<AuthSession> {
|
||||||
|
return this.authSessionRepository.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
async findUser(id: string): Promise<User> {
|
async findUser(id: string): Promise<User> {
|
||||||
return this.usersService.findById(id);
|
return this.usersService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
src/auth/constants.ts
Normal file
1
src/auth/constants.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export const COOKIE_REFRESH_TOKEN = "listory_refresh_token";
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { applyDecorators, UseGuards } from "@nestjs/common";
|
import { applyDecorators, UseGuards } from "@nestjs/common";
|
||||||
import { AuthGuard } from "@nestjs/passport";
|
|
||||||
import { ApiBearerAuth, ApiUnauthorizedResponse } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiUnauthorizedResponse } from "@nestjs/swagger";
|
||||||
|
import { AccessTokenAuthGuard } from "../guards/auth-strategies.guard";
|
||||||
|
|
||||||
export function Auth() {
|
export function AuthAccessToken() {
|
||||||
return applyDecorators(
|
return applyDecorators(
|
||||||
UseGuards(AuthGuard("jwt")),
|
UseGuards(AccessTokenAuthGuard),
|
||||||
ApiBearerAuth(),
|
ApiBearerAuth(),
|
||||||
ApiUnauthorizedResponse({ description: 'Unauthorized"' })
|
ApiUnauthorizedResponse({ description: 'Unauthorized"' })
|
||||||
);
|
);
|
||||||
3
src/auth/dto/refresh-access-token-response.dto.ts
Normal file
3
src/auth/dto/refresh-access-token-response.dto.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export class RefreshAccessTokenResponseDto {
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
9
src/auth/guards/auth-strategies.guard.ts
Normal file
9
src/auth/guards/auth-strategies.guard.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { AuthGuard } from "@nestjs/passport";
|
||||||
|
import { AuthStrategy } from "../strategies/strategies.enum";
|
||||||
|
|
||||||
|
// Internal
|
||||||
|
export const AccessTokenAuthGuard = AuthGuard(AuthStrategy.AccessToken);
|
||||||
|
export const RefreshTokenAuthGuard = AuthGuard(AuthStrategy.RefreshToken);
|
||||||
|
|
||||||
|
// Auth Provider
|
||||||
|
export const SpotifyAuthGuard = AuthGuard(AuthStrategy.Spotify);
|
||||||
|
|
@ -1,11 +1,15 @@
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import { Strategy, ExtractJwt } from "passport-jwt";
|
import { ExtractJwt, Strategy } from "passport-jwt";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "../auth.service";
|
||||||
|
import { AuthStrategy } from "./strategies.enum";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
export class AccessTokenStrategy extends PassportStrategy(
|
||||||
|
Strategy,
|
||||||
|
AuthStrategy.AccessToken
|
||||||
|
) {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
config: ConfigService
|
config: ConfigService
|
||||||
|
|
@ -17,7 +21,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(payload: any) {
|
async validate(payload: { sub: string }) {
|
||||||
return this.authService.findUser(payload.sub);
|
return this.authService.findUser(payload.sub);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
48
src/auth/strategies/refresh-token.strategy.ts
Normal file
48
src/auth/strategies/refresh-token.strategy.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
import {
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
ForbiddenException,
|
||||||
|
} from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
|
import { JwtFromRequestFunction, Strategy } from "passport-jwt";
|
||||||
|
import { AuthService } from "../auth.service";
|
||||||
|
import { COOKIE_REFRESH_TOKEN } from "../constants";
|
||||||
|
import { AuthStrategy } from "./strategies.enum";
|
||||||
|
import { AuthSession } from "../auth-session.entity";
|
||||||
|
|
||||||
|
const extractJwtFromCookie: JwtFromRequestFunction = (req) => {
|
||||||
|
const token = req.cookies[COOKIE_REFRESH_TOKEN] || null;
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RefreshTokenStrategy extends PassportStrategy(
|
||||||
|
Strategy,
|
||||||
|
AuthStrategy.RefreshToken
|
||||||
|
) {
|
||||||
|
constructor(
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
config: ConfigService
|
||||||
|
) {
|
||||||
|
super({
|
||||||
|
jwtFromRequest: extractJwtFromCookie,
|
||||||
|
ignoreExpiration: false,
|
||||||
|
secretOrKey: config.get<string>("JWT_SECRET"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async validate(payload: { jti: string }): Promise<AuthSession> {
|
||||||
|
const session = await this.authService.findSession(payload.jti);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
throw new UnauthorizedException("SessionNotFound");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (session.revokedAt) {
|
||||||
|
throw new ForbiddenException("SessionIsRevoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,10 +2,14 @@ import { Injectable } from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { PassportStrategy } from "@nestjs/passport";
|
import { PassportStrategy } from "@nestjs/passport";
|
||||||
import { Strategy } from "passport-spotify";
|
import { Strategy } from "passport-spotify";
|
||||||
import { AuthService } from "./auth.service";
|
import { AuthService } from "../auth.service";
|
||||||
|
import { AuthStrategy } from "./strategies.enum";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SpotifyStrategy extends PassportStrategy(Strategy) {
|
export class SpotifyStrategy extends PassportStrategy(
|
||||||
|
Strategy,
|
||||||
|
AuthStrategy.Spotify
|
||||||
|
) {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly authService: AuthService,
|
private readonly authService: AuthService,
|
||||||
config: ConfigService
|
config: ConfigService
|
||||||
8
src/auth/strategies/strategies.enum.ts
Normal file
8
src/auth/strategies/strategies.enum.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
export enum AuthStrategy {
|
||||||
|
// Internal
|
||||||
|
AccessToken = "access_token",
|
||||||
|
RefreshToken = "refresh_token",
|
||||||
|
|
||||||
|
// Auth Provider
|
||||||
|
Spotify = "spotify",
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,6 @@
|
||||||
import * as Joi from "@hapi/joi";
|
import * as Joi from "@hapi/joi";
|
||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
import {
|
import { ConfigModule as NestConfigModule } from "@nestjs/config";
|
||||||
ConfigModule as NestConfigModule,
|
|
||||||
ConfigService,
|
|
||||||
} from "@nestjs/config";
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -19,7 +16,9 @@ import {
|
||||||
JWT_ALGORITHM: Joi.string()
|
JWT_ALGORITHM: Joi.string()
|
||||||
.default("HS256")
|
.default("HS256")
|
||||||
.allow("HS256", "HS384", "HS512"),
|
.allow("HS256", "HS384", "HS512"),
|
||||||
JWT_EXPIRATION_TIME: Joi.string().default("1d"),
|
|
||||||
|
JWT_EXPIRATION_TIME: Joi.string().default("15m"),
|
||||||
|
SESSION_EXPIRATION_TIME: Joi.string().default("1y"),
|
||||||
|
|
||||||
// Spotify
|
// Spotify
|
||||||
SPOTIFY_CLIENT_ID: Joi.string().required(),
|
SPOTIFY_CLIENT_ID: Joi.string().required(),
|
||||||
|
|
|
||||||
16
src/cookie-parser/cookie-parser.middleware.ts
Normal file
16
src/cookie-parser/cookie-parser.middleware.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import { Injectable, NestMiddleware } from "@nestjs/common";
|
||||||
|
import * as cookieParser from "cookie-parser";
|
||||||
|
import type { RequestHandler } from "express";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CookieParserMiddleware implements NestMiddleware {
|
||||||
|
private readonly middleware: RequestHandler;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.middleware = cookieParser();
|
||||||
|
}
|
||||||
|
|
||||||
|
use(req: any, res: any, next: () => void) {
|
||||||
|
return this.middleware(req, res, next);
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/cookie-parser/index.ts
Normal file
1
src/cookie-parser/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { CookieParserMiddleware } from "./cookie-parser.middleware";
|
||||||
69
src/database/migrations/04-CreateAuthSessionsTable.ts
Normal file
69
src/database/migrations/04-CreateAuthSessionsTable.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import {
|
||||||
|
MigrationInterface,
|
||||||
|
QueryRunner,
|
||||||
|
Table,
|
||||||
|
TableIndex,
|
||||||
|
TableForeignKey,
|
||||||
|
} from "typeorm";
|
||||||
|
import { TableColumnOptions } from "typeorm/schema-builder/options/TableColumnOptions";
|
||||||
|
|
||||||
|
const primaryUUIDColumn: TableColumnOptions = {
|
||||||
|
name: "id",
|
||||||
|
type: "uuid",
|
||||||
|
isPrimary: true,
|
||||||
|
isGenerated: true,
|
||||||
|
generationStrategy: "uuid",
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CreateAuthSessionsTable0000000000004
|
||||||
|
implements MigrationInterface {
|
||||||
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.createTable(
|
||||||
|
new Table({
|
||||||
|
name: "auth_session",
|
||||||
|
columns: [
|
||||||
|
primaryUUIDColumn,
|
||||||
|
{
|
||||||
|
name: "userId",
|
||||||
|
type: "uuid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "createdAt",
|
||||||
|
type: "timestamp",
|
||||||
|
default: "CURRENT_TIMESTAMP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "lastUsedAt",
|
||||||
|
type: "timestamp",
|
||||||
|
default: "CURRENT_TIMESTAMP",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "revokedAt",
|
||||||
|
type: "timestamp",
|
||||||
|
default: null,
|
||||||
|
isNullable: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
indices: [
|
||||||
|
new TableIndex({
|
||||||
|
name: "IDX_AUTH_SESSION_USER_ID",
|
||||||
|
columnNames: ["userId"],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
foreignKeys: [
|
||||||
|
new TableForeignKey({
|
||||||
|
name: "FK_AUTH_SESSION_USER_ID",
|
||||||
|
columnNames: ["userId"],
|
||||||
|
referencedColumnNames: ["id"],
|
||||||
|
referencedTableName: "user",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.dropTable("auth_session");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Controller, Get, Query } from "@nestjs/common";
|
import { Controller, Get, Query } from "@nestjs/common";
|
||||||
import { Pagination } from "nestjs-typeorm-paginate";
|
import { Pagination } from "nestjs-typeorm-paginate";
|
||||||
import { Auth } from "../auth/decorators/auth.decorator";
|
import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator";
|
||||||
import { ReqUser } from "../auth/decorators/req-user.decorator";
|
import { ReqUser } from "../auth/decorators/req-user.decorator";
|
||||||
import { User } from "../users/user.entity";
|
import { User } from "../users/user.entity";
|
||||||
import { GetListensFilterDto } from "./dto/get-listens.dto";
|
import { GetListensFilterDto } from "./dto/get-listens.dto";
|
||||||
|
|
@ -12,7 +12,7 @@ export class ListensController {
|
||||||
constructor(private readonly listensService: ListensService) {}
|
constructor(private readonly listensService: ListensService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@Auth()
|
@AuthAccessToken()
|
||||||
async getRecentlyPlayed(
|
async getRecentlyPlayed(
|
||||||
@Query("page") page: number = 1,
|
@Query("page") page: number = 1,
|
||||||
@Query("limit") limit: number = 10,
|
@Query("limit") limit: number = 10,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Controller, Get, Query } from "@nestjs/common";
|
import { Controller, Get, Query } from "@nestjs/common";
|
||||||
import { Auth } from "../auth/decorators/auth.decorator";
|
import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator";
|
||||||
import { ReqUser } from "../auth/decorators/req-user.decorator";
|
import { ReqUser } from "../auth/decorators/req-user.decorator";
|
||||||
import { User } from "../users/user.entity";
|
import { User } from "../users/user.entity";
|
||||||
import { ListenReportDto } from "./dto/listen-report.dto";
|
import { ListenReportDto } from "./dto/listen-report.dto";
|
||||||
|
|
@ -13,7 +13,7 @@ export class ReportsController {
|
||||||
constructor(private readonly reportsService: ReportsService) {}
|
constructor(private readonly reportsService: ReportsService) {}
|
||||||
|
|
||||||
@Get("listens")
|
@Get("listens")
|
||||||
@Auth()
|
@AuthAccessToken()
|
||||||
async getListens(
|
async getListens(
|
||||||
@Query() time: ReportTimeDto,
|
@Query() time: ReportTimeDto,
|
||||||
@Query("timeFrame") timeFrame: Timeframe,
|
@Query("timeFrame") timeFrame: Timeframe,
|
||||||
|
|
@ -23,7 +23,7 @@ export class ReportsController {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get("top-artists")
|
@Get("top-artists")
|
||||||
@Auth()
|
@AuthAccessToken()
|
||||||
async getTopArtists(
|
async getTopArtists(
|
||||||
@Query() time: ReportTimeDto,
|
@Query() time: ReportTimeDto,
|
||||||
@ReqUser() user: User
|
@ReqUser() user: User
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { Controller, Get } from "@nestjs/common";
|
import { Controller, Get } from "@nestjs/common";
|
||||||
import { Auth } from "../auth/decorators/auth.decorator";
|
import { AuthAccessToken } from "../auth/decorators/auth-access-token.decorator";
|
||||||
import { ReqUser } from "../auth/decorators/req-user.decorator";
|
import { ReqUser } from "../auth/decorators/req-user.decorator";
|
||||||
import { User } from "./user.entity";
|
import { User } from "./user.entity";
|
||||||
|
|
||||||
@Controller("api/v1/users")
|
@Controller("api/v1/users")
|
||||||
export class UsersController {
|
export class UsersController {
|
||||||
@Get("me")
|
@Get("me")
|
||||||
@Auth()
|
@AuthAccessToken()
|
||||||
getMe(@ReqUser() user: User): Omit<User, "spotify"> {
|
getMe(@ReqUser() user: User): Omit<User, "spotify"> {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue