🔀 Merge remote-tracking branch 'origin/main' into release/v1.0.6
This commit is contained in:
@@ -8,41 +8,17 @@ on:
|
|||||||
- release/*
|
- release/*
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
setup:
|
|
||||||
name: Setup jobs
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
targets: ${{ steps.filter.outputs.changes }}
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: dorny/paths-filter@v3
|
|
||||||
id: filter
|
|
||||||
with:
|
|
||||||
filters: |
|
|
||||||
backend:
|
|
||||||
- "tdrive/backend/node/**"
|
|
||||||
- "tdrive/docker/tdrive-node/Dockerfile"
|
|
||||||
frontend:
|
|
||||||
- "tdrive/frontend/**"
|
|
||||||
- "tdrive/docker/tdrive-frontend/**"
|
|
||||||
onlyoffice-connector:
|
|
||||||
- "tdrive/connectors/onlyoffice-connector/**"
|
|
||||||
- "tdrive/docker/onlyoffice-connector/Dockerfile"
|
|
||||||
ldap-sync:
|
|
||||||
- "tdrive/backend/utils/ldap-sync/**"
|
|
||||||
- "tdrive/docker/tdrive-ldap-sync/Dockerfile"
|
|
||||||
nextcloud-migration:
|
|
||||||
- "tdrive/backend/utils/nextcloud-migration/**"
|
|
||||||
- "tdrive/docker/tdrive-nextcloud-migration/Dockerfile"
|
|
||||||
|
|
||||||
publish-feature:
|
publish-feature:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
targets: ${{ fromJSON(needs.setup.outputs.targets) }}
|
targets:
|
||||||
|
- backend
|
||||||
|
- frontend
|
||||||
|
- onlyoffice-connector
|
||||||
|
- ldap-sync
|
||||||
|
- nextcloud-migration
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
needs:
|
|
||||||
- setup
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
@@ -1,2 +1,10 @@
|
|||||||
export class FileNotFountException extends Error {}
|
export class FileNotFountException extends Error {
|
||||||
export class WriteFileException extends Error {}
|
constructor(readonly path: string, details: string) {
|
||||||
|
super(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class WriteFileException extends Error {
|
||||||
|
constructor(readonly path: string, details: string) {
|
||||||
|
super(details);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,19 +82,23 @@ export class OneOfStorageStrategy implements StorageConnectorAPI {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Log all errors and throw if all write operations fail
|
// Log all errors and throw if all write operations fail
|
||||||
const errors = writeResults.filter(result => result.status === "rejected");
|
let errorsCount = 0;
|
||||||
errors.forEach((error, index) => {
|
writeResults.forEach((result, index) => {
|
||||||
const storageId = this.storages[index].getId();
|
if (result.status === "rejected") {
|
||||||
logger.error(
|
const storageId = this.storages[index].getId();
|
||||||
new OneOfStorageWriteOneFailedException(
|
logger.error(
|
||||||
storageId,
|
new OneOfStorageWriteOneFailedException(
|
||||||
`Error writing to storage ${storageId}`,
|
storageId,
|
||||||
(error as PromiseRejectedResult).reason,
|
path,
|
||||||
),
|
`Error writing to storage ${storageId}`,
|
||||||
);
|
(result as PromiseRejectedResult).reason,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
errorsCount++;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (errors.length === this.storages.length) {
|
if (errorsCount === this.storages.length) {
|
||||||
throw new WriteFileException(`Write ${path} failed for all storages`);
|
throw new WriteFileException(path, `Write ${path} failed for all storages`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const successResult = writeResults.filter(
|
const successResult = writeResults.filter(
|
||||||
@@ -120,13 +124,14 @@ export class OneOfStorageStrategy implements StorageConnectorAPI {
|
|||||||
logger.error(
|
logger.error(
|
||||||
new OneOfStorageReadOneFailedException(
|
new OneOfStorageReadOneFailedException(
|
||||||
storage.getId(),
|
storage.getId(),
|
||||||
|
path,
|
||||||
`Reading ${path} from storage ${storage} failed.`,
|
`Reading ${path} from storage ${storage} failed.`,
|
||||||
err,
|
err,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new FileNotFountException(`Error reading ${path}`);
|
throw new FileNotFountException(path, `Error reading ${path}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -137,8 +142,17 @@ export class OneOfStorageStrategy implements StorageConnectorAPI {
|
|||||||
*/
|
*/
|
||||||
exists = async (path: string, options?: ReadOptions): Promise<boolean> => {
|
exists = async (path: string, options?: ReadOptions): Promise<boolean> => {
|
||||||
for (const storage of this.storages) {
|
for (const storage of this.storages) {
|
||||||
if (await storage.exists(path, options)) {
|
try {
|
||||||
return true;
|
if (await storage.exists(path, options)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw new OneOfStorageReadOneFailedException(
|
||||||
|
storage.getId(),
|
||||||
|
path,
|
||||||
|
`Reading ${path} from storage ${storage} failed.`,
|
||||||
|
e,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -160,7 +174,7 @@ export class OneOfStorageStrategy implements StorageConnectorAPI {
|
|||||||
* Throw when read from one of the storages is filed.
|
* Throw when read from one of the storages is filed.
|
||||||
*/
|
*/
|
||||||
class StorageException extends Error {
|
class StorageException extends Error {
|
||||||
constructor(readonly storageId: string, details: string, error: Error) {
|
constructor(readonly storageId: string, readonly path: string, details: string, error: Error) {
|
||||||
super(details, error);
|
super(details, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default class StorageService extends TdriveService<StorageAPI> implements
|
|||||||
|
|
||||||
async read(path: string, options?: ReadOptions): Promise<Readable> {
|
async read(path: string, options?: ReadOptions): Promise<Readable> {
|
||||||
if (!(await this.exists(path, options))) {
|
if (!(await this.exists(path, options))) {
|
||||||
throw new FileNotFountException();
|
throw new FileNotFountException(path, "File doesn't exist");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
||||||
|
|||||||
@@ -400,7 +400,7 @@ export class DocumentsService {
|
|||||||
company_id: driveItem.company_id,
|
company_id: driveItem.company_id,
|
||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
{ waitForThumbnail: true },
|
{ waitForThumbnail: false },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1505,10 +1505,17 @@ export class DocumentsService {
|
|||||||
if (token) throw new CrudException("Invalid token", 401);
|
if (token) throw new CrudException("Invalid token", 401);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param id DriveItemID to download. If it's a folder, a zip file will be returned
|
||||||
|
* @param versionId Optional specific version to download
|
||||||
|
* @param {Function} beginArchiveTransmit If a folder,
|
||||||
|
* called when the zip file can begin streaming, otherwise not called at all.
|
||||||
|
*/
|
||||||
download = async (
|
download = async (
|
||||||
id: string,
|
id: string,
|
||||||
versionId: string | null,
|
versionId: string | null,
|
||||||
|
beginArchiveTransmit: (readable: archiver.Archiver) => void,
|
||||||
context: DriveExecutionContext,
|
context: DriveExecutionContext,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
archive?: archiver.Archiver;
|
archive?: archiver.Archiver;
|
||||||
@@ -1522,7 +1529,7 @@ export class DocumentsService {
|
|||||||
const item = await this.get(id, null, context);
|
const item = await this.get(id, null, context);
|
||||||
|
|
||||||
if (item.item.is_directory) {
|
if (item.item.is_directory) {
|
||||||
return { archive: await this.createZip([id], context) };
|
return { archive: await this.createZip([id], beginArchiveTransmit, context) };
|
||||||
}
|
}
|
||||||
|
|
||||||
let version = item.item.last_version_cache;
|
let version = item.item.last_version_cache;
|
||||||
@@ -1541,11 +1548,13 @@ export class DocumentsService {
|
|||||||
* Creates a zip archive containing the drive items.
|
* Creates a zip archive containing the drive items.
|
||||||
*
|
*
|
||||||
* @param {string[]} ids - the drive item list
|
* @param {string[]} ids - the drive item list
|
||||||
|
* @param {Function} beginArchiveTransmit - Called when the zip file can begin streaming
|
||||||
* @param {DriveExecutionContext} context - the execution context
|
* @param {DriveExecutionContext} context - the execution context
|
||||||
* @returns {Promise<archiver.Archiver>} the created archive.
|
* @returns {Promise<archiver.Archiver>} the created archive.
|
||||||
*/
|
*/
|
||||||
createZip = async (
|
createZip = async (
|
||||||
ids: string[] = [],
|
ids: string[] = [],
|
||||||
|
beginArchiveTransmit: (archive: archiver.Archiver) => void,
|
||||||
context: DriveExecutionContext,
|
context: DriveExecutionContext,
|
||||||
): Promise<archiver.Archiver> => {
|
): Promise<archiver.Archiver> => {
|
||||||
if (!context) {
|
if (!context) {
|
||||||
@@ -1561,19 +1570,31 @@ export class DocumentsService {
|
|||||||
this.logger.error("error while creating ZIP file: ", error);
|
this.logger.error("error while creating ZIP file: ", error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let didBeginTransmission = false;
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
if (!(await checkAccess(id, null, "read", this.repository, context))) {
|
if (!(await checkAccess(id, null, "read", this.repository, context))) {
|
||||||
this.logger.warn(`not enough permissions to download ${id}, skipping`);
|
this.logger.warn({ id }, `not enough permissions to download ${id}, skipping`);
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await addDriveItemToArchive(id, null, archive, this.repository, context);
|
await addDriveItemToArchive(
|
||||||
} catch (error) {
|
id,
|
||||||
console.error(error);
|
null,
|
||||||
this.logger.warn("failed to add item to archive", error);
|
archive,
|
||||||
|
archive => {
|
||||||
|
if (didBeginTransmission) return;
|
||||||
|
didBeginTransmission = true;
|
||||||
|
beginArchiveTransmit(archive);
|
||||||
|
},
|
||||||
|
this.repository,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn({ err, id }, "failed to add item to archive");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!didBeginTransmission) beginArchiveTransmit(archive);
|
||||||
|
|
||||||
//TODO[ASH] why do we need this call??
|
//TODO[ASH] why do we need this call??
|
||||||
archive.finalize();
|
archive.finalize();
|
||||||
|
|||||||
@@ -345,6 +345,9 @@ export const addDriveItemToArchive = async (
|
|||||||
id: string,
|
id: string,
|
||||||
entity: DriveFile | null,
|
entity: DriveFile | null,
|
||||||
archive: archiver.Archiver,
|
archive: archiver.Archiver,
|
||||||
|
beginArchiveTransmit:
|
||||||
|
| "ONLY_SET_THIS_VALUE_IF_ALREADY_CALLED"
|
||||||
|
| ((archive: archiver.Archiver) => void),
|
||||||
repository: Repository<DriveFile>,
|
repository: Repository<DriveFile>,
|
||||||
context: CompanyExecutionContext,
|
context: CompanyExecutionContext,
|
||||||
prefix?: string,
|
prefix?: string,
|
||||||
@@ -368,6 +371,10 @@ export const addDriveItemToArchive = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
archive.append(file.file, { name: item.name, prefix: prefix ?? "" });
|
archive.append(file.file, { name: item.name, prefix: prefix ?? "" });
|
||||||
|
if (beginArchiveTransmit !== "ONLY_SET_THIS_VALUE_IF_ALREADY_CALLED") {
|
||||||
|
beginArchiveTransmit(archive);
|
||||||
|
beginArchiveTransmit = "ONLY_SET_THIS_VALUE_IF_ALREADY_CALLED";
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
let nextPage = "";
|
let nextPage = "";
|
||||||
@@ -384,6 +391,7 @@ export const addDriveItemToArchive = async (
|
|||||||
child.id,
|
child.id,
|
||||||
child,
|
child,
|
||||||
archive,
|
archive,
|
||||||
|
beginArchiveTransmit,
|
||||||
repository,
|
repository,
|
||||||
context,
|
context,
|
||||||
`${prefix || ""}${item.name}/`,
|
`${prefix || ""}${item.name}/`,
|
||||||
@@ -490,7 +498,7 @@ export const getFileMetadata = async (
|
|||||||
company_id: context.company.id,
|
company_id: context.company.id,
|
||||||
},
|
},
|
||||||
context,
|
context,
|
||||||
{ ...(context.user?.server_request ? {} : { waitForThumbnail: true }) },
|
{ ...(context.user?.server_request ? {} : { waitForThumbnail: false }) },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export class DocumentsController {
|
|||||||
chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1,
|
chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1,
|
||||||
filename: q.resumableFilename || q.filename || file?.filename || undefined,
|
filename: q.resumableFilename || q.filename || file?.filename || undefined,
|
||||||
type: q.resumableType || q.type || file?.mimetype || undefined,
|
type: q.resumableType || q.type || file?.mimetype || undefined,
|
||||||
waitForThumbnail: !!q.thumbnail_sync,
|
waitForThumbnail: false,
|
||||||
ignoreThumbnails: false,
|
ignoreThumbnails: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
createdFile = await globalResolver.services.files.save(null, file, options, context);
|
createdFile = await globalResolver.services.files.save(null, file, options, context);
|
||||||
@@ -551,21 +551,21 @@ export class DocumentsController {
|
|||||||
const archiveOrFile = await globalResolver.services.documents.documents.download(
|
const archiveOrFile = await globalResolver.services.documents.documents.download(
|
||||||
id,
|
id,
|
||||||
versionId,
|
versionId,
|
||||||
|
archive => {
|
||||||
|
archive.on("finish", () => {
|
||||||
|
response.status(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.on("error", () => {
|
||||||
|
response.internalServerError();
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(response.raw);
|
||||||
|
},
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (archiveOrFile.archive) {
|
if (archiveOrFile.archive) {
|
||||||
const archive = archiveOrFile.archive;
|
|
||||||
|
|
||||||
archive.on("finish", () => {
|
|
||||||
response.status(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
archive.on("error", () => {
|
|
||||||
response.internalServerError();
|
|
||||||
});
|
|
||||||
|
|
||||||
archive.pipe(response.raw);
|
|
||||||
return response;
|
return response;
|
||||||
} else if (archiveOrFile.file) {
|
} else if (archiveOrFile.file) {
|
||||||
const data = archiveOrFile.file;
|
const data = archiveOrFile.file;
|
||||||
@@ -623,22 +623,27 @@ export class DocumentsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const archive = await globalResolver.services.documents.documents.createZip(ids, context);
|
await globalResolver.services.documents.documents.createZip(
|
||||||
reply.raw.setHeader(
|
ids,
|
||||||
"content-disposition",
|
archive => {
|
||||||
formatAttachmentContentDispositionHeader("twake_drive.zip"),
|
reply.raw.setHeader(
|
||||||
|
"content-disposition",
|
||||||
|
formatAttachmentContentDispositionHeader("twake_drive.zip"),
|
||||||
|
);
|
||||||
|
|
||||||
|
archive.on("finish", () => {
|
||||||
|
reply.status(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.on("error", () => {
|
||||||
|
reply.internalServerError();
|
||||||
|
});
|
||||||
|
|
||||||
|
archive.pipe(reply.raw);
|
||||||
|
},
|
||||||
|
context,
|
||||||
);
|
);
|
||||||
|
|
||||||
archive.on("finish", () => {
|
|
||||||
reply.status(200);
|
|
||||||
});
|
|
||||||
|
|
||||||
archive.on("error", () => {
|
|
||||||
reply.internalServerError();
|
|
||||||
});
|
|
||||||
|
|
||||||
archive.pipe(reply.raw);
|
|
||||||
|
|
||||||
return reply;
|
return reply;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ error: `${error}` }, "failed to send zip file");
|
logger.error({ error: `${error}` }, "failed to send zip file");
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export class FileController {
|
|||||||
chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1,
|
chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1,
|
||||||
filename: q.resumableFilename || q.filename || file?.filename || undefined,
|
filename: q.resumableFilename || q.filename || file?.filename || undefined,
|
||||||
type: q.resumableType || q.type || file?.mimetype || undefined,
|
type: q.resumableType || q.type || file?.mimetype || undefined,
|
||||||
waitForThumbnail: q.thumbnail_sync,
|
waitForThumbnail: false,
|
||||||
ignoreThumbnails: q.ignore_thumbnails,
|
ignoreThumbnails: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const id = request.params.id;
|
const id = request.params.id;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { isNumber, isString } from "lodash";
|
|||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
import gr from "../../../global-resolver";
|
import gr from "../../../global-resolver";
|
||||||
import { TYPE as DriveFileType, DriveFile } from "../../../documents/entities/drive-file";
|
import { TYPE as DriveFileType, DriveFile } from "../../../documents/entities/drive-file";
|
||||||
|
import { UpdateUser } from "./types";
|
||||||
|
import { formatUsername } from "../../../../utils/users";
|
||||||
|
|
||||||
export class UserServiceImpl {
|
export class UserServiceImpl {
|
||||||
version: "1";
|
version: "1";
|
||||||
@@ -77,14 +79,29 @@ export class UserServiceImpl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async updateExtRepositoryInCaseChangeEmail(user: User, context?: ExecutionContext) {
|
||||||
|
if (user.identity_provider_id === "null") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const extUser = await this.extUserRepository.findOne({ user_id: user.id }, {}, context);
|
||||||
|
if (extUser) {
|
||||||
|
await this.extUserRepository.remove(extUser, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newExtUser = getExternalUserInstance({
|
||||||
|
service_id: user.identity_provider || "null",
|
||||||
|
external_id: user.identity_provider_id || "null",
|
||||||
|
user_id: user.id,
|
||||||
|
});
|
||||||
|
await this.extUserRepository.save(newExtUser, context);
|
||||||
|
}
|
||||||
|
|
||||||
private assignDefaults(user: User) {
|
private assignDefaults(user: User) {
|
||||||
user.creation_date = !isNumber(user.creation_date) ? Date.now() : user.creation_date;
|
user.creation_date = !isNumber(user.creation_date) ? Date.now() : user.creation_date;
|
||||||
if (user.identity_provider_id && !user.identity_provider) user.identity_provider = "console";
|
if (user.identity_provider_id && !user.identity_provider) user.identity_provider = "console";
|
||||||
if (user.email_canonical) user.email_canonical = user.email_canonical.toLocaleLowerCase();
|
if (user.email_canonical) user.email_canonical = user.email_canonical.toLocaleLowerCase();
|
||||||
if (user.username_canonical)
|
if (user.username_canonical) user.username_canonical = formatUsername(user.username_canonical);
|
||||||
user.username_canonical = (user.username_canonical || "")
|
|
||||||
.toLocaleLowerCase()
|
|
||||||
.replace(/[^a-z0-9_-]/, "");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(user: User, context?: ExecutionContext): Promise<CreateResult<User>> {
|
async create(user: User, context?: ExecutionContext): Promise<CreateResult<User>> {
|
||||||
@@ -92,8 +109,35 @@ export class UserServiceImpl {
|
|||||||
return new CreateResult("user", user);
|
return new CreateResult("user", user);
|
||||||
}
|
}
|
||||||
|
|
||||||
update(pk: Partial<User>, item: User, context?: ExecutionContext): Promise<UpdateResult<User>> {
|
async update(
|
||||||
throw new Error("Method not implemented.");
|
instance: User,
|
||||||
|
user: UpdateUser,
|
||||||
|
context?: ExecutionContext,
|
||||||
|
): Promise<UpdateResult<User>> {
|
||||||
|
let isChangeEmail = false;
|
||||||
|
if (instance.email_canonical !== user.email) {
|
||||||
|
isChangeEmail = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
instance.email_canonical = user.email || instance.email_canonical;
|
||||||
|
instance.first_name = user.first_name || instance.first_name;
|
||||||
|
instance.last_name = user.last_name || instance.last_name;
|
||||||
|
instance.picture = user.picture || instance.picture;
|
||||||
|
instance.creation_date = !isNumber(instance.creation_date)
|
||||||
|
? Date.now()
|
||||||
|
: instance.creation_date;
|
||||||
|
if (isChangeEmail) {
|
||||||
|
instance.username_canonical = formatUsername(user.email);
|
||||||
|
instance.identity_provider_id =
|
||||||
|
instance.identity_provider_id !== "null" ? user.email : "null";
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.repository.save(instance, context);
|
||||||
|
if (isChangeEmail) {
|
||||||
|
await this.updateExtRepositoryInCaseChangeEmail(instance, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new UpdateResult("user", instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
async save(user: User, context?: ExecutionContext): Promise<SaveResult<User>> {
|
async save(user: User, context?: ExecutionContext): Promise<SaveResult<User>> {
|
||||||
|
|||||||
@@ -8,3 +8,10 @@ export type SearchUserOptions = {
|
|||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
channelId?: string;
|
channelId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateUser = {
|
||||||
|
email: string;
|
||||||
|
picture: string;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import { formatUser } from "../../../utils/users";
|
|||||||
import gr from "../../global-resolver";
|
import gr from "../../global-resolver";
|
||||||
import config from "config";
|
import config from "config";
|
||||||
import { getLogger } from "../../../core/platform/framework";
|
import { getLogger } from "../../../core/platform/framework";
|
||||||
|
import { UpdateUser } from "../services/users/types";
|
||||||
|
import { hasCompanyAdminLevel } from "../../../utils/company";
|
||||||
|
|
||||||
export class UsersCrudController
|
export class UsersCrudController
|
||||||
implements
|
implements
|
||||||
@@ -366,6 +368,53 @@ export class UsersCrudController
|
|||||||
used: quota,
|
used: quota,
|
||||||
} as UserQuota;
|
} as UserQuota;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
request: FastifyRequest<{ Body: UpdateUser; Params: UserParameters }>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
): Promise<ResourceCreateResponse<UserObject>> {
|
||||||
|
const id = request.params.id;
|
||||||
|
const context = getExecutionContext(request);
|
||||||
|
|
||||||
|
const [currentUserCompanies, requestedUserCompanies] = await Promise.all(
|
||||||
|
[context.user.id, request.params.id].map(userId =>
|
||||||
|
gr.services.users.getUserCompanies({ id: userId }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const currentUserCompaniesIds = new Set(currentUserCompanies.map(a => a.group_id));
|
||||||
|
const sameCompanies = requestedUserCompanies.filter(a =>
|
||||||
|
currentUserCompaniesIds.has(a.group_id),
|
||||||
|
);
|
||||||
|
const roles = await Promise.all(
|
||||||
|
sameCompanies.map(a => gr.services.companies.getUserRole(a.group_id, context.user?.id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!roles.some(role => hasCompanyAdminLevel(role) === true)) {
|
||||||
|
reply.unauthorized(`User ${context.user?.id} is not allowed to update user ${id}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await gr.services.users.get({ id });
|
||||||
|
if (!user) {
|
||||||
|
reply.notFound(`User ${id} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = { ...request.body };
|
||||||
|
if (user.email_canonical !== body.email) {
|
||||||
|
const userByEmail = await gr.services.users.getByEmail(body.email, context);
|
||||||
|
if (userByEmail) {
|
||||||
|
reply.conflict(`Email ${body.email} is existed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await gr.services.users.update(user, body, context);
|
||||||
|
|
||||||
|
return {
|
||||||
|
resource: await formatUser(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getExecutionContext(request: FastifyRequest): ExecutionContext {
|
function getExecutionContext(request: FastifyRequest): ExecutionContext {
|
||||||
|
|||||||
@@ -143,6 +143,13 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next)
|
|||||||
handler: usersController.recent.bind(usersController),
|
handler: usersController.recent.bind(usersController),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.route({
|
||||||
|
method: "PUT",
|
||||||
|
url: `${usersUrl}/:id`,
|
||||||
|
preValidation: [fastify.authenticate],
|
||||||
|
handler: usersController.update.bind(usersController),
|
||||||
|
});
|
||||||
|
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -87,3 +87,7 @@ export async function formatUser(
|
|||||||
|
|
||||||
return resUser;
|
return resUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatUsername(value: string) {
|
||||||
|
return (value?.replace(/[^a-z0-9_-]/, "") || "").toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,12 +51,11 @@ describe("The /users API", () => {
|
|||||||
username: "adminuser",
|
username: "adminuser",
|
||||||
firstName: "admin",
|
firstName: "admin",
|
||||||
});
|
});
|
||||||
await testDbService.createUser([workspacePk]);
|
await testDbService.createUser([workspacePk], { password: "123456" });
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
});
|
afterAll(async () => {});
|
||||||
|
|
||||||
describe("The GET /users/:id route", () => {
|
describe("The GET /users/:id route", () => {
|
||||||
it("should 401 when not authenticated", async () => {
|
it("should 401 when not authenticated", async () => {
|
||||||
@@ -136,7 +135,6 @@ describe("The /users API", () => {
|
|||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should 200 and short response for another user", async () => {
|
it("should 200 and short response for another user", async () => {
|
||||||
@@ -222,7 +220,7 @@ describe("The /users API", () => {
|
|||||||
},
|
},
|
||||||
query: {
|
query: {
|
||||||
search: "anon",
|
search: "anon",
|
||||||
company_ids: oneUser.workspace.company_id
|
company_ids: oneUser.workspace.company_id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -232,9 +230,7 @@ describe("The /users API", () => {
|
|||||||
const resources = json.resources;
|
const resources = json.resources;
|
||||||
console.log(resources);
|
console.log(resources);
|
||||||
expect(resources.length).toBe(0);
|
expect(resources.length).toBe(0);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("The GET /users/:user_id/companies route", () => {
|
describe("The GET /users/:user_id/companies route", () => {
|
||||||
@@ -354,7 +350,6 @@ describe("The /users API", () => {
|
|||||||
total_guests: expect.any(Number),
|
total_guests: expect.any(Number),
|
||||||
total_messages: expect.any(Number),
|
total_messages: expect.any(Number),
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -473,7 +468,6 @@ describe("The /users API", () => {
|
|||||||
|
|
||||||
user = await testDbService.getUserFromDb({ id: firstId });
|
user = await testDbService.getUserFromDb({ id: firstId });
|
||||||
expect(user.devices).toMatchObject([]);
|
expect(user.devices).toMatchObject([]);
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
describe("List registered devices (GET)", () => {
|
describe("List registered devices (GET)", () => {
|
||||||
@@ -545,8 +539,94 @@ describe("The /users API", () => {
|
|||||||
expect(user.devices).toMatchObject([]);
|
expect(user.devices).toMatchObject([]);
|
||||||
const device = await testDbService.getDeviceFromDb(deviceToken);
|
const device = await testDbService.getDeviceFromDb(deviceToken);
|
||||||
expect(device).toBeFalsy();
|
expect(device).toBeFalsy();
|
||||||
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("The PUT /users/:id route", () => {
|
||||||
|
it("should 200 and response for updated user", async () => {
|
||||||
|
const myId = testDbService.users[0].id;
|
||||||
|
const updatedUserId = testDbService.users[1].id;
|
||||||
|
|
||||||
|
const jwtToken = await platform.auth.getJWTToken({ sub: myId });
|
||||||
|
const response = await platform.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `${url}/users/${updatedUserId}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
email: "newvalue@gmail.com",
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "Value",
|
||||||
|
picture: "null",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const resource = response.json()["resource"];
|
||||||
|
|
||||||
|
console.log("resource: ", resource);
|
||||||
|
|
||||||
|
expect(resource).toMatchObject({
|
||||||
|
id: updatedUserId,
|
||||||
|
provider: expect.any(String),
|
||||||
|
provider_id: expect.any(String),
|
||||||
|
email: expect.any(String),
|
||||||
|
username: expect.any(String),
|
||||||
|
is_verified: expect.any(Boolean),
|
||||||
|
picture: expect.any(String),
|
||||||
|
first_name: expect.any(String),
|
||||||
|
last_name: expect.any(String),
|
||||||
|
full_name: expect.any(String),
|
||||||
|
created_at: expect.any(Number),
|
||||||
|
deleted: expect.any(Boolean),
|
||||||
|
status: null,
|
||||||
|
cache: { companies: expect.any(Array) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Separate check for last_activity
|
||||||
|
expect([null, expect.any(Number)]).toContainEqual(resource.last_activity);
|
||||||
|
|
||||||
|
expect(resource).not.toMatchObject({
|
||||||
|
locale: expect.anything(),
|
||||||
|
timezone: expect.anything(),
|
||||||
|
companies: expect.anything(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should login with new email", async () => {
|
||||||
|
const myId = testDbService.users[0].id;
|
||||||
|
const updatedUserId = testDbService.users[1].id;
|
||||||
|
|
||||||
|
const jwtToken = await platform.auth.getJWTToken({ sub: myId });
|
||||||
|
await platform.app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `${url}/users/${updatedUserId}`,
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${jwtToken}`,
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
email: "newvalue@gmail.com",
|
||||||
|
first_name: "New",
|
||||||
|
last_name: "Value",
|
||||||
|
picture: "null",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await platform.app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/internal/services/console/v1/login",
|
||||||
|
body: {
|
||||||
|
email: "newvalue@gmail.com",
|
||||||
|
password: "123456",
|
||||||
|
remember_me: true,
|
||||||
|
device: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
|
|
||||||
.email {
|
.email {
|
||||||
height: 46px !important;
|
height: 46px !important;
|
||||||
line-height: 46px !important;
|
align-items: center;
|
||||||
.icon {
|
.icon {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user