diff --git a/app/auth/auth.server.ts b/app/auth/auth.server.ts index ed12af5..75ac25e 100644 --- a/app/auth/auth.server.ts +++ b/app/auth/auth.server.ts @@ -3,7 +3,8 @@ import { Authenticator } from 'remix-auth'; import { FormStrategy } from 'remix-auth-form'; import type { UsersService } from '../users/UsersService.server'; import { credentialsSchema } from './credentials-schema.server'; -import type { SessionData, SessionStorage } from './session.server'; +import type { SessionStorage } from './session.server'; +import type { SessionData } from './session-context'; export const CREDENTIALS_STRATEGY = 'credentials'; diff --git a/app/auth/session-context.ts b/app/auth/session-context.ts new file mode 100644 index 0000000..81a3dda --- /dev/null +++ b/app/auth/session-context.ts @@ -0,0 +1,12 @@ +import { createContext, useContext } from 'react'; + +export type SessionData = { + userId: number; + [key: string]: unknown; +}; + +const SessionContext = createContext(null); + +export const { Provider: SessionProvider } = SessionContext; + +export const useSession = () => useContext(SessionContext); diff --git a/app/auth/session.server.ts b/app/auth/session.server.ts index 92753b5..441b12e 100644 --- a/app/auth/session.server.ts +++ b/app/auth/session.server.ts @@ -1,10 +1,6 @@ import { createCookieSessionStorage } from '@remix-run/node'; import { env, isProd } from '../utils/env.server'; - -export type SessionData = { - userId: number; - [key: string]: unknown; -}; +import type { SessionData } from './session-context'; export const createSessionStorage = () => createCookieSessionStorage({ cookie: { diff --git a/app/common/MainHeader.tsx b/app/common/MainHeader.tsx index dc6b210..8e50bbb 100644 --- a/app/common/MainHeader.tsx +++ b/app/common/MainHeader.tsx @@ -1,13 +1,40 @@ +import { faArrowRightFromBracket as faLogout, faChevronDown as arrowIcon } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Link } from '@remix-run/react'; +import { useToggle } from '@shlinkio/shlink-frontend-kit'; import type { FC } from 'react'; import React from 'react'; -import { Navbar, NavbarBrand } from 'reactstrap'; +import { Collapse, Nav, Navbar, NavbarBrand, NavbarToggler, NavItem, NavLink } from 'reactstrap'; +import { useSession } from '../auth/session-context'; import { ShlinkLogo } from './ShlinkLogo'; -export const MainHeader: FC = () => ( - - - Shlink - - -); +export const MainHeader: FC = () => { + const session = useSession(); + const [isOpen, toggleCollapse] = useToggle(); + + return ( + + + Shlink + + + {session !== null && ( + <> + + + + + + + + + )} + + ); +}; diff --git a/app/container/container.server.ts b/app/container/container.server.ts index b6d100e..a65b030 100644 --- a/app/container/container.server.ts +++ b/app/container/container.server.ts @@ -16,8 +16,8 @@ bottle.serviceFactory('em', () => appDataSource.manager); bottle.serviceFactory('ServersRepository', createServersRepository, 'em'); -bottle.service(TagsService.name, TagsService, 'em'); bottle.service(ServersService.name, ServersService, 'ServersRepository'); +bottle.service(TagsService.name, TagsService, 'em', ServersService.name); bottle.service(SettingsService.name, SettingsService, 'em'); bottle.service(UsersService.name, UsersService, 'em'); diff --git a/app/root.tsx b/app/root.tsx index 4da667f..fe4de3e 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -1,6 +1,8 @@ import type { LoaderFunctionArgs } from '@remix-run/node'; -import { Links, Meta, Outlet, Scripts } from '@remix-run/react'; +import { Links, Meta, Outlet, Scripts, useLoaderData } from '@remix-run/react'; import { Authenticator } from 'remix-auth'; +import type { SessionData } from './auth/session-context'; +import { SessionProvider } from './auth/session-context'; import { MainHeader } from './common/MainHeader'; import { serverContainer } from './container/container.server'; import { appDataSource } from './db/data-source.server'; @@ -8,8 +10,9 @@ import './index.scss'; export async function loader( { request }: LoaderFunctionArgs, - authenticator: Authenticator = serverContainer[Authenticator.name], + authenticator: Authenticator = serverContainer[Authenticator.name], ) { + // FIXME This should be done during server start-up, not here if (!appDataSource.isInitialized) { console.log('Initializing database connection...'); await appDataSource.initialize(); @@ -17,32 +20,41 @@ export async function loader( } const { pathname } = new URL(request.url); - const isPublicRoute = ['/login'].includes(pathname); - if (!isPublicRoute) { - await authenticator.isAuthenticated(request, { - failureRedirect: `/login?redirect-to=${encodeURIComponent(pathname)}`, - }); - } - - return {}; + const isPublicRoute = ['/login', '/logout'].includes(pathname); + const session = await (isPublicRoute + ? authenticator.isAuthenticated(request) // For public routes, do not redirect + : authenticator.isAuthenticated( + request, + { failureRedirect: `/login?redirect-to=${encodeURIComponent(pathname)}` }, + )); + return { session }; } /* eslint-disable-next-line no-restricted-exports */ export default function App() { + const { session } = useLoaderData(); + return ( Shlink dashboard - + + + + + + - -
- -
- + + +
+ +
+ +
); diff --git a/app/routes/logout.ts b/app/routes/logout.ts new file mode 100644 index 0000000..f4d0423 --- /dev/null +++ b/app/routes/logout.ts @@ -0,0 +1,10 @@ +import type { ActionFunctionArgs } from '@remix-run/node'; +import { Authenticator } from 'remix-auth'; +import { serverContainer } from '../container/container.server'; + +export function loader( + { request }: ActionFunctionArgs, + authenticator: Authenticator = serverContainer[Authenticator.name], +) { + return authenticator.logout(request, { redirectTo: '/login' }); +} diff --git a/app/routes/server.$serverId.$.tsx b/app/routes/server.$serverId.$.tsx index b3a6dac..17975c4 100644 --- a/app/routes/server.$serverId.$.tsx +++ b/app/routes/server.$serverId.$.tsx @@ -5,7 +5,7 @@ import type { ReactNode } from 'react'; import { useEffect, useMemo, useState } from 'react'; import { Authenticator } from 'remix-auth'; import { ShlinkApiProxyClient } from '../api/ShlinkApiProxyClient.client'; -import type { SessionData } from '../auth/session.server'; +import type { SessionData } from '../auth/session-context'; import { serverContainer } from '../container/container.server'; import { SettingsService } from '../settings/SettingsService.server'; import { TagsService } from '../tags/TagsService.server'; diff --git a/app/routes/server.$serverId.shlink-api.$method.ts b/app/routes/server.$serverId.shlink-api.$method.ts index ebbb589..923e594 100644 --- a/app/routes/server.$serverId.shlink-api.$method.ts +++ b/app/routes/server.$serverId.shlink-api.$method.ts @@ -4,7 +4,7 @@ import type { ShlinkApiClient } from '@shlinkio/shlink-js-sdk'; import { ErrorType } from '@shlinkio/shlink-js-sdk/api-contract'; import { Authenticator } from 'remix-auth'; import type { ApiClientBuilder } from '../api/apiClientBuilder.server'; -import type { SessionData } from '../auth/session.server'; +import type { SessionData } from '../auth/session-context'; import { serverContainer } from '../container/container.server'; import { ServersService } from '../servers/ServersService.server'; import { problemDetails } from '../utils/response.server'; diff --git a/app/routes/server.$serverId.tags.colors.ts b/app/routes/server.$serverId.tags.colors.ts index 955a032..ac708e0 100644 --- a/app/routes/server.$serverId.tags.colors.ts +++ b/app/routes/server.$serverId.tags.colors.ts @@ -1,6 +1,6 @@ import type { ActionFunctionArgs } from '@remix-run/node'; import { Authenticator } from 'remix-auth'; -import type { SessionData } from '../auth/session.server'; +import type { SessionData } from '../auth/session-context'; import { serverContainer } from '../container/container.server'; import { TagsService } from '../tags/TagsService.server'; import { empty } from '../utils/response.server'; diff --git a/app/tags/TagsService.server.ts b/app/tags/TagsService.server.ts index 5a3676f..f5f33f9 100644 --- a/app/tags/TagsService.server.ts +++ b/app/tags/TagsService.server.ts @@ -1,9 +1,9 @@ import type { EntityManager } from 'typeorm'; import type { Server } from '../entities/Server'; -import { ServerEntity } from '../entities/Server'; import { TagEntity } from '../entities/Tag'; import type { User } from '../entities/User'; import { UserEntity } from '../entities/User'; +import type { ServersService } from '../servers/ServersService.server'; export type FindTagsParam = { userId: number; @@ -20,7 +20,7 @@ type ServerAndUserResult = { }; export class TagsService { - constructor(private readonly em: EntityManager) {} + constructor(private readonly em: EntityManager, private readonly serversService: ServersService) {} async tagColors(param: FindTagsParam): Promise> { const { server, user } = await this.resolveServerAndUser(param); @@ -71,7 +71,7 @@ export class TagsService { private async resolveServerAndUser({ userId, serverPublicId }: FindTagsParam): Promise { const [server, user] = await Promise.all([ - serverPublicId ? this.em.findOneBy(ServerEntity, { publicId: serverPublicId }) : null, + serverPublicId ? this.serversService.getByPublicIdAndUser(serverPublicId, userId) : null, this.em.findOneBy(UserEntity, { id: userId }), ]); diff --git a/package-lock.json b/package-lock.json index 9956ff9..0673a67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,11 @@ "name": "shlink-dashboard", "license": "MIT", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-brands-svg-icons": "^6.5.2", + "@fortawesome/free-regular-svg-icons": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", + "@fortawesome/react-fontawesome": "^0.2.1", "@remix-run/express": "^2.9.2", "@remix-run/node": "^2.9.2", "@remix-run/react": "^2.9.2", @@ -1277,7 +1282,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.2.tgz", "integrity": "sha512-gBxPg3aVO6J0kpfHNILc+NMhXnqHumFxOmjYCFfOiLZfwhnnfhtsdA2hfJlDnj+8PjAs6kKQPenOTKj3Rf7zHw==", "hasInstallScript": true, - "peer": true, "engines": { "node": ">=6" } @@ -1297,7 +1301,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.5.2.tgz", "integrity": "sha512-5CdaCBGl8Rh9ohNdxeeTMxIj8oc3KNBgIeLMvJosBMdslK/UnEB8rzyDRrbKdL1kDweqBPo4GT9wvnakHWucZw==", "hasInstallScript": true, - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.5.2" }, @@ -1310,7 +1313,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/free-brands-svg-icons/-/free-brands-svg-icons-6.5.2.tgz", "integrity": "sha512-zi5FNYdmKLnEc0jc0uuHH17kz/hfYTg4Uei0wMGzcoCL/4d3WM3u1VMc0iGGa31HuhV5i7ZK8ZlTCQrHqRHSGQ==", "hasInstallScript": true, - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.5.2" }, @@ -1323,7 +1325,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-6.5.2.tgz", "integrity": "sha512-iabw/f5f8Uy2nTRtJ13XZTS1O5+t+anvlamJ3zJGLEVE2pKsAWhPv2lq01uQlfgCX7VaveT3EVs515cCN9jRbw==", "hasInstallScript": true, - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.5.2" }, @@ -1336,7 +1337,6 @@ "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.5.2.tgz", "integrity": "sha512-QWFZYXFE7O1Gr1dTIp+D6UcFUF0qElOnZptpi7PBUMylJh+vFmIedVe1Ir6RM1t2tEQLLSV1k7bR4o92M+uqlw==", "hasInstallScript": true, - "peer": true, "dependencies": { "@fortawesome/fontawesome-common-types": "6.5.2" }, @@ -1345,10 +1345,9 @@ } }, "node_modules/@fortawesome/react-fontawesome": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.0.tgz", - "integrity": "sha512-uHg75Rb/XORTtVt7OS9WoK8uM276Ufi7gCzshVWkUJbHhh3svsUUeqXerrM96Wm7fRiDzfKRwSoahhMIkGAYHw==", - "peer": true, + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fortawesome/react-fontawesome/-/react-fontawesome-0.2.1.tgz", + "integrity": "sha512-ldr5QO2MneAX5W5WBCYB2pZp/PiHDD1hy9YEBLcXUyJb0qnO86oP8RU+CgmYVSH/R4Dbe2ernhcWOrcgaKD9NQ==", "dependencies": { "prop-types": "^15.8.1" }, diff --git a/package.json b/package.json index 1b5c35d..4fe070b 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,11 @@ "migration:create": "tsx node_modules/.bin/typeorm migration:create app/db/migrations/migration" }, "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-brands-svg-icons": "^6.5.2", + "@fortawesome/free-regular-svg-icons": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", + "@fortawesome/react-fontawesome": "^0.2.1", "@remix-run/express": "^2.9.2", "@remix-run/node": "^2.9.2", "@remix-run/react": "^2.9.2", diff --git a/test/common/MainHeader.test.tsx b/test/common/MainHeader.test.tsx index 476db6b..e4acfb6 100644 --- a/test/common/MainHeader.test.tsx +++ b/test/common/MainHeader.test.tsx @@ -1,14 +1,37 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import { fromPartial } from '@total-typescript/shoehorn'; import { MemoryRouter } from 'react-router'; +import type { SessionData } from '../../app/auth/session-context'; +import { SessionProvider } from '../../app/auth/session-context'; import { MainHeader } from '../../app/common/MainHeader'; import { checkAccessibility } from '../__helpers__/accessibility'; describe('', () => { - const setUp = () => render( - - - , + const setUp = (session?: SessionData) => render( + + + + + , ); - it('passes a11y checks', () => checkAccessibility(setUp())); + it.each([ + [undefined], + [fromPartial({})], + ])('passes a11y checks', (session) => checkAccessibility(setUp(session))); + + it.each([ + [undefined], + [fromPartial({})], + ])('shows logout and menu toggle only if session is set', (session) => { + setUp(session); + + if (session) { + expect(screen.getByRole('button')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'Logout' })).toBeInTheDocument(); + } else { + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Logout' })).not.toBeInTheDocument(); + } + }); }); diff --git a/test/routes/logout.test.ts b/test/routes/logout.test.ts new file mode 100644 index 0000000..5beea91 --- /dev/null +++ b/test/routes/logout.test.ts @@ -0,0 +1,19 @@ +import type { LoaderFunctionArgs } from '@remix-run/node'; +import { fromPartial } from '@total-typescript/shoehorn'; +import type { Authenticator } from 'remix-auth'; +import { loader as logoutLoader } from '../../app/routes/logout'; + +describe('logout', () => { + const logout = vi.fn(); + const authenticator = fromPartial({ logout }); + const setUp = () => (args: LoaderFunctionArgs) => logoutLoader(args, authenticator); + + it('logs out in authenticator', () => { + const request = fromPartial({}); + const action = setUp(); + + action(fromPartial({ request })); + + expect(logout).toHaveBeenCalledWith(request, { redirectTo: '/login' }); + }); +}); diff --git a/test/routes/server.$serverId.shlink-api.$method.test.ts b/test/routes/server.$serverId.shlink-api.$method.test.ts index 1e06aa7..0541ed1 100644 --- a/test/routes/server.$serverId.shlink-api.$method.test.ts +++ b/test/routes/server.$serverId.shlink-api.$method.test.ts @@ -4,7 +4,7 @@ import { ErrorType } from '@shlinkio/shlink-js-sdk/api-contract'; import { fromPartial } from '@total-typescript/shoehorn'; import type { Authenticator } from 'remix-auth'; import { expect } from 'vitest'; -import type { SessionData } from '../../app/auth/session.server'; +import type { SessionData } from '../../app/auth/session-context'; import { action } from '../../app/routes/server.$serverId.shlink-api.$method'; import type { ServersService } from '../../app/servers/ServersService.server'; diff --git a/test/routes/server.$serverId.tags.colors.test.ts b/test/routes/server.$serverId.tags.colors.test.ts index c5a3078..3f4623c 100644 --- a/test/routes/server.$serverId.tags.colors.test.ts +++ b/test/routes/server.$serverId.tags.colors.test.ts @@ -1,7 +1,7 @@ import type { ActionFunctionArgs } from '@remix-run/node'; import { fromPartial } from '@total-typescript/shoehorn'; import type { Authenticator } from 'remix-auth'; -import type { SessionData } from '../../app/auth/session.server'; +import type { SessionData } from '../../app/auth/session-context'; import { action } from '../../app/routes/server.$serverId.tags.colors'; import type { TagsService } from '../../app/tags/TagsService.server'; diff --git a/test/tags/TagsService.server.test.ts b/test/tags/TagsService.server.test.ts index 39089d3..f13e48a 100644 --- a/test/tags/TagsService.server.test.ts +++ b/test/tags/TagsService.server.test.ts @@ -1,11 +1,11 @@ import { fromPartial } from '@total-typescript/shoehorn'; import type { EntityManager } from 'typeorm'; import type { Server } from '../../app/entities/Server'; -import { ServerEntity } from '../../app/entities/Server'; import type { Tag } from '../../app/entities/Tag'; import { TagEntity } from '../../app/entities/Tag'; import type { User } from '../../app/entities/User'; import { UserEntity } from '../../app/entities/User'; +import type { ServersService } from '../../app/servers/ServersService.server'; import { TagsService } from '../../app/tags/TagsService.server'; describe('TagsService', () => { @@ -26,10 +26,12 @@ describe('TagsService', () => { options: { type: 'mysql' }, }, }); + const getByPublicIdAndUser = vi.fn(); + const serversService = fromPartial({ getByPublicIdAndUser }); let tagsService: TagsService; beforeEach(() => { - tagsService = new TagsService(em); + tagsService = new TagsService(em, serversService); transaction.mockImplementation((callback) => callback(em)); }); @@ -39,17 +41,15 @@ describe('TagsService', () => { [fromPartial({}), null], [null, fromPartial({})], ])('returns empty map when server or user are not found', async (server, user) => { - findOneBy - .mockResolvedValueOnce(server) - .mockResolvedValueOnce(user); + getByPublicIdAndUser.mockResolvedValue(server); + findOneBy.mockResolvedValue(user); const result = await tagsService.tagColors({ userId: 1, serverPublicId: '2' }); expect(result).toEqual({}); expect(find).not.toHaveBeenCalled(); - expect(findOneBy).toHaveBeenCalledTimes(2); - expect(findOneBy).toHaveBeenNthCalledWith(1, ServerEntity, { publicId: '2' }); - expect(findOneBy).toHaveBeenNthCalledWith(2, UserEntity, { id: 1 }); + expect(findOneBy).toHaveBeenCalledWith(UserEntity, { id: 1 }); + expect(getByPublicIdAndUser).toHaveBeenCalledWith('2', 1); }); it('returns empty map serverPublicId is not provided', async () => { @@ -59,6 +59,7 @@ describe('TagsService', () => { expect(result).toEqual({}); expect(find).not.toHaveBeenCalled(); + expect(getByPublicIdAndUser).not.toHaveBeenCalled(); expect(findOneBy).toHaveBeenCalledOnce(); }); @@ -66,9 +67,8 @@ describe('TagsService', () => { const server = fromPartial({}); const user = fromPartial({}); - findOneBy - .mockResolvedValueOnce(server) - .mockResolvedValueOnce(user); + getByPublicIdAndUser.mockResolvedValue(server); + findOneBy.mockResolvedValue(user); find.mockResolvedValue([ fromPartial({ tag: 'foo', color: 'red' }), fromPartial({ tag: 'bar', color: 'green' }), @@ -82,9 +82,8 @@ describe('TagsService', () => { where: { user, server }, order: { tag: 'ASC' }, }); - expect(findOneBy).toHaveBeenCalledTimes(2); - expect(findOneBy).toHaveBeenNthCalledWith(1, ServerEntity, { publicId: '2' }); - expect(findOneBy).toHaveBeenNthCalledWith(2, UserEntity, { id: 1 }); + expect(getByPublicIdAndUser).toHaveBeenCalledWith('2', 1); + expect(findOneBy).toHaveBeenCalledWith(UserEntity, { id: 1 }); }); }); @@ -94,16 +93,14 @@ describe('TagsService', () => { [fromPartial({}), null], [null, fromPartial({})], ])('does nothing when server or user are not found', async (server, user) => { - findOneBy - .mockResolvedValueOnce(server) - .mockResolvedValueOnce(user); + getByPublicIdAndUser.mockResolvedValue(server); + findOneBy.mockResolvedValue(user); await tagsService.updateTagColors({ userId: 1, serverPublicId: '2', colors: {} }); expect(transaction).not.toHaveBeenCalled(); - expect(findOneBy).toHaveBeenCalledTimes(2); - expect(findOneBy).toHaveBeenNthCalledWith(1, ServerEntity, { publicId: '2' }); - expect(findOneBy).toHaveBeenNthCalledWith(2, UserEntity, { id: 1 }); + expect(getByPublicIdAndUser).toHaveBeenCalledWith('2', 1); + expect(findOneBy).toHaveBeenCalledWith(UserEntity, { id: 1 }); }); it('upserts tags for non-ms databases', async () => { @@ -111,9 +108,8 @@ describe('TagsService', () => { const user = fromPartial({}); const colors = { foo: 'red', bar: 'green' }; - findOneBy - .mockResolvedValueOnce(server) - .mockResolvedValueOnce(user); + getByPublicIdAndUser.mockResolvedValue(server); + findOneBy.mockResolvedValue(user); await tagsService.updateTagColors({ userId: 1, serverPublicId: '2', colors }); @@ -136,8 +132,8 @@ describe('TagsService', () => { const secondTag = fromPartial({ tag: 'bar', user, server }); const colors = { foo: 'red', bar: 'green' }; + getByPublicIdAndUser.mockResolvedValue(server); findOneBy - .mockResolvedValueOnce(server) .mockResolvedValueOnce(user) .mockResolvedValueOnce(firstTag) // First tag .mockResolvedValueOnce(null); // Second tag. It does not exist diff --git a/vitest.config.ts b/vitest.config.ts index fb4786d..ab42524 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,12 +26,12 @@ export default defineConfig({ reporter: ['text', 'text-summary', 'clover', 'html'], // Required code coverage. Lower than this will make the check fail - // thresholds: { - // statements: 95, - // branches: 90, - // functions: 85, - // lines: 95, - // }, + thresholds: { + statements: 80, + branches: 90, + functions: 90, + lines: 80, + }, }, }, });