Backend support for user devices.
This commit is contained in:
parent
4d7f60236f
commit
3d01aed1c5
12 changed files with 248 additions and 9 deletions
src
db
models
repos
routes
|
@ -207,9 +207,9 @@ async function createUserSessionsTable(query: { context: any }) {
|
|||
async function createUserDevicesTable(query: { context: any }) {
|
||||
await query.context.createTable($db.tableName('user_devices'), {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
defaultValue: () => uuidv4(),
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
import { DataTypes, } from 'sequelize';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
function UserDevice(): Record<string, any> {
|
||||
return {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
defaultValue: () => uuidv4(),
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.INTEGER,
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Op } from 'sequelize';
|
|||
import { Optional } from '~/types';
|
||||
import Role from './Role';
|
||||
import RoleName from './RoleName';
|
||||
import UserDevice from './UserDevice';
|
||||
|
||||
class User {
|
||||
public id: number;
|
||||
|
@ -50,6 +51,16 @@ class User {
|
|||
).map((role) => new Role(role.dataValues));
|
||||
}
|
||||
|
||||
public async devices(): Promise<UserDevice[]> {
|
||||
return (
|
||||
await $db.UserDevice().findAll({
|
||||
where: {
|
||||
userId: this.id,
|
||||
},
|
||||
})
|
||||
).map((device) => new UserDevice(device.dataValues));
|
||||
}
|
||||
|
||||
public async setRoles(roles: (Role | RoleName | number | string)[]): Promise<void> {
|
||||
const inputRoleIds = new Set(
|
||||
roles.filter((role) => {
|
||||
|
|
48
src/models/UserDevice.ts
Normal file
48
src/models/UserDevice.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { Optional } from '~/types';
|
||||
|
||||
class UserDevice {
|
||||
public id: string;
|
||||
public userId: number;
|
||||
public name: string;
|
||||
public createdAt: Optional<Date>;
|
||||
|
||||
constructor({
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
createdAt = null,
|
||||
}: {
|
||||
id: string;
|
||||
userId: number;
|
||||
name: string;
|
||||
createdAt?: Optional<Date>;
|
||||
}) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.userId = userId;
|
||||
this.createdAt = createdAt ? new Date(createdAt) : null;
|
||||
}
|
||||
|
||||
public async destroy() {
|
||||
await $db.UserDevice().destroy({
|
||||
where: {
|
||||
id: this.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
public async save() {
|
||||
await $db.UserDevice().update(
|
||||
{
|
||||
name: this.name,
|
||||
},
|
||||
{
|
||||
where: {
|
||||
id: this.id,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserDevice;
|
|
@ -5,17 +5,20 @@ import { Optional } from '~/types';
|
|||
class UserSession {
|
||||
public id: string;
|
||||
public userId: number;
|
||||
public name?: Optional<string>;
|
||||
public expiresAt: Optional<Date>;
|
||||
public createdAt: Optional<Date>;
|
||||
|
||||
constructor({
|
||||
id,
|
||||
userId,
|
||||
name,
|
||||
expiresAt = null,
|
||||
createdAt = null,
|
||||
}: any) {
|
||||
this.id = id;
|
||||
this.userId = userId;
|
||||
this.name = name;
|
||||
this.expiresAt = expiresAt;
|
||||
this.createdAt = createdAt;
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ import GPSPoint from "./GPSPoint";
|
|||
import Role from "./Role";
|
||||
import RoleName from "./RoleName";
|
||||
import User from "./User";
|
||||
import UserDevice from "./UserDevice";
|
||||
import UserSession from "./UserSession";
|
||||
|
||||
export {
|
||||
|
@ -9,5 +10,6 @@ export {
|
|||
Role,
|
||||
RoleName,
|
||||
User,
|
||||
UserDevice,
|
||||
UserSession,
|
||||
};
|
||||
|
|
32
src/repos/UserDevices.ts
Normal file
32
src/repos/UserDevices.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { Optional } from '~/types';
|
||||
import { UserDevice } from '../models';
|
||||
|
||||
class UserDevices {
|
||||
public async get(deviceId: string): Promise<Optional<UserDevice>> {
|
||||
let dbDevice = await $db.UserDevice().findByPk(deviceId)
|
||||
if (!dbDevice) {
|
||||
dbDevice = await $db.UserDevice().findOne({
|
||||
where: { name: deviceId }
|
||||
});
|
||||
|
||||
if (!dbDevice) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return new UserDevice(dbDevice.dataValues);
|
||||
}
|
||||
|
||||
public async create(name: string, args: {
|
||||
userId: number,
|
||||
}): Promise<UserDevice> {
|
||||
const device = await $db.UserDevice().create({
|
||||
userId: args.userId,
|
||||
name: name,
|
||||
});
|
||||
|
||||
return new UserDevice(device.dataValues);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserDevices;
|
|
@ -1,17 +1,20 @@
|
|||
import Location from './Location';
|
||||
import Users from './Users';
|
||||
import UserDevices from './UserDevices';
|
||||
import UserRoles from './UserRoles';
|
||||
import UserSessions from './UserSessions';
|
||||
|
||||
class Repositories {
|
||||
public location: Location;
|
||||
public users: Users;
|
||||
public userDevices: UserDevices;
|
||||
public userRoles: UserRoles;
|
||||
public userSessions: UserSessions;
|
||||
|
||||
constructor() {
|
||||
this.location = new Location();
|
||||
this.users = new Users();
|
||||
this.userDevices = new UserDevices();
|
||||
this.userRoles = new UserRoles();
|
||||
this.userSessions = new UserSessions();
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { Express, Request, Response } from 'express';
|
||||
import { UniqueConstraintError, ValidationError } from 'sequelize';
|
||||
|
||||
import {
|
||||
BadRequest,
|
||||
|
@ -50,7 +51,14 @@ abstract class Route {
|
|||
return;
|
||||
}
|
||||
|
||||
if (error instanceof BadRequest) {
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
res.status(400).json({
|
||||
error: `A record with the same [${Object.values(error.fields).join(', ')}] already exists`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof BadRequest || error instanceof ValidationError) {
|
||||
res.status(400).json({
|
||||
error: error.message,
|
||||
});
|
||||
|
@ -66,17 +74,18 @@ abstract class Route {
|
|||
handlerName: string,
|
||||
) => {
|
||||
logRequest(req);
|
||||
// @ts-expect-error
|
||||
const handler = (this[handlerName]) as ((req: Request, res: Response, auth: AuthInfo) => Promise<void>);
|
||||
const preRequestHandler = (<typeof Route> this.constructor).preRequestHandlers[handlerName];
|
||||
|
||||
try {
|
||||
// @ts-expect-error
|
||||
const handler = (this[handlerName]) as ((req: Request, res: Response, auth: AuthInfo) => Promise<void>);
|
||||
const preRequestHandler = (<typeof Route> this.constructor).preRequestHandlers[handlerName];
|
||||
|
||||
let authInfo: Optional<AuthInfo>
|
||||
if (preRequestHandler) {
|
||||
authInfo = await preRequestHandler(req, res);
|
||||
}
|
||||
|
||||
handler(req, res, authInfo!);
|
||||
await handler(req, res, authInfo!);
|
||||
} catch (error) {
|
||||
(<typeof Route> this.constructor).handleError(req, res, error);
|
||||
}
|
||||
|
|
47
src/routes/api/v1/Devices.ts
Normal file
47
src/routes/api/v1/Devices.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { Optional } from '../../../types';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import ApiV1Route from './Route';
|
||||
import { AuthInfo, authenticate } from '../../../auth';
|
||||
import { BadRequest } from '../../../errors';
|
||||
|
||||
class Devices extends ApiV1Route {
|
||||
constructor() {
|
||||
super('/devices');
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /devices
|
||||
*
|
||||
* It returns a JSON object of devices associated with the authenticated user,
|
||||
* in the format `{ devices: UserDevice[] }`.
|
||||
*/
|
||||
@authenticate()
|
||||
get = async (_: Request, res: Response, auth: Optional<AuthInfo>) => {
|
||||
res.json({
|
||||
devices: (await auth!.user.devices()),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /devices
|
||||
*
|
||||
* It creates a new device associated with the authenticated user.
|
||||
* It expects a JSON object with the following properties:
|
||||
* - `name` (string): The name of the device.
|
||||
* It must be unique for the user.
|
||||
*/
|
||||
@authenticate()
|
||||
post = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
|
||||
const { name } = req.body;
|
||||
|
||||
if (!name) {
|
||||
throw new BadRequest('Missing name');
|
||||
}
|
||||
|
||||
const device = await $repos.userDevices.create(name, { userId: auth!.user.id });
|
||||
res.json(device);
|
||||
}
|
||||
}
|
||||
|
||||
export default Devices;
|
79
src/routes/api/v1/DevicesById.ts
Normal file
79
src/routes/api/v1/DevicesById.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
import { Optional } from '../../../types';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
import ApiV1Route from './Route';
|
||||
import { AuthInfo, authenticate } from '../../../auth';
|
||||
import { Forbidden, Unauthorized } from '../../../errors';
|
||||
import { RoleName, UserDevice } from '../../../models';
|
||||
|
||||
class DevicesById extends ApiV1Route {
|
||||
constructor() {
|
||||
super('/devices/:deviceId');
|
||||
}
|
||||
|
||||
private async authenticatedFetch(auth: AuthInfo, deviceId: string): Promise<UserDevice> {
|
||||
const device = await $repos.userDevices.get(deviceId);
|
||||
|
||||
if (!device) {
|
||||
// Throw a 403 instead of a 404 to avoid leaking information
|
||||
throw new Forbidden('You do not have access to this device');
|
||||
}
|
||||
|
||||
if (device.userId !== auth!.user.id) {
|
||||
try {
|
||||
authenticate([RoleName.Admin]);
|
||||
} catch (e) {
|
||||
throw new Unauthorized('You do not have access to this device');
|
||||
}
|
||||
}
|
||||
|
||||
return device;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /devices/:deviceId
|
||||
*
|
||||
* It returns a JSON object with the requested device.
|
||||
* Note that the device must be associated with the authenticated user,
|
||||
* unless the user is an admin.
|
||||
*/
|
||||
@authenticate()
|
||||
get = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
|
||||
res.json(await this.authenticatedFetch(auth!, req.params.deviceId));
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /devices/:deviceId
|
||||
*
|
||||
* It updates the requested device.
|
||||
* Note that the device must be associated with the authenticated user,
|
||||
* unless the user is an admin.
|
||||
*/
|
||||
@authenticate()
|
||||
patch = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
|
||||
const device = await this.authenticatedFetch(auth!, req.params.deviceId);
|
||||
const { name } = req.body;
|
||||
if (name) {
|
||||
device.name = name;
|
||||
}
|
||||
|
||||
await device.save();
|
||||
res.json(device);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /devices/:deviceId
|
||||
*
|
||||
* It deletes the requested device.
|
||||
* Note that the device must be associated with the authenticated user,
|
||||
* unless the user is an admin.
|
||||
*/
|
||||
@authenticate()
|
||||
delete = async (req: Request, res: Response, auth: Optional<AuthInfo>) => {
|
||||
const device = await this.authenticatedFetch(auth!, req.params.deviceId);
|
||||
await device.destroy();
|
||||
res.status(204).send();
|
||||
}
|
||||
}
|
||||
|
||||
export default DevicesById;
|
|
@ -1,4 +1,6 @@
|
|||
import Auth from "./Auth";
|
||||
import Devices from "./Devices";
|
||||
import DevicesById from "./DevicesById";
|
||||
import GPSData from "./GPSData";
|
||||
import Routes from "../../Routes";
|
||||
import UserSelf from "./UserSelf";
|
||||
|
@ -6,6 +8,8 @@ import UserSelf from "./UserSelf";
|
|||
class ApiV1Routes extends Routes {
|
||||
public routes = [
|
||||
new Auth(),
|
||||
new Devices(),
|
||||
new DevicesById(),
|
||||
new GPSData(),
|
||||
new UserSelf(),
|
||||
];
|
||||
|
|
Loading…
Add table
Reference in a new issue