Skip to content

Commit

Permalink
feat(api-server, openapi, website): implement login UI (#20)
Browse files Browse the repository at this point in the history
- API: rename and modify /user routes (formerly /login)
- api-server: add GET /user route to provide FE with data for logged-in
user
- Allow user to create user and login from website
- Remove Mirage, too much trouble now that api server exists, doesn't
play well with nextjs App Router without workarounds: miragejs/miragejs#1088
  • Loading branch information
IanLondon committed Feb 4, 2024
1 parent 888f34b commit e6a7f7f
Show file tree
Hide file tree
Showing 16 changed files with 417 additions and 68 deletions.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,18 @@ There is also `local-dev-reverse-proxy` — an Dockerized NGINX reverse proxy se

### TL;DR

#### Postgres Prerequisites

As a prerequisite, you need to:

- Run a local `postgres` server, eg `docker run --name some-postgres -e POSTGRES_PASSWORD=some_password -p 5432:5432 postgres`
- Create a `api-server/.env` with connection info, including that password
- Run migrations on the server with `knex`.

For these steps, see `api-server/README.md` for specific instructions.

#### Running the application locally

To run everything locally, run these on separate terminal tabs:

1. `cd api-server; npm run dev`
Expand Down
4 changes: 2 additions & 2 deletions api-server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import encounterCardRouter from './routes/encounter-cards'
import expeditionsRouter from './routes/expeditions'
import gameStateRouter from './routes/game-state'
import healthRouter from './routes/health'
import loginRouter from './routes/login'
import userRouter from './routes/user'
import rootRouter from './routes/root'
if (process.env.NODE_ENV !== 'production') {
console.log('Loading .env file into "process.env"')
Expand Down Expand Up @@ -38,7 +38,7 @@ app.use('/api/drifter-cards', drifterCardRouter)
app.use('/api/encounter-cards', encounterCardRouter)
app.use('/api/expeditions', expeditionsRouter)
app.use('/api/game-state', gameStateRouter)
app.use('/api/login', loginRouter)
app.use('/api/user', userRouter)
app.use('*', (req, res) => {
res
.status(404)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,39 +1,22 @@
import { describe, expect, it, jest } from '@jest/globals'
import type { NextFunction } from 'express'
import httpMocks from 'node-mocks-http'
import { i18n } from '../constants'
import { createUserHandler, footestHandler, loginUserHandler } from './login'
import { createUserHandler, getUserDataHander, loginUserHandler } from './user'

import { createUser, getUserWithCredentials } from '../controllers/users'
import { type UsersTableRow } from '../queries/users'
import { getUserByUid, type UsersTableRow } from '../queries/users'

jest.mock('../controllers/users')
jest.mock('../queries/users')

const createUserMock = createUser as jest.Mock<typeof createUser>
const getUserWithCredentialsMock = getUserWithCredentials as jest.Mock<
typeof getUserWithCredentials
>

describe('/login', () => {
// TODO IMMEDIATELY REMOVE
describe('POST /footest', () => {
it('should 201, echo and say "yeah boi"', () => {
const req = httpMocks.createRequest({
method: 'POST',
body: { echo: 'echoThis123' }
})
const res = httpMocks.createResponse()
const next: NextFunction = jest.fn()

footestHandler(req, res, next)

expect(res.statusCode).toEqual(201)
expect(res._getJSONData()).toEqual({
echo: 'echoThis123',
result: 'yeah boi'
})
})
})
const getUserByUidMock = getUserByUid as jest.Mock<typeof getUserByUid>

describe('/user', () => {
describe('POST /user', () => {
it('should create a new user with valid input', async () => {
const req = httpMocks.createRequest({
Expand Down Expand Up @@ -104,9 +87,54 @@ describe('/login', () => {
})
expect(res.statusCode).toEqual(401)
})

describe('GET /user', () => {
it('should give 401 error when the given user does not exist', async () => {
const uid = '1234'
getUserByUidMock.mockReturnValue(Promise.resolve(null))

const req = httpMocks.createRequest({
method: 'GET',
session: { uid }
})
const res = httpMocks.createResponse()
const next = jest.fn()

await getUserDataHander(req, res, next)

expect(next).not.toHaveBeenCalled()

expect(res._isEndCalled()).toBe(true)
expect(res.statusCode).toEqual(401)
})
it('should give user data if the session is valid', async () => {
const uid = '1234'
const username = 'alice'
const passhash = 'blah'
getUserByUidMock.mockReturnValue(
Promise.resolve({ uid, username, passhash })
)

const req = httpMocks.createRequest({
method: 'GET',
session: { uid }
})
const res = httpMocks.createResponse()
const next = jest.fn()

await getUserDataHander(req, res, next)

expect(next).not.toHaveBeenCalled()

expect(res._isEndCalled()).toBe(true)
expect(res._isJSON()).toBe(true)
expect(res._getJSONData()).toEqual({ username })
expect(res.statusCode).toEqual(200)
})
})
})

describe('POST /login', () => {
describe('POST /user/login', () => {
it('should give error with missing params', async () => {
const req = httpMocks.createRequest({
method: 'POST',
Expand Down
23 changes: 11 additions & 12 deletions api-server/src/routes/login.ts → api-server/src/routes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,25 +17,24 @@ type AsyncRequestHandler = (

const router = express.Router()

// TODO IMMEDIATELY remove. This is an example of an authenticated route
router.get('/private-example', (async (req, res, next): Promise<void> => {
// Get user data for logged-in user
export const getUserDataHander: AsyncRequestHandler = async (
req,
res,
next
) => {
const { uid } = req.session
if (uid !== undefined) {
const user = await getUserByUid(uid)
if (user !== null) {
res.send(`Hi ${user.username}. Your uid is ${uid}`)
return res.json({ username: user.username })
}
} else {
res.sendStatus(401)
}
}) as RequestHandler)

// TODO IMMEDIATELY REMOVE
export const footestHandler: RequestHandler = (req, res, next) => {
const echo: string | undefined = req.body.echo
res.status(201).json({ result: 'yeah boi', echo })
res.sendStatus(401)
}
router.post('/footest', footestHandler)

router.get('/', getUserDataHander as RequestHandler)

// Create new user
export const createUserHandler: AsyncRequestHandler = async (
Expand Down Expand Up @@ -63,7 +62,7 @@ export const createUserHandler: AsyncRequestHandler = async (
}
}

router.post('/user', createUserHandler as RequestHandler)
router.post('/', createUserHandler as RequestHandler)

// Login existing user
export const loginUserHandler: AsyncRequestHandler = async (req, res, next) => {
Expand Down
29 changes: 26 additions & 3 deletions common/openapi-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,19 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ExpeditionUpdate'
/login/user:
/user:
get:
description: Get data about logged-in user
security: []
responses:
'200':
description: data about the user
content:
application/json:
schema:
$ref: '#/components/schemas/UserData'
'401':
description: Error. Not logged in.
post:
description: Create a new user
security: []
Expand All @@ -144,12 +156,16 @@ paths:
description: Error. User already exists
'401':
description: Error. Incorrect parameters.
/login/login:
/user/login:
post:
description: Log in an existing user account
security: []
requestBody:
$ref: '#/paths/~1login~1user/post/requestBody'
description: A username and password
content:
application/json:
schema:
$ref: '#/components/schemas/UserLogin'
responses:
'200':
description: Successful login, obtain a session cookie to be used in subsequent requests
Expand Down Expand Up @@ -410,6 +426,13 @@ components:
type: string
required: [username, password]

UserData:
type: object
properties:
username:
type: string
required: [username]

# JSON Patch (RFC 6902)
# Thanks to Jamie Tanna for the OpenAPI schema
# https://www.jvt.me/posts/2022/05/29/openapi-json-patch/
Expand Down
16 changes: 16 additions & 0 deletions common/src/openapi-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ export interface paths {
};
};
"/login/user": {
/** @description Get data about logged-in user */
get: {
responses: {
/** @description data about the user */
200: {
content: never;
};
/** @description Error. Not logged in. */
401: {
content: never;
};
};
};
/** @description Create a new user */
post: {
/** @description A username and password */
Expand Down Expand Up @@ -311,6 +324,9 @@ export interface components {
username: string;
password: string;
};
UserData: {
username: string;
};
PatchRequest: (components["schemas"]["JSONPatchRequestAddReplaceTest"] | components["schemas"]["JSONPatchRequestRemove"] | components["schemas"]["JSONPatchRequestMoveCopy"])[];
JSONPatchRequestAddReplaceTest: {
/** @description A JSON Pointer path. */
Expand Down
2 changes: 2 additions & 0 deletions common/src/openapiTypes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,6 @@ export type SkillCheckRoll = components['schemas']['SkillCheckRoll']

export type StatNumber = components['schemas']['StatNumber']

export type UserData = components['schemas']['UserData']

export type UserLogin = components['schemas']['UserLogin']
12 changes: 0 additions & 12 deletions website/README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,5 @@
# Developing

## Local development standalone with Mirage server

[Mirage JS](https://miragejs.com/) mocks the API calls, so you can

`npm run mirage`

The server runs on port 8081

## Local development with local API server

See [top-level README](../README.md)

# Known Issues

- Mirage breaks hot reloading. See `mirage.ts`
2 changes: 0 additions & 2 deletions website/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
"private": true,
"scripts": {
"dev": "next dev -p 8081",
"mirage": "cross-env NEXT_PUBLIC_USE_MIRAGE_SERVER=1 next dev -p 8081",
"build": "next build",
"start": "next start -p 8081",
"check": "tsc --noEmit",
Expand Down Expand Up @@ -51,7 +50,6 @@
"eslint-plugin-tailwindcss": "^3.13.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"miragejs": "^0.1.48",
"postcss": "^8",
"prettier": "^3.1.1",
"prettier-plugin-tailwindcss": "^0.5.9",
Expand Down
6 changes: 5 additions & 1 deletion website/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { ConnectedLoginOrCreate } from '../components/LoginForm'
import { ConnectedPlayButton } from '../components/PlayButton'

export default function Home(): React.ReactNode {
return (
<main className='flex min-h-screen flex-col items-center justify-between p-24'>
<h1>Solarpunk Drifters</h1>

<article>A post-postapocalyptic cooperative game</article>

<a href='/play'>PLAY</a>
<ConnectedLoginOrCreate />
<ConnectedPlayButton />
<a href='/about'>About</a>
</main>
)
Expand Down
9 changes: 0 additions & 9 deletions website/src/app/play/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,11 @@ import {
useDispatch,
useSelector
} from '@/lib/redux'
import { makeMirageServer } from '@/mirage'
import { useBeginExpedition } from '@/lib/playerMoveHooks'
import GameActiveEncounter from './GameActiveEncounter'
import GameBetweenEncounters from './GameBetweenEncounters'
import GameLoadout from './GameLoadout'

console.log(`NODE_ENV: ${process.env.NODE_ENV}`)

if (process.env.NEXT_PUBLIC_USE_MIRAGE_SERVER === '1') {
makeMirageServer({ environment: 'development' })
} else {
console.log('NO MIRAGE')
}

export default function PlayPage(): React.ReactNode {
const dispatch = useDispatch()
const gameMode = useSelector(selectGameMode)
Expand Down
2 changes: 2 additions & 0 deletions website/src/app/serverRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ export const drifterCardUrl = (drifterCardId: string): string =>
`drifter-cards/${drifterCardId}`
export const encounterCardUrl = (encounterCardId: string): string =>
`encounter-cards/${encounterCardId}`
export const userUrl = 'user'
export const loginUrl = 'user/login'
Loading

0 comments on commit e6a7f7f

Please sign in to comment.