diff --git a/backend/src/entity/InstrumentContact.ts b/backend/src/entity/InstrumentContact.ts index f892c1d17..e95294e35 100644 --- a/backend/src/entity/InstrumentContact.ts +++ b/backend/src/entity/InstrumentContact.ts @@ -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; diff --git a/backend/src/entity/Person.ts b/backend/src/entity/Person.ts index 08e51eba4..2584dd505 100644 --- a/backend/src/entity/Person.ts +++ b/backend/src/entity/Person.ts @@ -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 { @@ -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[]; } diff --git a/backend/src/entity/Publication.ts b/backend/src/entity/Publication.ts index ce3b32464..dc875e79d 100644 --- a/backend/src/entity/Publication.ts +++ b/backend/src/entity/Publication.ts @@ -8,8 +8,8 @@ export class Publication { @Column({ type: "text" }) citation!: string; - @Column() - publishedAt!: Date; + @Column({ type: "date" }) + publishedAt!: string; @Column() updatedAt!: Date; diff --git a/backend/src/entity/UserAccount.ts b/backend/src/entity/UserAccount.ts index 23e9c7f26..5cd58c561 100644 --- a/backend/src/entity/UserAccount.ts +++ b/backend/src/entity/UserAccount.ts @@ -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 @@ -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) diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index d8454e2de..f74f45ba5 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -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; @@ -17,11 +19,13 @@ export class Authenticator { private userRepo: Repository; private tokenRepo: Repository; private personRepo: Repository; + private instrumentInfoRepo: Repository; 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) { @@ -47,27 +51,29 @@ export class Authenticator { } async orcidLogin(params: Record) { - 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) => { @@ -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" }); @@ -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) => { @@ -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) => { diff --git a/backend/src/migration/1780914907579-UserAccountPersonOneToOne.ts b/backend/src/migration/1780914907579-UserAccountPersonOneToOne.ts new file mode 100644 index 000000000..7093065b6 --- /dev/null +++ b/backend/src/migration/1780914907579-UserAccountPersonOneToOne.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserAccountPersonOneToOne1780914907579 implements MigrationInterface { + name = "UserAccountPersonOneToOne1780914907579"; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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`, + ); + } +} diff --git a/backend/src/migration/1781080591832-RemoveUserAccountOrcidIdAndFullName.ts b/backend/src/migration/1781080591832-RemoveUserAccountOrcidIdAndFullName.ts new file mode 100644 index 000000000..1ae01ce29 --- /dev/null +++ b/backend/src/migration/1781080591832-RemoveUserAccountOrcidIdAndFullName.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RemoveUserAccountOrcidIdAndFullName1781080591832 implements MigrationInterface { + name = "RemoveUserAccountOrcidIdAndFullName1781080591832"; + + public async up(queryRunner: QueryRunner): Promise { + 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 { + 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`); + } +} diff --git a/backend/src/routes/instrumentLog.ts b/backend/src/routes/instrumentLog.ts index 4c9571c78..de6c2ac17 100644 --- a/backend/src/routes/instrumentLog.ts +++ b/backend/src/routes/instrumentLog.ts @@ -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"]); @@ -25,43 +26,54 @@ const MAX_IMAGES_PER_LOG = 5; export class InstrumentLogRoutes { private logRepo: Repository; private imageRepo: Repository; + private instrumentContactRepo: Repository; private instrumentInfoRepo: Repository; private userRepo: Repository; 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 { - 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 { - 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 { + 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(); } @@ -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([]); @@ -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, @@ -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, diff --git a/backend/src/routes/publication.ts b/backend/src/routes/publication.ts index 73508638f..9d5cd1aea 100644 --- a/backend/src/routes/publication.ts +++ b/backend/src/routes/publication.ts @@ -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); @@ -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(), }; } diff --git a/backend/src/routes/site.ts b/backend/src/routes/site.ts index ca754dc13..719626249 100644 --- a/backend/src/routes/site.ts +++ b/backend/src/routes/site.ts @@ -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) => { diff --git a/backend/src/routes/userAccount.ts b/backend/src/routes/userAccount.ts index 3ded2e84f..e7bc28076 100644 --- a/backend/src/routes/userAccount.ts +++ b/backend/src/routes/userAccount.ts @@ -32,8 +32,7 @@ interface InstrumentLogPermissionInterface { interface UserAccountInterface { id?: number; username: string | null; - fullName?: string | null; - orcidId?: string | null; + person: Person | null; password?: string; passwordHash?: string; activationToken?: string; @@ -70,8 +69,7 @@ export class UserAccountRoutes { return { id: user.id, username: user.username, - fullName: user.fullName, - orcidId: user.orcidId, + person: user.person, activationToken: user.activationToken || undefined, permissions: user.permissions.map((p) => ({ id: p.id, @@ -88,10 +86,14 @@ export class UserAccountRoutes { }; postUserAccount: RequestHandler = async (req, res) => { - const where = req.body.orcidId ? { orcidId: req.body.orcidId } : { username: req.body.username }; + const where = req.body.personId ? { person: { id: req.body.personId } } : { username: req.body.username }; let user = await this.userRepo.findOne({ where, - relations: { permissions: { site: true, model: true }, instrumentLogPermissions: { instrumentInfo: true } }, + relations: { + person: true, + permissions: { site: true, model: true }, + instrumentLogPermissions: { instrumentInfo: true }, + }, }); if (user) { res.json(this.userResponse(user)); @@ -100,16 +102,13 @@ export class UserAccountRoutes { user = new UserAccount(); user.username = req.body.username ?? null; - if (req.body.orcidId) { - user.orcidId = req.body.orcidId; - const person = await this.personRepo.findOneBy({ orcid: req.body.orcidId }); - if (person) { - user.person = person; - } + if (req.body.personId) { + const person = await this.personRepo.findOneByOrFail({ id: req.body.personId }); + user.person = person; } if (req.body.password) { user.setPassword(req.body.password); - } else if (!req.body.orcidId) { + } else if (req.body.username) { user.activationToken = randomString(32); } user.permissions = await this.createPermissions(req.body.permissions); @@ -200,7 +199,7 @@ export class UserAccountRoutes { putUserAccount: RequestHandler = async (req, res, next) => { const user = await this.userRepo.findOne({ where: { id: Number(req.params.id) }, - relations: { permissions: { site: true }, instrumentLogPermissions: { instrumentInfo: true } }, + relations: { person: true, permissions: { site: true }, instrumentLogPermissions: { instrumentInfo: true } }, }); if (!user) { return next({ status: 404, errors: "UserAccount not found" }); @@ -214,8 +213,9 @@ export class UserAccountRoutes { } } } - if (hasProperty(req.body, "orcidId")) { - user.orcidId = req.body.orcidId; + if (hasProperty(req.body, "personId")) { + const person = await this.personRepo.findOneByOrFail({ id: req.body.personId }); + user.person = person; } if (hasProperty(req.body, "password")) { user.setPassword(req.body.password); @@ -233,7 +233,11 @@ export class UserAccountRoutes { getUserAccount: RequestHandler = async (req, res, next) => { const user = await this.userRepo.findOne({ where: { id: Number(req.params.id) }, - relations: { permissions: { site: true, model: true }, instrumentLogPermissions: { instrumentInfo: true } }, + relations: { + person: true, + permissions: { site: true, model: true }, + instrumentLogPermissions: { instrumentInfo: true }, + }, }); if (!user) return next({ status: 404, errors: "UserAccount not found" }); res.json(this.userResponse(user)); @@ -250,23 +254,22 @@ export class UserAccountRoutes { getAllUserAccounts: RequestHandler = async (req, res) => { const users = await this.userRepo.find({ - relations: { permissions: { site: true, model: true }, instrumentLogPermissions: { instrumentInfo: true } }, + relations: { + person: true, + permissions: { site: true, model: true }, + instrumentLogPermissions: { instrumentInfo: true }, + }, }); res.json(users.map((u) => this.userResponse(u))); }; validatePost: RequestHandler = async (req, res, next) => { - if (!hasProperty(req.body, "username") && !hasProperty(req.body, "orcidId")) { - return next({ status: 401, errors: "Missing the username or orcidId" }); + if (!hasProperty(req.body, "username") && !hasProperty(req.body, "personId")) { + return next({ status: 401, errors: "Missing the username or personId" }); } if (hasProperty(req.body, "username")) { this.validateUsername(req, next); } - if (hasProperty(req.body, "orcidId")) { - if (!this.isValidOrcidId(req.body.orcidId)) { - return next({ status: 422, errors: "Invalid ORCID iD format" }); - } - } if (hasProperty(req.body, "password")) { this.validatePassword(req, next); } @@ -284,11 +287,6 @@ export class UserAccountRoutes { if (hasProperty(req.body, "username") && req.body.username !== null) { this.validateUsername(req, next); } - if (hasProperty(req.body, "orcidId") && req.body.orcidId !== null) { - if (!this.isValidOrcidId(req.body.orcidId)) { - return next({ status: 422, errors: "Invalid ORCID iD format" }); - } - } if (hasProperty(req.body, "password")) { this.validatePassword(req, next); } @@ -318,10 +316,6 @@ export class UserAccountRoutes { } } - isValidOrcidId(orcidId: unknown): boolean { - return typeof orcidId === "string" && /^\d{4}-\d{4}-\d{4}-\d{3}[\dX]$/.test(orcidId); - } - validatePermissions: RequestHandler = async (req, res, next) => { if (Array.isArray(req.body.permissions)) { await this.validatePermissionList(req, res, next); diff --git a/backend/src/server.ts b/backend/src/server.ts index 0efbd6620..bdcf6cb5c 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -615,12 +615,14 @@ async function createServer(): Promise { monitoringVisualizationRoutes.putMonitoringVisualization, ); - // Private UserAccount and Permission routes + // Private UserAccount and Permission routes used by admin script. app.post("/user-accounts", express.json(), userAccountRoutes.validatePost, userAccountRoutes.postUserAccount); app.get("/user-accounts/:id", userAccountRoutes.getUserAccount); app.delete("/user-accounts/:id", userAccountRoutes.deleteUserAccount); app.put("/user-accounts/:id", express.json(), userAccountRoutes.validatePut, userAccountRoutes.putUserAccount); app.get("/user-accounts/", userAccountRoutes.getAllUserAccounts); + app.post("/persons/", express.json(), siteRoutes.postPerson); + app.get("/persons/orcid/:orcid", siteRoutes.personByOrcid); app.post( "/api/queue/publish", diff --git a/backend/tests/integration/parallel/__snapshots__/auth.test.ts.snap b/backend/tests/integration/parallel/__snapshots__/auth.test.ts.snap index 677cfac39..18dc87e2e 100644 --- a/backend/tests/integration/parallel/__snapshots__/auth.test.ts.snap +++ b/backend/tests/integration/parallel/__snapshots__/auth.test.ts.snap @@ -2,16 +2,13 @@ exports[`GET /auth/login returns admin permissions 1`] = ` { - "fullName": null, "id": 1, "instrumentLogPermissions": [ { - "id": 1, "instrumentInfoUuid": null, "permission": "canWriteLogs", }, ], - "orcidId": null, "permissions": [ { "id": 1, @@ -58,6 +55,7 @@ exports[`GET /auth/login returns admin permissions 1`] = ` "permission": "canManageNews", }, ], + "person": null, "personId": null, "username": "admin", } @@ -65,16 +63,13 @@ exports[`GET /auth/login returns admin permissions 1`] = ` exports[`GET /auth/me returns current user with cookie from login 1`] = ` { - "fullName": null, "id": 1, "instrumentLogPermissions": [ { - "id": 1, "instrumentInfoUuid": null, "permission": "canWriteLogs", }, ], - "orcidId": null, "permissions": [ { "id": 1, @@ -121,6 +116,7 @@ exports[`GET /auth/me returns current user with cookie from login 1`] = ` "permission": "canManageNews", }, ], + "person": null, "personId": null, "username": "admin", } @@ -128,16 +124,13 @@ exports[`GET /auth/me returns current user with cookie from login 1`] = ` exports[`GET /auth/me returns current user with new cookie 1`] = ` { - "fullName": null, "id": 1, "instrumentLogPermissions": [ { - "id": 1, "instrumentInfoUuid": null, "permission": "canWriteLogs", }, ], - "orcidId": null, "permissions": [ { "id": 1, @@ -184,6 +177,7 @@ exports[`GET /auth/me returns current user with new cookie 1`] = ` "permission": "canManageNews", }, ], + "person": null, "personId": null, "username": "admin", } diff --git a/backend/tests/integration/sequential/instrumentLogPermissions.test.ts b/backend/tests/integration/sequential/instrumentLogPermissions.test.ts index 0b43a9eba..ea862701c 100644 --- a/backend/tests/integration/sequential/instrumentLogPermissions.test.ts +++ b/backend/tests/integration/sequential/instrumentLogPermissions.test.ts @@ -7,6 +7,8 @@ import { InstrumentLogPermission } from "../../../src/entity/InstrumentLogPermis import { Token } from "../../../src/entity/Token"; import { AppDataSource } from "../../../src/data-source"; import { describe, expect, it, beforeAll, afterAll } from "@jest/globals"; +import { Person } from "../../../src/entity/Person"; +import { InstrumentContact } from "../../../src/entity/InstrumentContact"; let dataSource: DataSource; @@ -18,6 +20,8 @@ const otherInstrumentInfoUuid = "0b3a7fa0-4812-4964-af23-1162e8b3a665"; const globalReaderCreds = { username: "perm_globalreader", password: "hunter2" }; const globalWriterCreds = { username: "perm_globalwriter", password: "hunter2" }; const specificReaderCreds = { username: "perm_specificreader", password: "hunter2" }; +const oldContactCreds = { username: "perm_contact_old", password: "hunter2" }; +const newContactCreds = { username: "perm_contact_new", password: "hunter2" }; beforeAll(async () => { dataSource = await AppDataSource.initialize(); @@ -37,14 +41,12 @@ beforeAll(async () => { permissions: [], instrumentLogPermissions: [{ permission: "canReadLogs", instrumentInfoUuid: null }], }); - await axios.post(userAccountsUrl, { username: globalWriterCreds.username, password: globalWriterCreds.password, permissions: [], instrumentLogPermissions: [{ permission: "canWriteLogs", instrumentInfoUuid: null }], }); - await axios.post(userAccountsUrl, { username: specificReaderCreds.username, password: specificReaderCreds.password, @@ -63,6 +65,38 @@ beforeAll(async () => { { instrumentUuid: otherInstrumentInfoUuid, eventType: "maintenance", date: "2020-01-01" }, { auth: globalWriterCreds }, ); + + // Insert old instrument contact. + const oldContactRes = await axios.post(userAccountsUrl, { + username: oldContactCreds.username, + password: oldContactCreds.password, + permissions: [], + instrumentLogPermissions: [], + }); + const oldContactPerson = await dataSource.getRepository(Person).save({ + firstName: "Matti", + lastName: "Muukalainen", + }); + await dataSource.getRepository(UserAccount).save({ id: oldContactRes.data.id, person: oldContactPerson }); + await dataSource + .getRepository(InstrumentContact) + .save({ instrumentInfoUuid, person: oldContactPerson, endDate: "2025-12-31", createdAt: new Date() }); + + // Insert new instrument contact. + const newContactRes = await axios.post(userAccountsUrl, { + username: newContactCreds.username, + password: newContactCreds.password, + permissions: [], + instrumentLogPermissions: [], + }); + const newContactPerson = await dataSource.getRepository(Person).save({ + firstName: "Matti", + lastName: "Meikäläinen", + }); + await dataSource.getRepository(UserAccount).save({ id: newContactRes.data.id, person: newContactPerson }); + await dataSource + .getRepository(InstrumentContact) + .save({ instrumentInfoUuid, person: newContactPerson, startDate: "2026-01-01", createdAt: new Date() }); }); afterAll(async () => await dataSource.destroy()); @@ -142,6 +176,51 @@ describe("Instrument-specific canReadLogs isolation", () => { }); }); +describe("Previous contact", () => { + it("cannot read logs", async () => { + return expect( + axios.get(url, { params: { instrumentUuid: instrumentInfoUuid }, auth: oldContactCreds }), + ).rejects.toMatchObject(genResponse(403, { status: 403, errors: "Missing permission" })); + }); + + it("cannot write logs", async () => { + return expect( + axios.post( + url, + { instrumentUuid: instrumentInfoUuid, eventType: "maintenance", date: "2020-06-01" }, + { auth: oldContactCreds }, + ), + ).rejects.toMatchObject(genResponse(403, { status: 403, errors: "Missing permission" })); + }); +}); + +describe("Current contact", () => { + it("can read logs for the permitted instrument", async () => { + const res = await axios.get(url, { params: { instrumentUuid: instrumentInfoUuid }, auth: newContactCreds }); + expect(res.status).toBe(200); + }); + + it("can write logs for the primary instrument", async () => { + const res = await axios.post( + url, + { + instrumentUuid: instrumentInfoUuid, + eventType: "note", + date: "2026-01-01", + notes: "This is my instrument now!", + }, + { auth: newContactCreds }, + ); + expect(res.status).toBe(201); + }); + + it("cannot read logs for a different instrument", async () => { + return expect( + axios.get(url, { params: { instrumentUuid: otherInstrumentInfoUuid }, auth: newContactCreds }), + ).rejects.toMatchObject(genResponse(403, { status: 403, errors: "Missing permission" })); + }); +}); + describe("Permission deduplication", () => { it("strips canReadLogs when canWriteLogs is given for the same global scope", async () => { const res = await axios.post(userAccountsUrl, { diff --git a/backend/tests/integration/sequential/orcid.test.ts b/backend/tests/integration/sequential/orcid.test.ts new file mode 100644 index 000000000..d3b765805 --- /dev/null +++ b/backend/tests/integration/sequential/orcid.test.ts @@ -0,0 +1,128 @@ +import { afterAll, beforeAll, afterEach, describe, expect, it } from "@jest/globals"; +import { DataSource, Repository } from "typeorm"; +import { AppDataSource } from "../../../src/data-source"; +import { UserAccount } from "../../../src/entity/UserAccount"; +import { Person } from "../../../src/entity/Person"; +import { Authenticator } from "../../../src/lib/auth"; +import { InstrumentContact } from "../../../src/entity/InstrumentContact"; +import { cleanRepos } from "../../lib"; +import { Instrument, InstrumentInfo } from "../../../src/entity/Instrument"; + +let dataSource: DataSource; +let userRepo: Repository; +let personRepo: Repository; +let instrumentContactRepo: Repository; +let authenticator: Authenticator; + +beforeAll(async () => { + dataSource = await AppDataSource.initialize(); + await cleanRepos(dataSource); + + const instrument = await dataSource.getRepository(Instrument).save({ + id: "test-instrument", + type: "lidar" as any, + humanReadableName: "Test Instrument", + }); + await dataSource.getRepository(InstrumentInfo).save({ + uuid: "c43e9f54-c94d-45f7-8596-223b1c2b14c0", + pid: "https://hdl.handle.net/test-instrument", + name: "Test Instrument", + instrument, + owners: ["Test Owner"], + model: "Test Model", + type: "test", + }); + + userRepo = dataSource.getRepository(UserAccount); + personRepo = dataSource.getRepository(Person); + instrumentContactRepo = dataSource.getRepository(InstrumentContact); + authenticator = new Authenticator(dataSource); +}); + +afterEach(async () => { + await instrumentContactRepo.deleteAll(); + await personRepo.deleteAll(); + await userRepo.deleteAll(); +}); + +afterAll(async () => { + await dataSource.destroy(); +}); + +describe("ORCID login", () => { + describe("login by approved user", () => { + it("throws error when ORCID is missing", async () => { + const params = { name: "Test User" }; + await expect(authenticator.orcidLogin(params)).rejects.toThrow("Failed to get ORCID iD"); + }); + + it("prevents login by unknown user", async () => { + const orcidId = "0000-0000-0000-UNKNOWN"; + const fullName = "Random User"; + + const params = { orcid: orcidId, name: fullName }; + const result = await authenticator.orcidLogin(params); + + expect(result).toBe(false); + }); + + it("allows known user", async () => { + const orcidId = "0000-0000-0000-KNOWN"; + + const person = await personRepo.save({ orcid: orcidId, firstName: "Known", lastName: "User" }); + const user = await userRepo.save({ person }); + + const params = { orcid: orcidId }; + const result = await authenticator.orcidLogin(params); + expect(result).not.toBe(false); + expect((result as UserAccount).id).toBe(user.id); + }); + }); + + describe("login by instrument contact", () => { + it("creates new user for existing instrument contact", async () => { + const orcidId = "0000-0000-0000-PICARD"; + + const person = await personRepo.save({ firstName: "Jean-Luc", lastName: "Picard", orcid: orcidId }); + await instrumentContactRepo.save({ + instrumentInfoUuid: "c43e9f54-c94d-45f7-8596-223b1c2b14c0", + person, + startDate: "2020-01-01", + endDate: null, + createdAt: new Date(), + }); + + const params = { orcid: orcidId }; + const result = await authenticator.orcidLogin(params); + expect(result).not.toBe(false); + expect((result as UserAccount).personId).toBe(person.id); + }); + + it("returns false for person without active instrument contact", async () => { + const orcidId = "0000-0000-0000-NOCONTACT"; + + await personRepo.save({ firstName: "No", lastName: "Contacts", orcid: orcidId }); + + const params = { orcid: orcidId }; + const result = await authenticator.orcidLogin(params); + expect(result).toBe(false); + }); + + it("returns false for expired instrument contact", async () => { + const orcidId = "0000-0000-0000-EXPIRED"; + + const person = await personRepo.save({ firstName: "Expired", lastName: "Contact", orcid: orcidId }); + await instrumentContactRepo.save({ + instrumentInfoUuid: "c43e9f54-c94d-45f7-8596-223b1c2b14c0", + person, + startDate: "2020-01-01", + endDate: "2023-12-31", + createdAt: new Date(), + }); + + const params = { orcid: orcidId }; + const result = await authenticator.orcidLogin(params); + expect(result).toBe(false); + }); + }); +}); diff --git a/frontend/src/components/NewsList.vue b/frontend/src/components/NewsList.vue index 3b500c8e4..fc657a5c6 100644 --- a/frontend/src/components/NewsList.vue +++ b/frontend/src/components/NewsList.vue @@ -6,7 +6,7 @@ @@ -28,8 +28,7 @@ const error = ref(false); async function fetchNews() { try { const response = await axios.get(`${backendUrl}news`, { params: { limit: 5 } }); - // Filter out draft items for the frontpage - news.value = response.data.filter((item) => !item.draft); + news.value = response.data; } catch (err) { console.error("Failed to fetch news:", err); error.value = true; diff --git a/frontend/src/components/instrument/InstrumentLogbook.vue b/frontend/src/components/instrument/InstrumentLogbook.vue index 333503010..482ae0d2e 100644 --- a/frontend/src/components/instrument/InstrumentLogbook.vue +++ b/frontend/src/components/instrument/InstrumentLogbook.vue @@ -44,7 +44,7 @@ - {{ entry.createdBy?.fullName ?? entry.createdBy?.username ?? "–" }} + {{ formatUser(entry.createdBy) }} @@ -251,6 +251,9 @@ function sortIndicator(key: SortKey): string { return sortDir.value === "asc" ? " \u25B2" : " \u25BC"; } +const formatUser = (user: InstrumentLog["createdBy"]) => + user ? (user.person ? `${user.person.firstName} ${user.person.lastName}` : user.username || `User #${user.id}`) : "–"; + const sortedEntries = computed(() => { const dir = sortDir.value === "asc" ? 1 : -1; return [...entries.value].sort((a, b) => { @@ -262,8 +265,8 @@ const sortedEntries = computed(() => { av = a.eventType; bv = b.eventType; } else { - av = a.createdBy?.fullName ?? a.createdBy?.username ?? ""; - bv = b.createdBy?.fullName ?? b.createdBy?.username ?? ""; + av = formatUser(a.createdBy); + bv = formatUser(b.createdBy); } return av < bv ? -dir : av > bv ? dir : 0; }); @@ -341,7 +344,7 @@ function removeEndDate() { function formatTimestamp(entry: InstrumentLog): string { let text = `Added on ${entry.createdAt.slice(0, 10)} ${entry.createdAt.slice(11, 16)} UTC`; if (entry.updatedAt) { - const updater = entry.updatedBy?.fullName ?? entry.updatedBy?.username; + const updater = formatUser(entry.updatedBy); text += `\nUpdated on ${entry.updatedAt.slice(0, 10)} ${entry.updatedAt.slice(11, 16)} UTC`; if (updater) text += ` by ${updater}`; } diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 7ceb3726f..33925e6be 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -57,7 +57,9 @@ export async function logout() { function setUserState(user: UserAccount) { loginStore.isAuthenticated = true; - loginStore.name = user.fullName || user.username || `User ${user.id}`; + loginStore.name = user.person + ? `${user.person.firstName} ${user.person.lastName}` + : user.username || `User #${user.id}`; loginStore.permissions = user.permissions; loginStore.instrumentLogPermissions = user.instrumentLogPermissions ?? []; } diff --git a/frontend/src/views/NewsView.vue b/frontend/src/views/NewsView.vue index 46a1a5829..68da1fecf 100644 --- a/frontend/src/views/NewsView.vue +++ b/frontend/src/views/NewsView.vue @@ -3,7 +3,7 @@
Loading news item...
Failed to load news item
-

{{ newsItem.title }}

+

{{ newsItem.title }}{{ newsItem.draft ? " (draft)" : "" }}

{{ formatDisplayDate(newsItem.date) }}

diff --git a/scripts/admin b/scripts/admin index 341ca567d..3cc86e279 100755 --- a/scripts/admin +++ b/scripts/admin @@ -28,13 +28,10 @@ LOG_PERMISSIONS = ["canReadLogs", "canWriteLogs"] def get_instrument_names() -> dict[str, str]: - try: - req = urllib.request.Request(url=f"{BASE_URL}/api/instrument-pids") - with urllib.request.urlopen(req) as f: - instruments = json.load(f) - return {instr["uuid"]: instr.get("name", "") for instr in instruments} - except Exception: - return {} + req = urllib.request.Request(url=f"{BASE_URL}/api/instrument-pids") + with urllib.request.urlopen(req) as f: + instruments = json.load(f) + return {instr["uuid"]: instr.get("name", "") for instr in instruments} def get_all_users() -> list[dict]: @@ -52,7 +49,7 @@ def get_user(username: str) -> dict: def get_user_by_orcid(orcid: str) -> dict: for user in get_all_users(): - if user.get("orcidId") == orcid: + if user["person"] is not None and user["person"]["orcid"] == orcid: return user raise ValueError(f"User with ORCID {orcid} not found") @@ -89,16 +86,47 @@ def post_user(user: dict) -> dict: return json.load(f) +def get_or_create_person(orcid: str) -> dict | None: + req = urllib.request.Request(url=f"{BASE_URL}/persons/orcid/{orcid}") + with urllib.request.urlopen(req) as f: + person = json.load(f) + if person is not None: + return person + + req = urllib.request.Request( + url=f"https://pub.orcid.org/v3.0/{orcid}/personal-details", + headers={"Accept": "application/json"}, + ) + try: + with urllib.request.urlopen(req) as f: + res = json.load(f) + first_name = res["name"]["given-names"]["value"] + last_name = res["name"]["family-name"]["value"] + except urllib.error.HTTPError as err: + if err.code == 404: + return None + raise + body = {"firstName": first_name, "lastName": last_name, "orcid": orcid} + req = urllib.request.Request( + method="POST", + url=f"{BASE_URL}/persons", + headers={"Content-Type": "application/json"}, + data=json.dumps(body).encode("utf-8"), + ) + with urllib.request.urlopen(req) as f: + person = json.load(f) + return person + + def print_user(user: dict, instrument_names: dict[str, str] | None = None) -> None: - full_name = user.get("fullName", "") if user["username"]: print("Username: " + user["username"], end="") - elif user.get("orcidId"): - print("ORCID: " + user["orcidId"], end="") + elif user["person"] is not None and user["person"]["orcid"] is not None: + print("ORCID: " + user["person"]["orcid"], end="") else: print("User ID: " + str(user["id"]), end="") - if full_name: - print(f" ({full_name})") + if user["person"] is not None: + print(f" ({user['person']['firstName']} {user['person']['lastName']})") else: print() permissions = user.get("permissions", []) @@ -145,23 +173,25 @@ def user_info(args: argparse.Namespace) -> None: print_user(resolve_user(args)) -def validate_orcid(orcid: str) -> None: +def validate_orcid(orcid: str) -> str: if not re.fullmatch(r"\d{4}-\d{4}-\d{4}-\d{3}[\dX]", orcid): - print(f"Invalid ORCID iD format: {orcid}", file=sys.stderr) - sys.exit(1) + raise ValueError(f"Invalid ORCID iD format: {orcid}") + return orcid def create_user(args: argparse.Namespace) -> None: if not args.username and not args.orcid: print("Either username or --orcid is required", file=sys.stderr) sys.exit(1) - if args.orcid: - validate_orcid(args.orcid) user: dict = {"permissions": []} if args.username: user["username"] = args.username if args.orcid: - user["orcidId"] = args.orcid + person = get_or_create_person(args.orcid) + if person is None: + print(f"Didn't find person with ORCID iD {args.orcid}", file=sys.stderr) + sys.exit(1) + user["personId"] = person["id"] if args.password: user["password"] = args.password user = post_user(user) @@ -323,18 +353,18 @@ def main(): subparser = subparsers.add_parser("user-info") subparser.add_argument("username", nargs="?") - subparser.add_argument("--orcid") + subparser.add_argument("--orcid", type=validate_orcid) subparser.set_defaults(func=user_info) subparser = subparsers.add_parser("create-user") subparser.add_argument("username", nargs="?") - subparser.add_argument("--orcid") + subparser.add_argument("--orcid", type=validate_orcid) subparser.add_argument("--password") subparser.set_defaults(func=create_user) subparser = subparsers.add_parser("add-permission") subparser.add_argument("username", nargs="?") - subparser.add_argument("--orcid") + subparser.add_argument("--orcid", type=validate_orcid) subparser.add_argument("permission", choices=PERMISSIONS) group = subparser.add_mutually_exclusive_group() group.add_argument("--site", nargs="+") @@ -349,7 +379,7 @@ def main(): subparser = subparsers.add_parser("remove-permission") subparser.add_argument("username", nargs="?") - subparser.add_argument("--orcid") + subparser.add_argument("--orcid", type=validate_orcid) subparser.add_argument("permission", choices=PERMISSIONS) group = subparser.add_mutually_exclusive_group() group.add_argument("--site", nargs="+") diff --git a/shared/lib/entity/InstrumentLog.ts b/shared/lib/entity/InstrumentLog.ts index ce0090582..6288b2d41 100644 --- a/shared/lib/entity/InstrumentLog.ts +++ b/shared/lib/entity/InstrumentLog.ts @@ -1,3 +1,5 @@ +import type { Person } from "./Person"; + export type InstrumentLogEventType = | "calibration" | "check" @@ -25,6 +27,6 @@ export interface InstrumentLog { images: InstrumentLogImage[]; createdAt: string; // ISO datetime updatedAt: string | null; // ISO datetime - createdBy: { id: number; username: string | null; fullName: string | null } | null; - updatedBy: { id: number; username: string | null; fullName: string | null } | null; + createdBy: { id: number; username: string | null; person: Person | null } | null; + updatedBy: { id: number; username: string | null; person: Person | null } | null; } diff --git a/shared/lib/entity/InstrumentLogPermission.ts b/shared/lib/entity/InstrumentLogPermission.ts index 5ba96d604..8442a621d 100644 --- a/shared/lib/entity/InstrumentLogPermission.ts +++ b/shared/lib/entity/InstrumentLogPermission.ts @@ -1,7 +1,6 @@ export type InstrumentLogPermissionType = "canReadLogs" | "canWriteLogs"; export interface InstrumentLogPermission { - id: number; permission: InstrumentLogPermissionType; instrumentInfoUuid: string | null; } diff --git a/shared/lib/entity/Person.ts b/shared/lib/entity/Person.ts index 47a6c91da..fa7902976 100644 --- a/shared/lib/entity/Person.ts +++ b/shared/lib/entity/Person.ts @@ -1,7 +1,7 @@ export interface Person { id: number; - firstname: string; - surname: string; + firstName: string; + lastName: string; orcid: string | null; } diff --git a/shared/lib/entity/UserAccount.ts b/shared/lib/entity/UserAccount.ts index f79d08889..de18a6689 100644 --- a/shared/lib/entity/UserAccount.ts +++ b/shared/lib/entity/UserAccount.ts @@ -1,11 +1,11 @@ import type { Permission } from "./Permission"; import type { InstrumentLogPermission } from "./InstrumentLogPermission"; +import type { Person } from "./Person"; export interface UserAccount { id: number; username: string | null; - fullName: string | null; - personId: number | null; + person: Person | null; permissions: Permission[]; instrumentLogPermissions: InstrumentLogPermission[]; }