Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/src/entity/InstrumentContact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class InstrumentContact {
@Column()
personId!: number;

@ManyToOne(() => Person, { onDelete: "CASCADE" })
@ManyToOne(() => Person, (person) => person.instrumentContacts, { onDelete: "CASCADE" })
@JoinColumn({ name: "personId" })
person!: Person;

Expand Down
10 changes: 9 additions & 1 deletion backend/src/entity/Person.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
import { Column, Entity, OneToMany, OneToOne, PrimaryGeneratedColumn } from "typeorm";
import { InstrumentContact } from "./InstrumentContact";
import { UserAccount } from "./UserAccount";

@Entity()
export class Person {
Expand All @@ -16,4 +18,10 @@ export class Person {

@Column({ type: "varchar", nullable: true, select: false })
email?: string | null;

@OneToOne(() => UserAccount, (user) => user.person, { nullable: true })
userAccount!: UserAccount | null;

@OneToMany(() => InstrumentContact, (contact) => contact.person)
instrumentContacts!: InstrumentContact[];
}
4 changes: 2 additions & 2 deletions backend/src/entity/Publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export class Publication {
@Column({ type: "text" })
citation!: string;

@Column()
publishedAt!: Date;
@Column({ type: "date" })
publishedAt!: string;

@Column()
updatedAt!: Date;
Expand Down
20 changes: 12 additions & 8 deletions backend/src/entity/UserAccount.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { Column, Entity, PrimaryGeneratedColumn, ManyToMany, JoinTable, ManyToOne, RelationId } from "typeorm";
import {
Column,
Entity,
PrimaryGeneratedColumn,
ManyToMany,
JoinTable,
OneToOne,
RelationId,
JoinColumn,
} from "typeorm";
import { timingSafeEqual } from "node:crypto";
const md5 = require("apache-md5"); // eslint-disable-line @typescript-eslint/no-require-imports

Expand All @@ -20,13 +29,8 @@ export class UserAccount {
@Column({ type: "varchar", nullable: true, unique: true })
activationToken!: string | null;

@Column({ type: "varchar", nullable: true })
fullName!: string | null;

@Column({ type: "varchar", nullable: true, unique: true })
orcidId!: string | null;

@ManyToOne(() => Person, { nullable: true, onDelete: "SET NULL" })
@OneToOne(() => Person, (person) => person.userAccount, { nullable: true, onDelete: "SET NULL" })
@JoinColumn()
person!: Person | null;

@RelationId((user: UserAccount) => user.person)
Expand Down
78 changes: 52 additions & 26 deletions backend/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { Model } from "../entity/Model";
import { Person } from "../entity/Person";
import env from "./env";
import { hashVerifier, Token } from "../entity/Token";
import { InstrumentInfo } from "../entity/Instrument";
import { InstrumentLogPermissionType } from "../entity/InstrumentLogPermission";

const API_TOKEN_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
const SESSION_TOKEN_LIFETIME_MS = 365 * 24 * 60 * 60 * 1000;
Expand All @@ -17,11 +19,13 @@ export class Authenticator {
private userRepo: Repository<UserAccount>;
private tokenRepo: Repository<Token>;
private personRepo: Repository<Person>;
private instrumentInfoRepo: Repository<InstrumentInfo>;

constructor(dataSource: DataSource) {
this.userRepo = dataSource.getRepository(UserAccount);
this.tokenRepo = dataSource.getRepository(Token);
this.personRepo = dataSource.getRepository(Person);
this.instrumentInfoRepo = dataSource.getRepository(InstrumentInfo);
}

async basicLogin(username: string, password: string) {
Expand All @@ -47,27 +51,29 @@ export class Authenticator {
}

async orcidLogin(params: Record<string, any>) {
const orcidId = params.orcid;
const fullName = params.name;
if (!orcidId) {
const orcid = params.orcid;
if (!orcid) {
throw new Error("Failed to get ORCID iD");
}
const user = await this.userRepo.findOneBy({ orcidId });

// Allow existing user.
const user = await this.userRepo.findOneBy({ person: { orcid } });
if (user) {
user.fullName = fullName;
if (!user.personId) {
const person = await this.personRepo.findOneBy({ orcid: orcidId });
if (person) {
user.person = person;
}
}
await this.userRepo.save(user);
} else {
// Allow only existing users for now.
// user = await this.userRepo.save({ orcidId, fullName });
return user;
}

// Create user for active instrument contact.
const person = await this.personRepo
.createQueryBuilder("person")
.innerJoin("person.instrumentContacts", "contact")
.where("person.orcid = :orcid", { orcid })
.andWhere('CURRENT_DATE <@ daterange(contact."startDate", contact."endDate", \'[]\')')
.getOne();
if (!person) {
return false;
}
return user;
const newUser = await this.userRepo.save({ person });
return newUser;
}

logIn: RequestHandler = async (req, res, next) => {
Expand All @@ -79,7 +85,7 @@ export class Authenticator {
}
const user = await this.userRepo.findOne({
where: { username: req.body.username, passwordHash: Not(IsNull()) },
relations: { permissions: true, instrumentLogPermissions: { instrumentInfo: true } },
relations: { person: true, permissions: true, instrumentLogPermissions: { instrumentInfo: true } },
});
if (!user || !(await this.hasPermission(user, PermissionType.canLogin))) {
return next({ status: 401, errors: "Invalid username" });
Expand All @@ -88,7 +94,8 @@ export class Authenticator {
return next({ status: 401, errors: "Invalid password" });
}
await this.createSessionToken(res, user);
res.send(this.serializeUser(user));
const uuids = await this.getInstrumentUuids(user);
res.send(this.serializeUser(user, uuids));
};

userInfo: RequestHandler = async (req, res, next) => {
Expand All @@ -97,20 +104,39 @@ export class Authenticator {
}
const user = await this.userRepo.findOneOrFail({
where: { id: req.user.id },
relations: { permissions: true, instrumentLogPermissions: { instrumentInfo: true } },
relations: { person: true, permissions: true, instrumentLogPermissions: { instrumentInfo: true } },
});
res.send(this.serializeUser(user));
const uuids = await this.getInstrumentUuids(user);
res.send(this.serializeUser(user, uuids));
};

private serializeUser = (user: UserAccount) => ({
private async getInstrumentUuids(user: UserAccount) {
if (!user.person) return [];
const instrumentInfos = await this.instrumentInfoRepo
.createQueryBuilder("instrumentInfo")
.select("instrumentInfo.uuid")
.innerJoin("instrumentInfo.contacts", "contact")
.where("contact.personId = :personId", { personId: user.person.id })
.andWhere('CURRENT_DATE <@ daterange(contact."startDate", contact."endDate", \'[]\')')
.getMany();
return instrumentInfos.map((instrument) => instrument.uuid);
}

private serializeUser = (user: UserAccount, instrumentInfoUuids: string[]) => ({
...user,
passwordHash: undefined,
activationToken: undefined,
instrumentLogPermissions: (user.instrumentLogPermissions ?? []).map((p) => ({
id: p.id,
permission: p.permission,
instrumentInfoUuid: p.instrumentInfo ? p.instrumentInfo.uuid : null,
})),
instrumentLogPermissions: (user.instrumentLogPermissions ?? [])
.map((p) => ({
permission: p.permission,
instrumentInfoUuid: p.instrumentInfo ? p.instrumentInfo.uuid : null,
}))
.concat(
instrumentInfoUuids.map((instrumentUuid) => ({
permission: InstrumentLogPermissionType.canWriteLogs,
instrumentInfoUuid: instrumentUuid,
})),
),
});

logOut: RequestHandler = async (req, res) => {
Expand Down
23 changes: 23 additions & 0 deletions backend/src/migration/1780914907579-UserAccountPersonOneToOne.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class UserAccountPersonOneToOne1780914907579 implements MigrationInterface {
name = "UserAccountPersonOneToOne1780914907579";

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_account" DROP CONSTRAINT "FK_a38a7de4c91f447eaef1d25b553"`);
await queryRunner.query(
`ALTER TABLE "user_account" ADD CONSTRAINT "UQ_a38a7de4c91f447eaef1d25b553" UNIQUE ("personId")`,
);
await queryRunner.query(
`ALTER TABLE "user_account" ADD CONSTRAINT "FK_a38a7de4c91f447eaef1d25b553" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_account" DROP CONSTRAINT "FK_a38a7de4c91f447eaef1d25b553"`);
await queryRunner.query(`ALTER TABLE "user_account" DROP CONSTRAINT "UQ_a38a7de4c91f447eaef1d25b553"`);
await queryRunner.query(
`ALTER TABLE "user_account" ADD CONSTRAINT "FK_a38a7de4c91f447eaef1d25b553" FOREIGN KEY ("personId") REFERENCES "person"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class RemoveUserAccountOrcidIdAndFullName1781080591832 implements MigrationInterface {
name = "RemoveUserAccountOrcidIdAndFullName1781080591832";

public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_account" DROP COLUMN "fullName"`);
await queryRunner.query(`ALTER TABLE "user_account" DROP CONSTRAINT "UQ_eb6dc869a6f743244ff5b823f9d"`);
await queryRunner.query(`ALTER TABLE "user_account" DROP COLUMN "orcidId"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "user_account" ADD "orcidId" character varying`);
await queryRunner.query(
`ALTER TABLE "user_account" ADD CONSTRAINT "UQ_eb6dc869a6f743244ff5b823f9d" UNIQUE ("orcidId")`,
);
await queryRunner.query(`ALTER TABLE "user_account" ADD "fullName" character varying`);
}
}
52 changes: 32 additions & 20 deletions backend/src/routes/instrumentLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "../../../shared/lib/entity/InstrumentLogConfig";
import { isValidDate, ssAuthString, getS3pathForLogImage } from "../lib";
import env from "../lib/env";
import { InstrumentContact } from "../entity/InstrumentContact";

const VALID_EVENT_TYPES = Object.values(InstrumentLogEventType) as string[];
const ALLOWED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
Expand All @@ -25,43 +26,54 @@ const MAX_IMAGES_PER_LOG = 5;
export class InstrumentLogRoutes {
private logRepo: Repository<InstrumentLog>;
private imageRepo: Repository<InstrumentLogImage>;
private instrumentContactRepo: Repository<InstrumentContact>;
private instrumentInfoRepo: Repository<InstrumentInfo>;
private userRepo: Repository<UserAccount>;

constructor(dataSource: DataSource) {
this.logRepo = dataSource.getRepository(InstrumentLog);
this.imageRepo = dataSource.getRepository(InstrumentLogImage);
this.instrumentContactRepo = dataSource.getRepository(InstrumentContact);
this.instrumentInfoRepo = dataSource.getRepository(InstrumentInfo);
this.userRepo = dataSource.getRepository(UserAccount);
}

private async hasLogPermission(
userId: number,
userId: UserAccount["id"],
permission: InstrumentLogPermissionType,
instrumentInfoUuid: string,
instrumentInfoUuid: InstrumentInfo["uuid"],
): Promise<boolean> {
return this.userRepo
.createQueryBuilder("user")
.leftJoin("user.instrumentLogPermissions", "ilp")
.leftJoin("ilp.instrumentInfo", "instrumentInfo")
.where("user.id = :userId", { userId })
.andWhere("ilp.permission = :permission", { permission })
.andWhere('("ilp"."instrumentInfoUuid" IS NULL OR "instrumentInfo"."uuid" = :uuid)', { uuid: instrumentInfoUuid })
.getExists();
return this.hasAnyLogPermission(userId, [permission], instrumentInfoUuid);
}

private async hasAnyLogPermission(
userId: number,
userId: UserAccount["id"],
permissions: InstrumentLogPermissionType[],
instrumentInfoUuid: string,
instrumentInfoUuid: InstrumentInfo["uuid"],
): Promise<boolean> {
return this.userRepo
const hasPerm = await this.userRepo
.createQueryBuilder("user")
.leftJoin("user.instrumentLogPermissions", "ilp")
.leftJoin("ilp.instrumentInfo", "instrumentInfo")
.leftJoin("user.instrumentLogPermissions", "perm")
.where("user.id = :userId", { userId })
.andWhere("ilp.permission IN (:...permissions)", { permissions })
.andWhere('("ilp"."instrumentInfoUuid" IS NULL OR "instrumentInfo"."uuid" = :uuid)', { uuid: instrumentInfoUuid })
.andWhere("perm.permission IN (:...permissions)", { permissions })
.andWhere('(perm."instrumentInfoUuid" IS NULL OR perm."instrumentInfoUuid" = :uuid)', {
uuid: instrumentInfoUuid,
})
.getExists();
return hasPerm || this.isInstrumentContact(userId, instrumentInfoUuid);
}

private async isInstrumentContact(
userId: UserAccount["id"],
instrumentInfoUuid: InstrumentInfo["uuid"],
): Promise<boolean> {
return this.instrumentContactRepo
.createQueryBuilder("contact")
.innerJoin("contact.person", "person")
.innerJoin("person.userAccount", "user")
.where("user.id = :userId", { userId: userId })
.andWhere("contact.instrumentInfoUuid = :uuid", { uuid: instrumentInfoUuid })
.andWhere('CURRENT_DATE <@ daterange(contact."startDate", contact."endDate", \'[]\')')
.getExists();
}

Expand Down Expand Up @@ -164,7 +176,7 @@ export class InstrumentLogRoutes {
}
const logs = await this.logRepo.find({
where: { instrumentInfoUuid: uuid },
relations: { createdBy: true, updatedBy: true },
relations: { createdBy: { person: true }, updatedBy: { person: true } },
order: { date: "DESC", createdAt: "DESC" },
});
if (logs.length === 0) return res.json([]);
Expand All @@ -179,7 +191,7 @@ export class InstrumentLogRoutes {
imagesByLogId.set(img.instrumentLogId, list);
}
const formatUser = (user: UserAccount | null) =>
user ? { id: user.id, username: user.username, fullName: user.fullName } : null;
user ? { id: user.id, username: user.username, person: user.person } : null;
const safeEntries = logs.map(
({ createdBy, updatedBy, createdById: _, updatedById: _u, instrumentInfoUuid, ...rest }) => ({
...rest,
Expand Down Expand Up @@ -232,7 +244,7 @@ export class InstrumentLogRoutes {
createdBy: req.user,
});
const saved = await this.logRepo.save(log);
const userInfo = req.user ? { id: req.user.id!, username: req.user.username, fullName: req.user.fullName } : null;
const userInfo = req.user ? { id: req.user.id!, username: req.user.username, person: req.user.person } : null;
res.status(201).json({
id: saved.id,
instrumentUuid: saved.instrumentInfoUuid,
Expand Down
4 changes: 2 additions & 2 deletions backend/src/routes/publication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export class PublicationRoutes {
const day = citationJson.published[2] || 1;
const pub = new Publication();
pub.pid = citationJson.url;
pub.publishedAt = new Date(year, month - 1, day);
pub.publishedAt = `${year}-${month.toString().padStart(2, "0")}-${day.toString().padStart(2, "0")}`;
pub.citation = citationHtml;
await this.publicationRepo.save(pub);
res.sendStatus(200);
Expand Down Expand Up @@ -62,7 +62,7 @@ export class PublicationRoutes {
return {
pid: publication.pid,
citation: publication.citation,
publishedAt: publication.publishedAt.toISOString().slice(0, 10),
publishedAt: publication.publishedAt,
updatedAt: publication.updatedAt.toISOString(),
};
}
Expand Down
7 changes: 6 additions & 1 deletion backend/src/routes/site.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,13 +356,18 @@ export class SiteRoutes {
res.sendStatus(204);
};

postPerson: RequestHandler = async (req, res) => {
const person = await this.personRepo.save(req.body);
res.json(person);
};

personByOrcid: RequestHandler = async (req, res) => {
const person = await findPersonByOrcid(this.personRepo, req.params.orcid as string);
if (!person) {
res.json(null);
return;
}
res.json({ firstName: person.firstName, lastName: person.lastName, email: person.email ?? null });
res.json(person);
};

searchPersons: RequestHandler = async (req, res) => {
Expand Down
Loading