Implemented invitation logic. Implemented play list method. Mild refactoring.
This commit is contained in:
@@ -24,8 +24,6 @@ COPY ./package.json ./package.json
|
||||
|
||||
# copy production dependencies and source code into final image
|
||||
FROM base AS release
|
||||
ENV DATABASE_URL=postgres://admin:iiyama12@192.168.1.166:5432/bgApp
|
||||
ENV JWT_SECRET_KEY=MySecret
|
||||
COPY --from=install /temp/prod/node_modules node_modules
|
||||
COPY --from=prerelease /usr/src/app/index.ts .
|
||||
COPY --from=prerelease /usr/src/app/utilities/ ./utilities
|
||||
|
||||
@@ -10,13 +10,17 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@react-email/render": "^2.0.4",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.23",
|
||||
"@types/react": "^19.2.14",
|
||||
"argon2": "^0.44.0",
|
||||
"hashids": "^2.3.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.17.23",
|
||||
"reflect-metadata": "^0.2.2"
|
||||
"react": "^19.2.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"resend": "^6.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.9"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
77
src/emails/invite.tsx
Normal file
77
src/emails/invite.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as React from 'react';
|
||||
import { brandColours } from '../utilities/helpers';
|
||||
import { size } from 'lodash';
|
||||
|
||||
interface InviteEmailProperties {
|
||||
playerName: string;
|
||||
inviteCode: string;
|
||||
}
|
||||
|
||||
export const InviteEmail = (props: InviteEmailProperties) => (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: brandColours.light,
|
||||
}}
|
||||
>
|
||||
<table
|
||||
width="100%"
|
||||
border={0}
|
||||
cellSpacing={0}
|
||||
cellPadding={0}
|
||||
>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<div
|
||||
style={{
|
||||
padding: '20px',
|
||||
borderRadius: '20px',
|
||||
background: brandColours.white,
|
||||
margin: '50px',
|
||||
color: brandColours.dark,
|
||||
maxWidth: '450px',
|
||||
}}
|
||||
>
|
||||
<h1>You're in, {props.playerName}!</h1>
|
||||
<p>
|
||||
You've been invited to join {process.env.PRODUCT_NAME}, please click the button below to
|
||||
finish signing up.
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
marginBottom: '40px',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '5px',
|
||||
background: brandColours.primary,
|
||||
textDecoration: 'none',
|
||||
color: brandColours.light,
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
}}
|
||||
href={`${process.env.ROOT_URL}/invitation/${props.inviteCode}`}
|
||||
>
|
||||
Join {process.env.PRODUCT_NAME}
|
||||
</a>
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
opacity: '80%',
|
||||
}}
|
||||
>
|
||||
If above button does not work, copy the link below into a new browser tab:
|
||||
<br />
|
||||
{`${process.env.ROOT_URL}/invitation/${props.inviteCode}`}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
@@ -10,7 +10,7 @@ async function login(request: UnwrappedRequest<LoginRequest>): Promise<Response>
|
||||
const verify: {
|
||||
userId: SecureId;
|
||||
refreshCount: string;
|
||||
} | null = await orm.users.verifyCredentials(request.body.username, request.body.password);
|
||||
} | null = await orm.users.verifyCredentials(request.body.email, request.body.password);
|
||||
if (!verify) {
|
||||
return new UnauthorizedResponse('Invalid credentials');
|
||||
}
|
||||
@@ -32,6 +32,7 @@ async function login(request: UnwrappedRequest<LoginRequest>): Promise<Response>
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
maxAge: tokenLifeSpanInDays * 24 * 60 * 60,
|
||||
path: '/api/auth/token'
|
||||
});
|
||||
return new OkResponse();
|
||||
} catch (error: any) {
|
||||
@@ -60,7 +61,9 @@ async function token(request: UnwrappedRequest): Promise<Response> {
|
||||
|
||||
const claims: Claims | null = await orm.claims.getByUserId(refreshToken.u);
|
||||
|
||||
const token = jwt.sign({ ...claims }, process.env.JWT_SECRET_KEY as string, { expiresIn: '1h' });
|
||||
const token = jwt.sign({ ...claims }, process.env.JWT_SECRET_KEY as string, {
|
||||
expiresIn: process.env.JWT_LIFESPAN as any,
|
||||
});
|
||||
return new OkResponse({ token });
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
|
||||
37
src/endpoints/invite.ts
Normal file
37
src/endpoints/invite.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { orm } from '../orm/orm';
|
||||
import { UnwrappedRequest } from '../utilities/guard';
|
||||
import { CreatedResponse, ErrorResponse } from '../utilities/responseHelper';
|
||||
import {
|
||||
AcceptInviteRequest,
|
||||
InviteUserRequest,
|
||||
SecureId,
|
||||
} from '../utilities/requestModels';
|
||||
|
||||
async function create(request: UnwrappedRequest<InviteUserRequest>): Promise<Response> {
|
||||
try {
|
||||
const newUser = await orm.invites.create(
|
||||
{
|
||||
...request.body,
|
||||
playerId: SecureId.fromHash(request.body.playerId),
|
||||
invitedByUserId: request.claims.userId as SecureId,
|
||||
},
|
||||
);
|
||||
return new CreatedResponse(newUser);
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function accept(request: UnwrappedRequest<AcceptInviteRequest>): Promise<Response> {
|
||||
try {
|
||||
const newUser = await orm.invites.accept(request.body);
|
||||
return new CreatedResponse(newUser);
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
accept,
|
||||
};
|
||||
@@ -20,6 +20,14 @@ async function get(request: UnwrappedRequest): Promise<Response> {
|
||||
}
|
||||
}
|
||||
|
||||
async function list(request: UnwrappedRequest): Promise<Response> {
|
||||
try {
|
||||
return new OkResponse(await orm.players.list(request.claims));
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
async function update(request: UnwrappedRequest<UpdatePlayerRequest>): Promise<Response> {
|
||||
try {
|
||||
return new OkResponse(
|
||||
@@ -41,6 +49,7 @@ async function drop(request: UnwrappedRequest): Promise<Response> {
|
||||
export default {
|
||||
create,
|
||||
get,
|
||||
list,
|
||||
update,
|
||||
drop,
|
||||
};
|
||||
|
||||
@@ -29,11 +29,7 @@ async function get(request: UnwrappedRequest): Promise<Response> {
|
||||
async function update(request: UnwrappedRequest<UpdateUserRequest>): Promise<Response> {
|
||||
try {
|
||||
return new OkResponse(
|
||||
await orm.users.update(
|
||||
SecureId.fromHash(request.params.id),
|
||||
request.body,
|
||||
request.claims,
|
||||
),
|
||||
await orm.users.update(SecureId.fromHash(request.params.id), request.body, request.claims),
|
||||
);
|
||||
} catch (error: any) {
|
||||
return new ErrorResponse(error as Error);
|
||||
|
||||
@@ -3,6 +3,7 @@ import user from './routes/user';
|
||||
import player from './routes/player';
|
||||
import game from './routes/game';
|
||||
import { OkResponse } from './utilities/responseHelper';
|
||||
import invite from './routes/invite';
|
||||
|
||||
const server = Bun.serve({
|
||||
routes: {
|
||||
@@ -10,6 +11,7 @@ const server = Bun.serve({
|
||||
...user,
|
||||
...player,
|
||||
...game,
|
||||
...invite,
|
||||
'/test': {
|
||||
GET: () => {
|
||||
return new OkResponse();
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { sql } from 'bun';
|
||||
import { ClaimDefinition } from '../utilities/claimDefinitions';
|
||||
import { SecureId } from '../utilities/requestModels';
|
||||
|
||||
export class Claims extends ClaimDefinition {
|
||||
userId?: string;
|
||||
userId?: SecureId;
|
||||
claims: string[] = [];
|
||||
|
||||
constructor(raw?:{userId?:string, claims?: string[]}) {
|
||||
super();
|
||||
this.userId = raw?.userId ? SecureId.fromHash(raw.userId) : undefined;
|
||||
this.claims = raw?.claims ?? [];
|
||||
}
|
||||
|
||||
public static test(guardClaim: string, userClaims?: Claims): Boolean {
|
||||
return userClaims === undefined || userClaims.claims.some((x) => x === guardClaim);
|
||||
}
|
||||
@@ -17,7 +24,7 @@ export class ClaimsOrm {
|
||||
JOIN claims as c on uc.claim_id = c.id
|
||||
where uc.user_id = ${userId};`;
|
||||
const claims = new Claims();
|
||||
claims.userId = userId;
|
||||
claims.userId = SecureId.fromID(userId);
|
||||
claims.claims = dbResults.map((x) => x.name);
|
||||
return claims;
|
||||
}
|
||||
|
||||
@@ -20,19 +20,12 @@ export class Game {
|
||||
}
|
||||
|
||||
export class GamesOrm {
|
||||
async create(model: CreateGameRequest, claims?: Claims): Promise<Game | null> {
|
||||
async create(model: CreateGameRequest, claims?: Claims): Promise<Game> {
|
||||
await sql`INSERT INTO games (name, image_path, bgg_id)
|
||||
VALUES (${model.name}, ${Claims.test(Claims.GAMES.MANAGE_IMAGES, claims) ? model.imagePath : null}, ${model.bggId})`;
|
||||
const newGameId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
|
||||
|
||||
try {
|
||||
return await this.get(SecureId.fromID(newGameId));
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async get(id: SecureId): Promise<Game> {
|
||||
@@ -55,7 +48,7 @@ export class GamesOrm {
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: SecureId, patch: UpdateGameRequest, claims?: Claims): Promise<Game | null> {
|
||||
async update(id: SecureId, patch: UpdateGameRequest, claims?: Claims): Promise<Game> {
|
||||
const gameToUpdate = await this.get(id);
|
||||
gameToUpdate.name = patch.name ?? gameToUpdate.name;
|
||||
gameToUpdate.bggId = patch.bggId ?? gameToUpdate.bggId;
|
||||
@@ -70,17 +63,10 @@ export class GamesOrm {
|
||||
image_path=${gameToUpdate.imagePath}
|
||||
WHERE id = ${id.raw}`;
|
||||
|
||||
try {
|
||||
return await this.get(id);
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async drop(id: SecureId): Promise<undefined> {
|
||||
async drop(id: SecureId): Promise<void> {
|
||||
// Ensure player exists before attempting to delete
|
||||
await this.get(id);
|
||||
await sql.transaction(async (tx) => {
|
||||
|
||||
133
src/orm/invites.ts
Normal file
133
src/orm/invites.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { sql } from 'bun';
|
||||
import { first } from 'lodash';
|
||||
import { BadRequestError, InternalServerError, NotFoundError, UnauthorizedError } from '../utilities/errors';
|
||||
import { SecureId } from '../utilities/requestModels';
|
||||
import { createRandomString } from '../utilities/helpers';
|
||||
import { Resend } from 'resend';
|
||||
import { orm } from './orm';
|
||||
import { InviteEmail } from '../emails/invite';
|
||||
import { User } from './user';
|
||||
import { Claims } from './claims';
|
||||
|
||||
export class InvitesOrm {
|
||||
async create(
|
||||
{
|
||||
email,
|
||||
playerId,
|
||||
invitedByUserId,
|
||||
}: {
|
||||
email: string;
|
||||
playerId: SecureId;
|
||||
invitedByUserId: SecureId;
|
||||
},
|
||||
claims?: Claims,
|
||||
): Promise<void> {
|
||||
if (!Claims.test(Claims.ADMIN, claims)) {
|
||||
const userInviteCount = (
|
||||
first(
|
||||
await sql`SELECT COUNT(*) AS count
|
||||
FROM user_invites
|
||||
WHERE invited_by_user_id = ${invitedByUserId.raw}`,
|
||||
) as { count: number }
|
||||
)?.count;
|
||||
|
||||
if (process.env.MAX_INVITE_ALLOWANCE && userInviteCount >= parseInt(process.env.MAX_INVITE_ALLOWANCE)) {
|
||||
throw new UnauthorizedError('Invite allowance reached.');
|
||||
}
|
||||
|
||||
const inviteExists = (
|
||||
first(
|
||||
await sql`SELECT COUNT(*) > 0 AS exists
|
||||
FROM user_invites
|
||||
WHERE player_id = ${playerId.raw}
|
||||
OR email = ${email}`,
|
||||
) as {
|
||||
exists: boolean;
|
||||
}
|
||||
)?.exists;
|
||||
if (inviteExists) {
|
||||
throw new BadRequestError('Player has already been invited.');
|
||||
}
|
||||
}
|
||||
|
||||
const playerHasUser = (
|
||||
first(
|
||||
await sql`SELECT COUNT(*) > 0 AS exists
|
||||
FROM users
|
||||
WHERE player_id = ${playerId.raw}
|
||||
OR email = ${email}`,
|
||||
) as {
|
||||
exists: boolean;
|
||||
}
|
||||
)?.exists;
|
||||
if (playerHasUser) {
|
||||
throw new BadRequestError('User has already been invited.');
|
||||
}
|
||||
|
||||
const player = await orm.players.get(playerId);
|
||||
|
||||
const invitationCode = createRandomString(6);
|
||||
await sql`INSERT INTO user_invites (invite_code, email, player_id, invited_by_user_id)
|
||||
VALUES (${invitationCode}, ${email}, ${playerId.raw}, ${invitedByUserId.raw})`;
|
||||
const newInviteId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
|
||||
|
||||
const resend = new Resend(process.env.RESEND_KEY);
|
||||
const resendResponse = await resend.emails.send({
|
||||
from: `${process.env.PRODUCT_NAME} <noreply@mail.jdar.uk>`,
|
||||
to: [email],
|
||||
subject: "You've been invited!",
|
||||
react: InviteEmail({ playerName: player.name, inviteCode: invitationCode }),
|
||||
});
|
||||
|
||||
if (resendResponse.error) {
|
||||
throw new InternalServerError();
|
||||
}
|
||||
|
||||
await sql`UPDATE user_invites SET was_email_sent = true WHERE id=${newInviteId}`;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async accept({ inviteCode, password }: { inviteCode: string; password: string }): Promise<User> {
|
||||
const invite: {
|
||||
id: string;
|
||||
email: string;
|
||||
player_id: string;
|
||||
accepted: boolean;
|
||||
} = first(await sql`SELECT * FROM user_invites WHERE invite_code=${inviteCode} LIMIT 1`);
|
||||
|
||||
if (!invite) {
|
||||
throw new NotFoundError('Invalid invite code');
|
||||
}
|
||||
|
||||
if (invite.accepted) {
|
||||
throw new UnauthorizedError('Invite already accepted');
|
||||
}
|
||||
|
||||
const playerHasUser = (
|
||||
first(
|
||||
await sql`SELECT COUNT(*) > 0 AS exists
|
||||
FROM users
|
||||
WHERE player_id = ${invite.player_id}
|
||||
OR email = ${invite.email}`,
|
||||
) as {
|
||||
exists: boolean;
|
||||
}
|
||||
)?.exists;
|
||||
if (playerHasUser) {
|
||||
throw new BadRequestError('User has already been invited.');
|
||||
}
|
||||
|
||||
const createdUser = await orm.users.create({
|
||||
email: invite.email,
|
||||
playerId: SecureId.fromID(invite.player_id),
|
||||
password,
|
||||
});
|
||||
|
||||
await sql`UPDATE user_invites
|
||||
SET accepted = true
|
||||
WHERE id = ${invite.id}`;
|
||||
|
||||
return createdUser;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import { ClaimsOrm } from './claims';
|
||||
import { UsersOrm } from './user';
|
||||
import { PlayersOrm } from './players';
|
||||
import { GamesOrm } from './games';
|
||||
import { InvitesOrm } from './invites';
|
||||
|
||||
class Orm {
|
||||
readonly claims: ClaimsOrm = new ClaimsOrm();
|
||||
readonly users: UsersOrm = new UsersOrm();
|
||||
readonly players: PlayersOrm = new PlayersOrm();
|
||||
readonly games: GamesOrm = new GamesOrm();
|
||||
readonly invites: InvitesOrm = new InvitesOrm();
|
||||
}
|
||||
|
||||
export const orm = new Orm();
|
||||
|
||||
@@ -28,26 +28,19 @@ export class Player {
|
||||
}
|
||||
|
||||
export class PlayersOrm {
|
||||
async create(model: { name: string }, claims?: Claims): Promise<Player | null> {
|
||||
async create(model: { name: string }, claims?: Claims): Promise<Player> {
|
||||
await sql`INSERT INTO players (name)
|
||||
VALUES (${model.name})`;
|
||||
const newPlayerId: string = (first(await sql`SELECT lastval();`) as any)?.lastval as string;
|
||||
|
||||
try {
|
||||
return await this.get(SecureId.fromID(newPlayerId), claims);
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return await this.get(SecureId.fromID(newPlayerId));
|
||||
}
|
||||
|
||||
async get(id: SecureId, claims?: Claims): Promise<Player> {
|
||||
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.READ, claims))) {
|
||||
throw new UnauthorizedError();
|
||||
} else if (Claims.test(Claims.PLAYERS.SELF.READ, claims) && claims?.userId) {
|
||||
const user = await orm.users.get(SecureId.fromHash(claims.userId));
|
||||
const user = await orm.users.get(claims.userId);
|
||||
if (id.raw !== user.playerId.raw) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
@@ -66,21 +59,59 @@ export class PlayersOrm {
|
||||
return new Player({
|
||||
id: SecureId.fromID(dbResult.id),
|
||||
name: dbResult.name,
|
||||
elo: dbResult.elo,
|
||||
elo: parseInt(dbResult.elo),
|
||||
isRatingLocked: dbResult.is_rating_locked,
|
||||
canBeMultiple: dbResult.can_be_multiple,
|
||||
});
|
||||
}
|
||||
|
||||
async update(
|
||||
id: SecureId,
|
||||
patch: UpdatePlayerRequest,
|
||||
claims?: Claims,
|
||||
): Promise<Player | null> {
|
||||
async list(claims?: Claims): Promise<Player[]> {
|
||||
if (!claims || Claims.test(Claims.ADMIN, claims)) {
|
||||
return (await sql`SELECT * FROM players`).map(
|
||||
(x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) =>
|
||||
new Player({
|
||||
id: SecureId.fromID(x.id),
|
||||
name: x.name,
|
||||
elo: parseInt(x.elo),
|
||||
isRatingLocked: x.is_rating_locked,
|
||||
canBeMultiple: x.can_be_multiple,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (!Claims.test(Claims.PLAYERS.OTHER.READ, claims)) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
return (
|
||||
await sql`SELECT p.*
|
||||
FROM
|
||||
users u
|
||||
JOIN player_circles upc on upc.player_id = u.id
|
||||
JOIN circles c ON c.id = upc.circle_id
|
||||
JOIN player_circles pc ON pc.circle_id = c.id
|
||||
JOIN players p ON p.id = pc.player_id
|
||||
WHERE
|
||||
u.player_id = ${claims.userId?.raw}
|
||||
AND
|
||||
pc.player_id <> u.player_id`
|
||||
).map(
|
||||
(x: { id: string; name: string; elo: string; is_rating_locked: boolean; can_be_multiple: boolean }) =>
|
||||
new Player({
|
||||
id: SecureId.fromID(x.id),
|
||||
name: x.name,
|
||||
elo: parseInt(x.elo),
|
||||
isRatingLocked: x.is_rating_locked,
|
||||
canBeMultiple: x.can_be_multiple,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async update(id: SecureId, patch: UpdatePlayerRequest, claims?: Claims): Promise<Player> {
|
||||
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.UPDATE, claims))) {
|
||||
throw new UnauthorizedError();
|
||||
} else if (Claims.test(Claims.PLAYERS.SELF.UPDATE, claims) && claims?.userId) {
|
||||
const user = await orm.users.get(SecureId.fromHash(claims.userId));
|
||||
const user = await orm.users.get(claims.userId);
|
||||
if (id.raw !== user.playerId.raw) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
@@ -97,21 +128,14 @@ export class PlayersOrm {
|
||||
can_be_multiple=${playerToUpdate.canBeMultiple}
|
||||
WHERE id = ${id.raw}`;
|
||||
|
||||
try {
|
||||
return await this.get(id, claims);
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return await this.get(id);
|
||||
}
|
||||
|
||||
async drop(id: SecureId, claims?: Claims): Promise<undefined> {
|
||||
async drop(id: SecureId, claims?: Claims): Promise<void> {
|
||||
if (!(Claims.test(Claims.ADMIN, claims) || Claims.test(Claims.PLAYERS.OTHER.DELETE, claims))) {
|
||||
throw new UnauthorizedError();
|
||||
} else if (Claims.test(Claims.PLAYERS.SELF.DELETE, claims) && claims?.userId) {
|
||||
const user = await orm.users.get(SecureId.fromHash(claims.userId));
|
||||
const user = await orm.users.get(claims.userId);
|
||||
if (id.raw !== user.playerId.raw) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
@@ -9,14 +9,14 @@ import { orm } from './orm';
|
||||
export class User {
|
||||
id: SecureId;
|
||||
playerId: SecureId;
|
||||
name: string;
|
||||
email: string;
|
||||
isAdmin: boolean;
|
||||
isActive: boolean;
|
||||
|
||||
constructor(id: SecureId, playerId: SecureId, name: string, isAdmin: boolean = false, isActive: boolean = true) {
|
||||
constructor(id: SecureId, playerId: SecureId, email: string, isAdmin: boolean = false, isActive: boolean = true) {
|
||||
this.id = id;
|
||||
this.playerId = playerId;
|
||||
this.name = name;
|
||||
this.email = email;
|
||||
this.isAdmin = isAdmin;
|
||||
this.isActive = isActive;
|
||||
}
|
||||
@@ -24,23 +24,23 @@ export class User {
|
||||
|
||||
export class UsersOrm {
|
||||
async create(
|
||||
model: { username: string; password: string; playerId: SecureId },
|
||||
{ email, password, playerId }: { email: string; password: string; playerId: SecureId },
|
||||
claims?: Claims,
|
||||
): Promise<User | null> {
|
||||
): Promise<User> {
|
||||
const existingUser: any = first(
|
||||
await sql`SELECT id
|
||||
FROM users
|
||||
WHERE username = ${model.username}
|
||||
WHERE email = ${email} OR player_id = ${playerId.raw}
|
||||
LIMIT 1`,
|
||||
);
|
||||
if (existingUser) {
|
||||
throw new BadRequestError(`User ${model.username} already exists`);
|
||||
throw new BadRequestError(`User or player already exists`);
|
||||
}
|
||||
|
||||
const defaultClaims: number[] = await orm.claims.getDefaultClaims();
|
||||
const passwordHash = await argon2.hash(model.password);
|
||||
await sql`INSERT INTO users (username, pass_hash, player_id)
|
||||
VALUES (${model.username}, ${passwordHash}, ${model.playerId.raw})`;
|
||||
const passwordHash = await argon2.hash(password);
|
||||
await sql`INSERT INTO users (email, pass_hash, player_id)
|
||||
VALUES (${email}, ${passwordHash}, ${playerId.raw})`;
|
||||
const newUserId: SecureId = SecureId.fromID((first(await sql`SELECT lastval();`) as any)?.lastval as string);
|
||||
await sql.transaction(async (tx) => {
|
||||
for (let i in defaultClaims) {
|
||||
@@ -49,14 +49,7 @@ export class UsersOrm {
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
return await this.get(newUserId, claims);
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return await this.get(newUserId);
|
||||
}
|
||||
|
||||
async get(id: SecureId, claims?: Claims): Promise<User> {
|
||||
@@ -64,7 +57,7 @@ export class UsersOrm {
|
||||
!(
|
||||
Claims.test(Claims.ADMIN, claims) ||
|
||||
Claims.test(Claims.USERS.OTHER.READ, claims) ||
|
||||
(Claims.test(Claims.USERS.SELF.READ, claims) && id.raw === claims?.userId)
|
||||
(Claims.test(Claims.USERS.SELF.READ, claims) && id === claims?.userId)
|
||||
)
|
||||
) {
|
||||
throw new UnauthorizedError();
|
||||
@@ -85,21 +78,17 @@ export class UsersOrm {
|
||||
return new User(
|
||||
SecureId.fromID(dbResult.id),
|
||||
SecureId.fromID(dbResult.player_id),
|
||||
dbResult.username,
|
||||
dbResult.email,
|
||||
dbResult.is_admin,
|
||||
);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: SecureId,
|
||||
patch: UpdateUserRequest,
|
||||
claims?: Claims,
|
||||
): Promise<User | null> {
|
||||
async update(id: SecureId, patch: UpdateUserRequest, claims?: Claims): Promise<User> {
|
||||
if (
|
||||
!(
|
||||
Claims.test(Claims.ADMIN, claims) ||
|
||||
Claims.test(Claims.USERS.OTHER.UPDATE, claims) ||
|
||||
(Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId)
|
||||
(Claims.test(Claims.USERS.SELF.UPDATE, claims) && id === claims?.userId)
|
||||
)
|
||||
) {
|
||||
throw new UnauthorizedError();
|
||||
@@ -116,22 +105,15 @@ export class UsersOrm {
|
||||
is_admin=${userToUpdate.isAdmin}
|
||||
WHERE id = ${id.raw}`;
|
||||
|
||||
try {
|
||||
return await this.get(id, claims);
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return await this.get(id);
|
||||
}
|
||||
|
||||
async drop(id: SecureId, claims?: Claims): Promise<User | null> {
|
||||
async drop(id: SecureId, claims?: Claims): Promise<void> {
|
||||
if (
|
||||
!(
|
||||
Claims.test(Claims.ADMIN, claims) ||
|
||||
Claims.test(Claims.USERS.OTHER.DELETE, claims) ||
|
||||
(Claims.test(Claims.USERS.SELF.DELETE, claims) && id.raw === claims?.userId)
|
||||
(Claims.test(Claims.USERS.SELF.DELETE, claims) && id === claims?.userId)
|
||||
)
|
||||
) {
|
||||
throw new UnauthorizedError();
|
||||
@@ -148,17 +130,17 @@ export class UsersOrm {
|
||||
WHERE id = ${id.raw}`;
|
||||
});
|
||||
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
async verifyCredentials(
|
||||
username: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ userId: SecureId; refreshCount: string } | null> {
|
||||
const dbResult: any = first(
|
||||
await sql`SELECT *
|
||||
FROM users
|
||||
WHERE username = ${username}
|
||||
WHERE email = ${email}
|
||||
AND is_active = true
|
||||
limit 1`,
|
||||
);
|
||||
@@ -183,7 +165,6 @@ export class UsersOrm {
|
||||
WHERE id = ${id.raw}
|
||||
LIMIT 1`,
|
||||
);
|
||||
console.log(dbResult.refresh_count, refreshCount);
|
||||
return dbResult.refresh_count === refreshCount;
|
||||
}
|
||||
|
||||
@@ -192,9 +173,9 @@ export class UsersOrm {
|
||||
oldPassword: string | null,
|
||||
newPassword: string,
|
||||
claims?: Claims,
|
||||
): Promise<undefined> {
|
||||
): Promise<void> {
|
||||
const isAdmin = Claims.test(Claims.ADMIN, claims);
|
||||
if (!(isAdmin || (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id.raw === claims?.userId))) {
|
||||
if (!(isAdmin || (Claims.test(Claims.USERS.SELF.UPDATE, claims) && id === claims?.userId))) {
|
||||
throw new UnauthorizedError();
|
||||
}
|
||||
|
||||
|
||||
12
src/routes/invite.ts
Normal file
12
src/routes/invite.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { guard, unwrapMethod } from '../utilities/guard';
|
||||
import { Claims } from '../orm/claims';
|
||||
import invite from '../endpoints/invite';
|
||||
|
||||
export default {
|
||||
'/api/invite': {
|
||||
POST: guard(invite.create, [Claims.ADMIN, Claims.USERS.INVITE]),
|
||||
},
|
||||
'/api/invite/accept': {
|
||||
POST: unwrapMethod(invite.accept),
|
||||
},
|
||||
};
|
||||
@@ -11,4 +11,7 @@ export default {
|
||||
PATCH: guard(player.update, [Claims.ADMIN, Claims.PLAYERS.OTHER.UPDATE, Claims.PLAYERS.SELF.UPDATE]),
|
||||
DELETE: guard(player.drop, [Claims.ADMIN, Claims.PLAYERS.OTHER.DELETE, Claims.PLAYERS.SELF.DELETE]),
|
||||
},
|
||||
'/api/player/list': {
|
||||
GET: guard(player.list, [Claims.ADMIN, Claims.PLAYERS.OTHER.READ]),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { guard } from '../utilities/guard';
|
||||
import { guard, unwrap, unwrapMethod } from '../utilities/guard';
|
||||
import user from '../endpoints/user';
|
||||
import { Claims } from '../orm/claims';
|
||||
|
||||
@@ -6,6 +6,12 @@ export default {
|
||||
'/api/user': {
|
||||
POST: guard(user.create, [Claims.ADMIN, Claims.USERS.CREATE]),
|
||||
},
|
||||
'/api/user/invite': {
|
||||
POST: guard(user.invite, [Claims.ADMIN, Claims.USERS.INVITE]),
|
||||
},
|
||||
'/api/user/invite/accept': {
|
||||
POST: unwrapMethod(user.acceptInvite),
|
||||
},
|
||||
'/api/user/:id': {
|
||||
GET: guard(user.get, [Claims.ADMIN, Claims.USERS.OTHER.READ, Claims.USERS.SELF.READ]),
|
||||
PATCH: guard(user.update, [Claims.ADMIN, Claims.USERS.OTHER.UPDATE, Claims.USERS.SELF.UPDATE]),
|
||||
|
||||
@@ -2,6 +2,7 @@ export class ClaimDefinition {
|
||||
public static readonly ADMIN = 'ADMIN';
|
||||
public static readonly USERS = {
|
||||
CREATE: 'USERS_CREATE',
|
||||
INVITE: 'USERS_INVITE',
|
||||
SELF: {
|
||||
READ: 'USERS_SELF_READ',
|
||||
UPDATE: 'USERS_SELF_UPDATE',
|
||||
|
||||
@@ -4,6 +4,12 @@ export class BadRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerError extends Error {
|
||||
constructor(message?: string | undefined) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(message?: string | undefined) {
|
||||
super(message);
|
||||
|
||||
@@ -26,12 +26,13 @@ export function guard(
|
||||
const authHeader: string | null =
|
||||
(request.headers.get('Authorization')?.replace(/^Bearer /, '') as string) ?? null;
|
||||
try {
|
||||
const userClaims: Claims = jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as Claims;
|
||||
const userClaims: Claims = new Claims(jwt.verify(authHeader as string, process.env.JWT_SECRET_KEY as string) as any);
|
||||
if (!userClaims.claims.some((x: string): boolean => guardedClaims.includes(x))) {
|
||||
return new UnauthorizedResponse('Unauthorized');
|
||||
}
|
||||
return method(await unwrap(request, userClaims));
|
||||
} catch (error: any) {
|
||||
console.log(error)
|
||||
if (error instanceof TokenExpiredError) {
|
||||
return new UnauthorizedResponse(error.message);
|
||||
}
|
||||
|
||||
@@ -18,3 +18,20 @@ export function memo<T extends (...args: any[]) => {}, S>(
|
||||
return cache[key].value;
|
||||
}) as unknown as T;
|
||||
}
|
||||
|
||||
export function createRandomString(length:number = 6):string {
|
||||
const maxRandStringVal = parseInt(''.padEnd(length, 'z'), 36);
|
||||
return Math.floor(Math.random() * maxRandStringVal).toString(36).toUpperCase();
|
||||
}
|
||||
|
||||
export const brandColours = {
|
||||
dark: '#14111C',
|
||||
mid: '#CBCACB',
|
||||
light: '#FBF8FC',
|
||||
white: '#FFFFFF',
|
||||
black: '#000000',
|
||||
primary: '#CA00E7',
|
||||
secondary: '#FFB527',
|
||||
tertiary: '#6ED500',
|
||||
danger: '#CA3211',
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { hashIds } from './guard';
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
export interface ChangePasswordRequest {
|
||||
@@ -9,7 +9,7 @@ export interface ChangePasswordRequest {
|
||||
newPassword: string;
|
||||
}
|
||||
export interface CreateUserRequest {
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
playerId: string;
|
||||
}
|
||||
@@ -17,13 +17,21 @@ export interface UpdateUserRequest {
|
||||
isActive?: boolean;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
export interface InviteUserRequest {
|
||||
email: string;
|
||||
playerId: string;
|
||||
}
|
||||
export interface AcceptInviteRequest {
|
||||
inviteCode: string;
|
||||
password: string;
|
||||
}
|
||||
export interface CreatePlayerRequest {
|
||||
name: string;
|
||||
}
|
||||
export interface UpdatePlayerRequest {
|
||||
name?: string;
|
||||
isRatingLocked?:boolean;
|
||||
canBeMultiple?:boolean;
|
||||
isRatingLocked?: boolean;
|
||||
canBeMultiple?: boolean;
|
||||
}
|
||||
export interface CreateGameRequest {
|
||||
name: string;
|
||||
@@ -65,6 +73,10 @@ export class SecureId {
|
||||
return this.#hashedValue;
|
||||
}
|
||||
|
||||
valueOf(): string | undefined {
|
||||
return this.#secureValue;
|
||||
}
|
||||
|
||||
public static fromHash(hash: string) {
|
||||
return new SecureId({ public: hash });
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user