diff --git a/.pnp.cjs b/.pnp.cjs index 7286b757c..450b9e803 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -10475,6 +10475,8 @@ const RAW_RUNTIME_STATE = ["react-dom", "virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:18.2.0"],\ ["react-redux", "virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:9.0.4"],\ ["react-use-dimensions", "virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:1.2.1"],\ + ["remix-auth", "virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:3.6.0"],\ + ["remix-auth-form", "virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:1.4.0"],\ ["remix-esbuild-override", "virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:3.1.0"],\ ["remix-island", "virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:0.1.2"],\ ["remix-routes", "npm:1.5.1"],\ @@ -11403,6 +11405,57 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["remix-auth", [\ + ["npm:3.6.0", {\ + "packageLocation": "../../root/.yarn/berry/cache/remix-auth-npm-3.6.0-c96fab9c4d-10c0.zip/node_modules/remix-auth/",\ + "packageDependencies": [\ + ["remix-auth", "npm:3.6.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:3.6.0", {\ + "packageLocation": "./.yarn/__virtual__/remix-auth-virtual-bf0feb60bf/3/root/.yarn/berry/cache/remix-auth-npm-3.6.0-c96fab9c4d-10c0.zip/node_modules/remix-auth/",\ + "packageDependencies": [\ + ["remix-auth", "virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:3.6.0"],\ + ["@remix-run/react", "virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:2.4.0"],\ + ["@remix-run/server-runtime", "virtual:e266eabc878c06e0fb7ee856ffa450ad0e9f6b58f996d84818af0495aeffc4a4072abba9ab7a1a9b90e6168f050f863032cf9f3d545dffc1bb4d21bc5ec297d5#npm:2.4.1"],\ + ["@types/remix-run__react", null],\ + ["@types/remix-run__server-runtime", null],\ + ["uuid", "npm:8.3.2"]\ + ],\ + "packagePeers": [\ + "@remix-run/react",\ + "@types/remix-run__react",\ + "@types/remix-run__server-runtime"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ + ["remix-auth-form", [\ + ["npm:1.4.0", {\ + "packageLocation": "../../root/.yarn/berry/cache/remix-auth-form-npm-1.4.0-78eb6e3706-10c0.zip/node_modules/remix-auth-form/",\ + "packageDependencies": [\ + ["remix-auth-form", "npm:1.4.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:1.4.0", {\ + "packageLocation": "./.yarn/__virtual__/remix-auth-form-virtual-caa281e050/3/root/.yarn/berry/cache/remix-auth-form-npm-1.4.0-78eb6e3706-10c0.zip/node_modules/remix-auth-form/",\ + "packageDependencies": [\ + ["remix-auth-form", "virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:1.4.0"],\ + ["@remix-run/server-runtime", "virtual:e266eabc878c06e0fb7ee856ffa450ad0e9f6b58f996d84818af0495aeffc4a4072abba9ab7a1a9b90e6168f050f863032cf9f3d545dffc1bb4d21bc5ec297d5#npm:2.4.1"],\ + ["@types/remix-auth", null],\ + ["@types/remix-run__server-runtime", null],\ + ["remix-auth", "virtual:ad23fe81aeb29bcce40d0c7ffeeb6e1168c962d4abd3a3f04b44f484e41813b6b7bd80d8d24c46d62328ececd2479698afaa9e56b6a360aa6ae58f8edfd92e19#npm:3.6.0"]\ + ],\ + "packagePeers": [\ + "@types/remix-auth",\ + "@types/remix-run__server-runtime",\ + "remix-auth"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["remix-esbuild-override", [\ ["npm:3.1.0", {\ "packageLocation": "../../root/.yarn/berry/cache/remix-esbuild-override-npm-3.1.0-4e6d320cea-10c0.zip/node_modules/remix-esbuild-override/",\ diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index cf1c01db7..cf37bbb88 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/.yarnrc.yml b/.yarnrc.yml index 4aa2a00f7..7d1125b6d 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -11,3 +11,9 @@ packageExtensions: 'remix-island@*': dependencies: '@remix-run/server-runtime': '*' + 'remix-auth@*': + dependencies: + '@remix-run/server-runtime': '*' + 'remix-auth-form@*': + dependencies: + '@remix-run/server-runtime': '*' diff --git a/README.md b/README.md index bedb272f7..f9604cd40 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,9 @@ Use the docker [packaged image](https://github.com/andrew-codes/playnite-web/pkg | DB_USERNAME | Username to access database | Optional, only required if disabled anonymous access | | DB_PASSWORD | Password to access database | Optional, only required if disabled anonymous access | | DEBUG | `"playnite-web/*"` | Optional, for troubleshooting; send logs to STDIO | +| USERNAME | | Username used to login | +| PASSWORD | | Password value used to login | +| SECRET | | Secret used to protect credentials | ### Post Deployment Steps diff --git a/apps/playnite-web/local.env b/apps/playnite-web/local.env index d94b3763c..713387a7e 100644 --- a/apps/playnite-web/local.env +++ b/apps/playnite-web/local.env @@ -1,8 +1,12 @@ NODE_ENV=development -DEBUG="playnite-web/*" +DEBUG="playnite-web-app/*" DB_HOST=localhost DB_PORT=27017 DB_USERNAME=local DB_PASSWORD=dev + +SECRET="some secret" +USERNAME="username" +PASSWORD="password" diff --git a/apps/playnite-web/package.json b/apps/playnite-web/package.json index aace05a49..6d75816ff 100644 --- a/apps/playnite-web/package.json +++ b/apps/playnite-web/package.json @@ -18,6 +18,8 @@ "react-dom": "^18.2.0", "react-redux": "^9.0.4", "react-use-dimensions": "^1.2.1", + "remix-auth": "^3.6.0", + "remix-auth-form": "^1.4.0", "remix-island": "^0.1.2", "remix-routes": "^1.5.1", "styled-components": "^6.1.6" diff --git a/apps/playnite-web/project.json b/apps/playnite-web/project.json index cd0536b35..22d838310 100644 --- a/apps/playnite-web/project.json +++ b/apps/playnite-web/project.json @@ -14,7 +14,10 @@ "executor": "nx:run-commands", "options": { "cwd": "{projectRoot}", - "command": "yarn concurrently \"remix-routes -w\" \"yarn cross-env DEBUG='playnite-web-app/*' yarn remix dev -c 'node server.mjs'\"" + "commands": [ + "yarn remix-routes -w", + "bash -c 'set -o allexport && source local.env && set +o allexport && yarn remix dev -c \"node server.mjs\"'" + ] } }, "test/components": { diff --git a/apps/playnite-web/server.mjs b/apps/playnite-web/server.mjs index 080782012..6df50fc94 100644 --- a/apps/playnite-web/server.mjs +++ b/apps/playnite-web/server.mjs @@ -1,15 +1,11 @@ import { createRequestHandler } from '@remix-run/express' import { broadcastDevReady } from '@remix-run/node' import createDebugger from 'debug' -import dotenv from 'dotenv' import express from 'express' -import path from 'path' import * as build from './build/index.js' const debug = createDebugger('playnite-web-app/server') -dotenv.config({ path: path.join(process.cwd(), 'local.env'), override: true }) - const { PORT } = process.env const port = PORT ? parseInt(PORT, 10) : 3000 @@ -23,5 +19,5 @@ app.listen(port, () => { debug('sending dev-ready') broadcastDevReady(build) } - debug(`App listening on http://localhost:$PORT`) + debug(`App listening on http://localhost:${port}`) }) diff --git a/apps/playnite-web/src/api/auth/auth.server.ts b/apps/playnite-web/src/api/auth/auth.server.ts new file mode 100644 index 000000000..a1f4b3ba5 --- /dev/null +++ b/apps/playnite-web/src/api/auth/auth.server.ts @@ -0,0 +1,27 @@ +import createDebugger from 'debug' +import { Authenticator } from 'remix-auth' +import { FormStrategy } from 'remix-auth-form' +import { sessionStorage } from './session.server' + +const { USERNAME, PASSWORD } = process.env +const debug = createDebugger('playnite-web-app/auth.server') + +const authenticator = new Authenticator<{ + username: string +}>(sessionStorage) + +authenticator.use( + new FormStrategy(async ({ form }) => { + let username = form.get('username') + let password = form.get('password') + if (USERNAME !== username || PASSWORD !== password) { + debug('Invalid credentials', { username, password }) + throw new Error('Invalid credentials') + } + + return { username } + }), + 'user-pass', +) + +export { authenticator } diff --git a/apps/playnite-web/src/api/auth/session.server.ts b/apps/playnite-web/src/api/auth/session.server.ts new file mode 100644 index 000000000..7955acd00 --- /dev/null +++ b/apps/playnite-web/src/api/auth/session.server.ts @@ -0,0 +1,16 @@ +// app/services/session.server.ts +import { createCookieSessionStorage } from '@remix-run/node' + +// export the whole sessionStorage object +const sessionStorage = createCookieSessionStorage({ + cookie: { + name: '_session', // use any name you want here + sameSite: 'strict', + path: '/', + httpOnly: true, + secrets: [process.env.SECRET ?? 'secret'], + secure: process.env.NODE_ENV === 'production', + }, +}) + +export { sessionStorage } diff --git a/apps/playnite-web/src/api/client/state/authSlice.ts b/apps/playnite-web/src/api/client/state/authSlice.ts new file mode 100644 index 000000000..f28a08fce --- /dev/null +++ b/apps/playnite-web/src/api/client/state/authSlice.ts @@ -0,0 +1,28 @@ +import { createSlice } from '@reduxjs/toolkit' +import _ from 'lodash' + +const { merge } = _ + +const initialState = { + isAuthenticated: false, +} + +const authSlice = createSlice({ + name: 'auth', + initialState, + selectors: { + getIsAuthenticated: (state) => state.isAuthenticated, + }, + reducers: { + signedIn(state, action) { + return merge(state, { isAuthenticated: true }) + }, + signedOut(state, action) { + return merge(state, { isAuthenticated: false }) + }, + }, +}) + +export const { reducer } = authSlice +export const { signedIn, signedOut } = authSlice.actions +export const { getIsAuthenticated } = authSlice.selectors diff --git a/apps/playnite-web/src/api/client/state/index.ts b/apps/playnite-web/src/api/client/state/index.ts index 759f0827f..304dd1827 100644 --- a/apps/playnite-web/src/api/client/state/index.ts +++ b/apps/playnite-web/src/api/client/state/index.ts @@ -1,6 +1,10 @@ import { combineReducers } from '@reduxjs/toolkit' +import * as authSlice from './authSlice' import * as layoutSlice from './layoutSlice' -const reducer = combineReducers({ layout: layoutSlice.reducer }) +const reducer = combineReducers({ + layout: layoutSlice.reducer, + auth: authSlice.reducer, +}) export { reducer } diff --git a/apps/playnite-web/src/api/server/playnite/databases/mongo/client.ts b/apps/playnite-web/src/api/server/playnite/databases/mongo/client.ts index 333d3bbf5..14ceb50d5 100644 --- a/apps/playnite-web/src/api/server/playnite/databases/mongo/client.ts +++ b/apps/playnite-web/src/api/server/playnite/databases/mongo/client.ts @@ -24,6 +24,7 @@ const getDbClient = (connectionOptions?: DbConnectionOptions): MongoClient => { ) if (!username && !password) { + debug(`No username or password provided; connecting without auth`) client = new MongoClient(`mongodb://${host}:${port}`) } else { client = new MongoClient(`mongodb://${host}:${port}`, { diff --git a/apps/playnite-web/src/api/server/playnite/index.ts b/apps/playnite-web/src/api/server/playnite/index.server.ts similarity index 100% rename from apps/playnite-web/src/api/server/playnite/index.ts rename to apps/playnite-web/src/api/server/playnite/index.server.ts diff --git a/apps/playnite-web/src/components/WithNavigation.tsx b/apps/playnite-web/src/components/WithNavigation.tsx index ebcd7e696..df52bafe8 100644 --- a/apps/playnite-web/src/components/WithNavigation.tsx +++ b/apps/playnite-web/src/components/WithNavigation.tsx @@ -1,6 +1,7 @@ import { FC, PropsWithChildren } from 'react' import { useSelector } from 'react-redux' import { styled } from 'styled-components' +import { getIsAuthenticated } from '../api/client/state/authSlice' import { getIsMobile } from '../api/client/state/layoutSlice' const Layout = styled.div<{ mobile: boolean }>` @@ -77,12 +78,16 @@ const WithNavigation: FC = ({ }) => { const isMobile = useSelector(getIsMobile) + const isAuthenticated = useSelector(getIsAuthenticated) + return ( On Deck {Toolbar && } Browse + {!isAuthenticated && Sign In} + {isAuthenticated && Logout} {children} diff --git a/apps/playnite-web/src/root.tsx b/apps/playnite-web/src/root.tsx index fb05e3510..5340010ce 100644 --- a/apps/playnite-web/src/root.tsx +++ b/apps/playnite-web/src/root.tsx @@ -14,7 +14,9 @@ import { FC } from 'react' import { Provider } from 'react-redux' import { createHead } from 'remix-island' import { createGlobalStyle } from 'styled-components' +import { authenticator } from './api/auth/auth.server' import { reducer } from './api/client/state' +import { signedIn, signedOut } from './api/client/state/authSlice' import { layoutDetermined } from './api/client/state/layoutSlice' import inferredLayout from './api/server/layout' @@ -38,10 +40,13 @@ async function loader({ request }: LoaderFunctionArgs) { const isMobile = request.headers.get('user-agent')?.includes('Mobile') + const user = await authenticator.isAuthenticated(request) + return json({ isMobile, gameWidth, gameHeight, + user, }) } @@ -67,14 +72,20 @@ body { ` const App: FC<{}> = () => { - const { isMobile, gameWidth, gameHeight } = useLoaderData<{ + const { isMobile, gameWidth, gameHeight, user } = useLoaderData<{ isMobile: boolean gameWidth: number gameHeight: number + user?: any }>() const store = configureStore({ reducer }) store.dispatch(layoutDetermined({ isMobile, gameWidth, gameHeight })) + if (!!user) { + store.dispatch(signedIn({ payload: null })) + } else { + store.dispatch(signedOut({ payload: null })) + } return ( <> diff --git a/apps/playnite-web/src/routes/_index.tsx b/apps/playnite-web/src/routes/_index.tsx index 50e82109d..6ca48f579 100644 --- a/apps/playnite-web/src/routes/_index.tsx +++ b/apps/playnite-web/src/routes/_index.tsx @@ -4,7 +4,7 @@ import { useLoaderData } from '@remix-run/react' import { useSelector } from 'react-redux' import { styled } from 'styled-components' import { getGameDimensions } from '../api/client/state/layoutSlice' -import PlayniteApi from '../api/server/playnite' +import PlayniteApi from '../api/server/playnite/index.server' import type { Game, Playlist } from '../api/server/playnite/types' import GameList from '../components/GameList.js' import GameListItem from '../components/GameListItem' diff --git a/apps/playnite-web/src/routes/browse.tsx b/apps/playnite-web/src/routes/browse.tsx index e6a917b2f..ff312416e 100644 --- a/apps/playnite-web/src/routes/browse.tsx +++ b/apps/playnite-web/src/routes/browse.tsx @@ -6,15 +6,16 @@ import { useCallback, useEffect, useReducer } from 'react' import { useSelector } from 'react-redux' import useDimensions from 'react-use-dimensions' import { styled } from 'styled-components' +import { authenticator } from '../api/auth/auth.server' import { getGameDimensions } from '../api/client/state/layoutSlice' -import PlayniteApi from '../api/server/playnite' +import PlayniteApi from '../api/server/playnite/index.server' import type { Game } from '../api/server/playnite/types' import GameList from '../components/GameList' import GameListItem from '../components/GameListItem' import Search from '../components/Search' import WithNavigation from '../components/WithNavigation' -const { debounce, merge } = _ +const { debounce } = _ async function loader({ request }: LoaderFunctionArgs) { const api = new PlayniteApi() @@ -32,7 +33,10 @@ async function loader({ request }: LoaderFunctionArgs) { return 0 }) + const user = await authenticator.isAuthenticated(request) + return json({ + user, games, }) } @@ -100,7 +104,10 @@ function Index() { [debouncedSearch, height, ref, search.query], ) - const { games } = useLoaderData() as unknown as { games: Game[] } + const { games } = useLoaderData() as unknown as { + games: Game[] + } + const handleFilter = useCallback( (game: Game) => game.name.toLowerCase().includes(search.query), [search.query], diff --git a/apps/playnite-web/src/routes/coverArt.$oid.tsx b/apps/playnite-web/src/routes/coverArt.$oid.tsx index 9dd9e3b45..56b4131d2 100644 --- a/apps/playnite-web/src/routes/coverArt.$oid.tsx +++ b/apps/playnite-web/src/routes/coverArt.$oid.tsx @@ -1,8 +1,8 @@ import { LoaderFunctionArgs } from '@remix-run/node' import createDebugger from 'debug' import { $params } from 'remix-routes' -import PlayniteApi from '../api/server/playnite' import Oid from '../api/server/playnite/Oid' +import PlayniteApi from '../api/server/playnite/index.server' const debug = createDebugger('playnite-web-app/route/coverArt') diff --git a/apps/playnite-web/src/routes/login.tsx b/apps/playnite-web/src/routes/login.tsx new file mode 100644 index 000000000..28368a53a --- /dev/null +++ b/apps/playnite-web/src/routes/login.tsx @@ -0,0 +1,32 @@ +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node' +import { Form } from '@remix-run/react' +import { authenticator } from '../api/auth/auth.server' + +const LoginForm = () => ( +
+ + + +
+) + +async function action({ request }: ActionFunctionArgs) { + return await authenticator.authenticate('user-pass', request, { + successRedirect: '/', + failureRedirect: '/login', + }) +} + +async function loader({ request }: LoaderFunctionArgs) { + return await authenticator.isAuthenticated(request, { + successRedirect: '/', + }) +} + +export default LoginForm +export { action, loader } diff --git a/apps/playnite-web/src/routes/logout.tsx b/apps/playnite-web/src/routes/logout.tsx new file mode 100644 index 000000000..2000be4fe --- /dev/null +++ b/apps/playnite-web/src/routes/logout.tsx @@ -0,0 +1,10 @@ +import { LoaderFunctionArgs } from '@remix-run/node' +import { authenticator } from '../api/auth/auth.server' + +async function loader({ request }: LoaderFunctionArgs) { + return await authenticator.logout(request, { + redirectTo: '/', + }) +} + +export { loader } diff --git a/yarn.lock b/yarn.lock index c28f7972c..4987d2955 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8747,6 +8747,8 @@ __metadata: react-dom: "npm:^18.2.0" react-redux: "npm:^9.0.4" react-use-dimensions: "npm:^1.2.1" + remix-auth: "npm:^3.6.0" + remix-auth-form: "npm:^1.4.0" remix-esbuild-override: "npm:^3.1.0" remix-island: "npm:^0.1.2" remix-routes: "npm:^1.5.1" @@ -9416,6 +9418,28 @@ __metadata: languageName: node linkType: hard +"remix-auth-form@npm:^1.4.0": + version: 1.4.0 + resolution: "remix-auth-form@npm:1.4.0" + peerDependencies: + "@remix-run/server-runtime": ^1.0.0 || ^2.0.0 + remix-auth: ^3.6.0 + checksum: 3a1c3e9e497d2baa6a278e457559fe5bcb711ed37af79b8dbbc16e6a00872537359ec95dab725307442a5954df27d31c87198d4a13bc4d08a6df82b5bd49fedb + languageName: node + linkType: hard + +"remix-auth@npm:^3.6.0": + version: 3.6.0 + resolution: "remix-auth@npm:3.6.0" + dependencies: + uuid: "npm:^8.3.2" + peerDependencies: + "@remix-run/react": ^1.0.0 || ^2.0.0 + "@remix-run/server-runtime": ^1.0.0 || ^2.0.0 + checksum: 2b9d9f5ad02e7addbf2082d0968b8a23cb7569d68c322b713b35864e21028ceca9d554c36e33f42e7ece04c826a08411e120fdc827595cee3932facd5071fcf5 + languageName: node + linkType: hard + "remix-esbuild-override@npm:^3.1.0": version: 3.1.0 resolution: "remix-esbuild-override@npm:3.1.0"