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 }) {
|
async function createUserDevicesTable(query: { context: any }) {
|
||||||
await query.context.createTable($db.tableName('user_devices'), {
|
await query.context.createTable($db.tableName('user_devices'), {
|
||||||
id: {
|
id: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.UUID,
|
||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
autoIncrement: true
|
defaultValue: () => uuidv4(),
|
||||||
},
|
},
|
||||||
userId: {
|
userId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import { DataTypes, } from 'sequelize';
|
import { DataTypes, } from 'sequelize';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
function UserDevice(): Record<string, any> {
|
function UserDevice(): Record<string, any> {
|
||||||
return {
|
return {
|
||||||
id: {
|
id: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.UUID,
|
||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
autoIncrement: true
|
defaultValue: () => uuidv4(),
|
||||||
},
|
},
|
||||||
userId: {
|
userId: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { Op } from 'sequelize';
|
||||||
import { Optional } from '~/types';
|
import { Optional } from '~/types';
|
||||||
import Role from './Role';
|
import Role from './Role';
|
||||||
import RoleName from './RoleName';
|
import RoleName from './RoleName';
|
||||||
|
import UserDevice from './UserDevice';
|
||||||
|
|
||||||
class User {
|
class User {
|
||||||
public id: number;
|
public id: number;
|
||||||
|
@ -50,6 +51,16 @@ class User {
|
||||||
).map((role) => new Role(role.dataValues));
|
).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> {
|
public async setRoles(roles: (Role | RoleName | number | string)[]): Promise<void> {
|
||||||
const inputRoleIds = new Set(
|
const inputRoleIds = new Set(
|
||||||
roles.filter((role) => {
|
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 {
|
class UserSession {
|
||||||
public id: string;
|
public id: string;
|
||||||
public userId: number;
|
public userId: number;
|
||||||
|
public name?: Optional<string>;
|
||||||
public expiresAt: Optional<Date>;
|
public expiresAt: Optional<Date>;
|
||||||
public createdAt: Optional<Date>;
|
public createdAt: Optional<Date>;
|
||||||
|
|
||||||
constructor({
|
constructor({
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
|
name,
|
||||||
expiresAt = null,
|
expiresAt = null,
|
||||||
createdAt = null,
|
createdAt = null,
|
||||||
}: any) {
|
}: any) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
|
this.name = name;
|
||||||
this.expiresAt = expiresAt;
|
this.expiresAt = expiresAt;
|
||||||
this.createdAt = createdAt;
|
this.createdAt = createdAt;
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@ import GPSPoint from "./GPSPoint";
|
||||||
import Role from "./Role";
|
import Role from "./Role";
|
||||||
import RoleName from "./RoleName";
|
import RoleName from "./RoleName";
|
||||||
import User from "./User";
|
import User from "./User";
|
||||||
|
import UserDevice from "./UserDevice";
|
||||||
import UserSession from "./UserSession";
|
import UserSession from "./UserSession";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@ -9,5 +10,6 @@ export {
|
||||||
Role,
|
Role,
|
||||||
RoleName,
|
RoleName,
|
||||||
User,
|
User,
|
||||||
|
UserDevice,
|
||||||
UserSession,
|
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 Location from './Location';
|
||||||
import Users from './Users';
|
import Users from './Users';
|
||||||
|
import UserDevices from './UserDevices';
|
||||||
import UserRoles from './UserRoles';
|
import UserRoles from './UserRoles';
|
||||||
import UserSessions from './UserSessions';
|
import UserSessions from './UserSessions';
|
||||||
|
|
||||||
class Repositories {
|
class Repositories {
|
||||||
public location: Location;
|
public location: Location;
|
||||||
public users: Users;
|
public users: Users;
|
||||||
|
public userDevices: UserDevices;
|
||||||
public userRoles: UserRoles;
|
public userRoles: UserRoles;
|
||||||
public userSessions: UserSessions;
|
public userSessions: UserSessions;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.location = new Location();
|
this.location = new Location();
|
||||||
this.users = new Users();
|
this.users = new Users();
|
||||||
|
this.userDevices = new UserDevices();
|
||||||
this.userRoles = new UserRoles();
|
this.userRoles = new UserRoles();
|
||||||
this.userSessions = new UserSessions();
|
this.userSessions = new UserSessions();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import { Express, Request, Response } from 'express';
|
import { Express, Request, Response } from 'express';
|
||||||
|
import { UniqueConstraintError, ValidationError } from 'sequelize';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BadRequest,
|
BadRequest,
|
||||||
|
@ -50,7 +51,14 @@ abstract class Route {
|
||||||
return;
|
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({
|
res.status(400).json({
|
||||||
error: error.message,
|
error: error.message,
|
||||||
});
|
});
|
||||||
|
@ -66,17 +74,18 @@ abstract class Route {
|
||||||
handlerName: string,
|
handlerName: string,
|
||||||
) => {
|
) => {
|
||||||
logRequest(req);
|
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 {
|
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>
|
let authInfo: Optional<AuthInfo>
|
||||||
if (preRequestHandler) {
|
if (preRequestHandler) {
|
||||||
authInfo = await preRequestHandler(req, res);
|
authInfo = await preRequestHandler(req, res);
|
||||||
}
|
}
|
||||||
|
|
||||||
handler(req, res, authInfo!);
|
await handler(req, res, authInfo!);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
(<typeof Route> this.constructor).handleError(req, res, 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 Auth from "./Auth";
|
||||||
|
import Devices from "./Devices";
|
||||||
|
import DevicesById from "./DevicesById";
|
||||||
import GPSData from "./GPSData";
|
import GPSData from "./GPSData";
|
||||||
import Routes from "../../Routes";
|
import Routes from "../../Routes";
|
||||||
import UserSelf from "./UserSelf";
|
import UserSelf from "./UserSelf";
|
||||||
|
@ -6,6 +8,8 @@ import UserSelf from "./UserSelf";
|
||||||
class ApiV1Routes extends Routes {
|
class ApiV1Routes extends Routes {
|
||||||
public routes = [
|
public routes = [
|
||||||
new Auth(),
|
new Auth(),
|
||||||
|
new Devices(),
|
||||||
|
new DevicesById(),
|
||||||
new GPSData(),
|
new GPSData(),
|
||||||
new UserSelf(),
|
new UserSelf(),
|
||||||
];
|
];
|
||||||
|
|
Loading…
Add table
Reference in a new issue