Browse Source

Nc feat/user delete (#9889)

* feat: account delete (WIP)

* fix: null check issue

* feat: move integration source delete logic to model level

* docs: account delete

* test: delete integration

---------

Co-authored-by: mertmit <mertmit99@gmail.com>
pull/9686/merge
Raju Udava 2 days ago committed by GitHub
parent
commit
d1d5890e5f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 33
      packages/noco-docs/docs/140.account-settings/010.profile-page.md
  2. BIN
      packages/noco-docs/static/img/v2/account-settings/account-delete-1.png
  3. BIN
      packages/noco-docs/static/img/v2/account-settings/account-delete-2.png
  4. BIN
      packages/noco-docs/static/img/v2/account-settings/account-delete-3.png
  5. 1
      packages/nocodb/src/models/ApiToken.ts
  6. 34
      packages/nocodb/src/models/Integration.spec.ts
  7. 49
      packages/nocodb/src/models/Integration.ts
  8. 21
      packages/nocodb/src/models/User.ts
  9. 64
      packages/nocodb/src/services/integrations.service.ts
  10. 10
      packages/nocodb/src/services/sources.service.ts

33
packages/noco-docs/docs/140.account-settings/010.profile-page.md

@ -11,7 +11,36 @@ Profile page is the place where you can manage your profile information. Current
3. Change `Profile name` 3. Change `Profile name`
4. Click on `Save` button to save the changes 4. Click on `Save` button to save the changes
![profile page](/img/v2/account-settings/account-settings.png) ![delete account](/img/v2/account-settings/account-delete-1.png)
![profile page](/img/v2/account-settings/profile-page.png) ![profile page](/img/v2/account-settings/profile-page.png)
## Delete Account
:::note
This option is available currently only for the cloud users.
:::
To delete your account permanently,
1. Click on `User menu` in the bottom left corner of the sidebar,
2. Select `Account Settings` from the dropdown
3. In the section for delete account, click on `Delete Account` button
4. Confirmation modal displays the Workspaces, Bases & Tokens that will be invalidated upon deletion. As a confirmation step, you will be asked to enter your email address associated with the account.
5. Click on `Delete Account` button to delete the account permanently.
![delete account](/img/v2/account-settings/account-delete-1.png)
![delete account](/img/v2/account-settings/account-delete-2.png)
![delete account](/img/v2/account-settings/account-delete-3.png)
:::warning
- Deleting the account is irreversible and all the data associated with the account will be lost permanently.
- All the workspaces & bases for which you are the sole owner will be deleted. For all other workspaces & bases, you access permissions will be revoked.
- All the tokens associated with the account will be invalidated.
- You will be allowed to re-register with the same email address. However, you will require invite from the existing workspace owner to access the workspaces & bases.
:::

BIN
packages/noco-docs/static/img/v2/account-settings/account-delete-1.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

BIN
packages/noco-docs/static/img/v2/account-settings/account-delete-2.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

BIN
packages/noco-docs/static/img/v2/account-settings/account-delete-3.png vendored

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

1
packages/nocodb/src/models/ApiToken.ts

@ -11,6 +11,7 @@ import Noco from '~/Noco';
import NocoCache from '~/cache/NocoCache'; import NocoCache from '~/cache/NocoCache';
export default class ApiToken implements ApiTokenType { export default class ApiToken implements ApiTokenType {
id?: string;
fk_workspace_id?: string; fk_workspace_id?: string;
base_id?: string; base_id?: string;
fk_user_id?: string; fk_user_id?: string;

34
packages/nocodb/src/models/Integration.spec.ts

@ -5,6 +5,21 @@ import { decryptPropIfRequired, isEE } from '~/utils';
jest.mock('~/Noco'); jest.mock('~/Noco');
const knexGenericMock = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
whereNull: jest.fn().mockReturnThis(),
orWhereNull: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
clone: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
};
describe('Integration Model', () => { describe('Integration Model', () => {
let integration: Integration; let integration: Integration;
let mockNcMeta: jest.Mocked<any>; let mockNcMeta: jest.Mocked<any>;
@ -38,17 +53,7 @@ describe('Integration Model', () => {
]; ];
// Mock the knex function // Mock the knex function
mockNcMeta.knex = jest.fn().mockReturnValue({ mockNcMeta.knex = jest.fn().mockReturnValue({
select: jest.fn().mockReturnThis(), ...knexGenericMock,
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
whereNull: jest.fn().mockReturnThis(),
orWhereNull: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
clone: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
then: jest then: jest
.fn() .fn()
.mockImplementation((callback) => .mockImplementation((callback) =>
@ -271,6 +276,13 @@ describe('Integration Model', () => {
describe('delete', () => { describe('delete', () => {
it('should delete an integration', async () => { it('should delete an integration', async () => {
mockNcMeta.knex = jest.fn().mockReturnValue({
...knexGenericMock,
then: jest
.fn()
.mockImplementation((callback) => Promise.resolve(callback([]))),
});
await integration.delete(mockNcMeta); await integration.delete(mockNcMeta);
expect(mockNcMeta.metaDelete).toHaveBeenCalledWith( expect(mockNcMeta.metaDelete).toHaveBeenCalledWith(

49
packages/nocodb/src/models/Integration.ts

@ -24,7 +24,7 @@ import {
partialExtract, partialExtract,
} from '~/utils'; } from '~/utils';
import { PagedResponseImpl } from '~/helpers/PagedResponse'; import { PagedResponseImpl } from '~/helpers/PagedResponse';
import { IntegrationStore } from '~/models'; import { IntegrationStore, Source } from '~/models';
export default class Integration implements IntegrationType { export default class Integration implements IntegrationType {
public static availableIntegrations: { public static availableIntegrations: {
@ -455,17 +455,39 @@ export default class Integration implements IntegrationType {
} }
async delete(ncMeta = Noco.ncMeta) { async delete(ncMeta = Noco.ncMeta) {
const res = await ncMeta.metaDelete( const sources = await this.getSources(ncMeta, true);
for (const source of sources) {
await source.delete(
{
workspace_id: this.fk_workspace_id,
base_id: source.base_id,
},
ncMeta,
);
}
return await ncMeta.metaDelete(
this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE, this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE,
RootScopes.WORKSPACE, RootScopes.WORKSPACE,
MetaTable.INTEGRATIONS, MetaTable.INTEGRATIONS,
this.id, this.id,
); );
return res;
} }
async softDelete(ncMeta = Noco.ncMeta) { async softDelete(ncMeta = Noco.ncMeta) {
const sources = await this.getSources(ncMeta, true);
for (const source of sources) {
await source.softDelete(
{
workspace_id: this.fk_workspace_id,
base_id: source.base_id,
},
ncMeta,
);
}
await ncMeta.metaUpdate( await ncMeta.metaUpdate(
this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE, this.fk_workspace_id ? this.fk_workspace_id : RootScopes.WORKSPACE,
RootScopes.WORKSPACE, RootScopes.WORKSPACE,
@ -477,11 +499,10 @@ export default class Integration implements IntegrationType {
); );
} }
async getSources(ncMeta = Noco.ncMeta): Promise<any> { async getSources(ncMeta = Noco.ncMeta, force = false): Promise<Source[]> {
const qb = ncMeta.knex(MetaTable.SOURCES); const qb = ncMeta.knex(MetaTable.SOURCES);
const sources = await qb qb.select(`${MetaTable.SOURCES}.id`)
.select(`${MetaTable.SOURCES}.id`)
.select(`${MetaTable.SOURCES}.alias`) .select(`${MetaTable.SOURCES}.alias`)
.select(`${MetaTable.PROJECT}.title as project_title`) .select(`${MetaTable.PROJECT}.title as project_title`)
.select(`${MetaTable.SOURCES}.base_id`) .select(`${MetaTable.SOURCES}.base_id`)
@ -490,19 +511,23 @@ export default class Integration implements IntegrationType {
`${MetaTable.SOURCES}.base_id`, `${MetaTable.SOURCES}.base_id`,
`${MetaTable.PROJECT}.id`, `${MetaTable.PROJECT}.id`,
) )
.where(`${MetaTable.SOURCES}.fk_integration_id`, this.id) .where(`${MetaTable.SOURCES}.fk_integration_id`, this.id);
.where((whereQb) => {
if (!force) {
qb.where((whereQb) => {
whereQb whereQb
.where(`${MetaTable.SOURCES}.deleted`, false) .where(`${MetaTable.SOURCES}.deleted`, false)
.orWhereNull(`${MetaTable.SOURCES}.deleted`); .orWhereNull(`${MetaTable.SOURCES}.deleted`);
}) }).where((whereQb) => {
.where((whereQb) => {
whereQb whereQb
.where(`${MetaTable.PROJECT}.deleted`, false) .where(`${MetaTable.PROJECT}.deleted`, false)
.orWhereNull(`${MetaTable.PROJECT}.deleted`); .orWhereNull(`${MetaTable.PROJECT}.deleted`);
}); });
}
const sources = await qb;
return (this.sources = sources); return (this.sources = sources.map((src) => new Source(src)));
} }
static async getCategoryDefault( static async getCategoryDefault(

21
packages/nocodb/src/models/User.ts

@ -37,6 +37,9 @@ export default class User implements UserType {
blocked?: boolean; blocked?: boolean;
blocked_reason?: string; blocked_reason?: string;
deleted_at?: Date;
is_deleted?: boolean;
constructor(data: User) { constructor(data: User) {
Object.assign(this, data); Object.assign(this, data);
} }
@ -155,6 +158,11 @@ export default class User implements UserType {
); );
await NocoCache.set(`${CacheScope.USER}:${email}`, user); await NocoCache.set(`${CacheScope.USER}:${email}`, user);
} }
if (user?.is_deleted) {
return null;
}
return this.castType(user); return this.castType(user);
} }
@ -200,6 +208,11 @@ export default class User implements UserType {
); );
await NocoCache.set(`${CacheScope.USER}:${userId}`, user); await NocoCache.set(`${CacheScope.USER}:${userId}`, user);
} }
if (user?.is_deleted) {
return null;
}
return this.castType(user); return this.castType(user);
} }
@ -213,12 +226,18 @@ export default class User implements UserType {
return null; return null;
} }
return await ncMeta.metaGet2( const user = await ncMeta.metaGet2(
RootScopes.ROOT, RootScopes.ROOT,
RootScopes.ROOT, RootScopes.ROOT,
MetaTable.USERS, MetaTable.USERS,
userRefreshToken.fk_user_id, userRefreshToken.fk_user_id,
); );
if (user?.is_deleted) {
return null;
}
return this.castType(user);
} }
public static async list( public static async list(

64
packages/nocodb/src/services/integrations.service.ts

@ -152,20 +152,6 @@ export class IntegrationsService {
NcError.integrationLinkedWithMultiple(bases, sources); NcError.integrationLinkedWithMultiple(bases, sources);
} }
for (const source of sources) {
await this.sourcesService.baseDelete(
{
workspace_id: integration.fk_workspace_id,
base_id: source.base_id,
},
{
sourceId: source.id,
req: param.req,
},
ncMeta,
);
}
await integration.delete(ncMeta); await integration.delete(ncMeta);
this.appHooksService.emit(AppEvents.INTEGRATION_DELETE, { this.appHooksService.emit(AppEvents.INTEGRATION_DELETE, {
integration, integration,
@ -179,19 +165,65 @@ export class IntegrationsService {
if (e instanceof NcError || e instanceof NcBaseError) throw e; if (e instanceof NcError || e instanceof NcBaseError) throw e;
NcError.badRequest(e); NcError.badRequest(e);
} }
return true; return true;
} }
async integrationSoftDelete( async integrationSoftDelete(
context: Omit<NcContext, 'base_id'>, context: Omit<NcContext, 'base_id'>,
param: { integrationId: string }, param: { integrationId: string; req: any },
) { ) {
try { try {
const integration = await Integration.get(context, param.integrationId); const integration = await Integration.get(context, param.integrationId);
if (!integration) { if (!integration) {
NcError.integrationNotFound(param.integrationId); NcError.integrationNotFound(param.integrationId);
} }
await integration.softDelete();
const ncMeta = await Noco.ncMeta.startTransaction();
try {
// get linked sources
const sourceListQb = ncMeta
.knex(MetaTable.SOURCES)
.where({
fk_integration_id: integration.id,
})
.where((qb) => {
qb.where('deleted', false).orWhere('deleted', null);
});
if (integration.fk_workspace_id) {
sourceListQb.where('fk_workspace_id', integration.fk_workspace_id);
}
const sources: Pick<Source, 'id' | 'base_id'>[] =
await sourceListQb.select('id', 'base_id');
for (const source of sources) {
await this.sourcesService.baseSoftDelete(
{
workspace_id: integration.fk_workspace_id,
base_id: source.base_id,
},
{
sourceId: source.id,
},
ncMeta,
);
}
await integration.softDelete(ncMeta);
this.appHooksService.emit(AppEvents.INTEGRATION_DELETE, {
integration,
req: param.req,
user: param.req?.user,
});
await ncMeta.commit();
} catch (e) {
await ncMeta.rollback(e);
if (e instanceof NcError || e instanceof NcBaseError) throw e;
NcError.badRequest(e);
}
} catch (e) { } catch (e) {
NcError.badRequest(e); NcError.badRequest(e);
} }

10
packages/nocodb/src/services/sources.service.ts

@ -82,10 +82,14 @@ export class SourcesService {
return true; return true;
} }
async baseSoftDelete(context: NcContext, param: { sourceId: string }) { async baseSoftDelete(
context: NcContext,
param: { sourceId: string },
ncMeta = Noco.ncMeta,
) {
try { try {
const source = await Source.get(context, param.sourceId); const source = await Source.get(context, param.sourceId, false, ncMeta);
await source.softDelete(context); await source.softDelete(context, ncMeta);
} catch (e) { } catch (e) {
NcError.badRequest(e); NcError.badRequest(e);
} }

Loading…
Cancel
Save