feat(apps): Add Apps subsystem: App and AppConnection models, managers, typed request handlers, web UI routes and documentation
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { AppManager } from './classes.appmanager.js';
|
||||
|
||||
@plugins.smartdata.Manager()
|
||||
export class App extends plugins.smartdata.SmartDataDbDoc<
|
||||
App,
|
||||
plugins.idpInterfaces.data.IAppDocument,
|
||||
AppManager
|
||||
> {
|
||||
// INSTANCE
|
||||
@plugins.smartdata.unI()
|
||||
id: plugins.idpInterfaces.data.IAppDocument['id'];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
type: plugins.idpInterfaces.data.IAppDocument['type'];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
data: plugins.idpInterfaces.data.IAppDocument['data'];
|
||||
|
||||
/**
|
||||
* Check if the app is a global app
|
||||
*/
|
||||
public isGlobalApp(): this is App & { type: 'global' } {
|
||||
return this.type === 'global';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the app is a partner app
|
||||
*/
|
||||
public isPartnerApp(): this is App & { type: 'partner' } {
|
||||
return this.type === 'partner';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the app is a custom OIDC app
|
||||
*/
|
||||
public isCustomOidcApp(): this is App & { type: 'custom_oidc' } {
|
||||
return this.type === 'custom_oidc';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { AppConnectionManager } from './classes.appconnectionmanager.js';
|
||||
|
||||
@plugins.smartdata.Manager()
|
||||
export class AppConnection extends plugins.smartdata.SmartDataDbDoc<
|
||||
AppConnection,
|
||||
plugins.idpInterfaces.data.IAppConnection,
|
||||
AppConnectionManager
|
||||
> {
|
||||
// INSTANCE
|
||||
@plugins.smartdata.unI()
|
||||
id: plugins.idpInterfaces.data.IAppConnection['id'];
|
||||
|
||||
@plugins.smartdata.svDb()
|
||||
data: plugins.idpInterfaces.data.IAppConnection['data'];
|
||||
|
||||
/**
|
||||
* Check if the connection is active
|
||||
*/
|
||||
public isActive(): boolean {
|
||||
return this.data.status === 'active';
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the app
|
||||
*/
|
||||
public async disconnect(): Promise<void> {
|
||||
this.data.status = 'disconnected';
|
||||
await this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconnect the app
|
||||
*/
|
||||
public async reconnect(userId: string): Promise<void> {
|
||||
this.data.status = 'active';
|
||||
this.data.connectedAt = Date.now();
|
||||
this.data.connectedByUserId = userId;
|
||||
await this.save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { Reception } from './classes.reception.js';
|
||||
import { AppConnection } from './classes.appconnection.js';
|
||||
|
||||
export class AppConnectionManager {
|
||||
public receptionRef: Reception;
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public CAppConnection = plugins.smartdata.setDefaultManagerForDoc(this, AppConnection);
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
|
||||
// Handler: Get app connections for an organization
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetAppConnections>(
|
||||
'getAppConnections',
|
||||
async (requestArg) => {
|
||||
// Verify JWT and get user
|
||||
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: jwtData.data.userId,
|
||||
});
|
||||
|
||||
// Check user has access to the organization
|
||||
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: requestArg.organizationId,
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||
data: {
|
||||
organizationId: organization.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'User not authorized for this organization'
|
||||
);
|
||||
}
|
||||
|
||||
// Get all connections for this organization
|
||||
const connections = await this.CAppConnection.getInstances({
|
||||
'data.organizationId': requestArg.organizationId,
|
||||
});
|
||||
|
||||
const connectionObjects = await Promise.all(
|
||||
connections.map(async (conn) => await conn.createSavableObject())
|
||||
);
|
||||
|
||||
return {
|
||||
connections: connectionObjects,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// Handler: Toggle app connection (connect/disconnect)
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ToggleAppConnection>(
|
||||
'toggleAppConnection',
|
||||
async (requestArg) => {
|
||||
// Verify JWT and get user
|
||||
const jwtData = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||
id: jwtData.data.userId,
|
||||
});
|
||||
|
||||
// Check user has admin access to the organization
|
||||
const organization = await this.receptionRef.organizationmanager.COrganization.getInstance({
|
||||
id: requestArg.organizationId,
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
throw new plugins.typedrequest.TypedResponseError('Organization not found');
|
||||
}
|
||||
|
||||
const isAdmin = await organization.checkIfUserIsAdmin(user);
|
||||
if (!isAdmin) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
'Only organization admins can manage app connections'
|
||||
);
|
||||
}
|
||||
|
||||
// Get the app
|
||||
const app = await this.receptionRef.appManager.getAppById(requestArg.appId);
|
||||
if (!app) {
|
||||
throw new plugins.typedrequest.TypedResponseError('App not found');
|
||||
}
|
||||
|
||||
// Find existing connection
|
||||
let connection = await this.CAppConnection.getInstance({
|
||||
'data.organizationId': requestArg.organizationId,
|
||||
'data.appId': requestArg.appId,
|
||||
});
|
||||
|
||||
if (requestArg.action === 'connect') {
|
||||
if (connection && connection.isActive()) {
|
||||
// Already connected
|
||||
return {
|
||||
success: true,
|
||||
connection: await connection.createSavableObject(),
|
||||
};
|
||||
}
|
||||
|
||||
if (connection) {
|
||||
// Reconnect existing connection
|
||||
await connection.reconnect(user.id);
|
||||
} else {
|
||||
// Create new connection
|
||||
connection = new AppConnection();
|
||||
connection.id = plugins.smartunique.shortId();
|
||||
connection.data = {
|
||||
organizationId: requestArg.organizationId,
|
||||
appId: requestArg.appId,
|
||||
appType: app.type,
|
||||
status: 'active',
|
||||
connectedAt: Date.now(),
|
||||
connectedByUserId: user.id,
|
||||
grantedScopes: app.data.oauthCredentials?.allowedScopes || [],
|
||||
};
|
||||
await connection.save();
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
connection: await connection.createSavableObject(),
|
||||
};
|
||||
} else {
|
||||
// Disconnect
|
||||
if (!connection) {
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
}
|
||||
|
||||
await connection.disconnect();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
connection: await connection.createSavableObject(),
|
||||
};
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all connections for an organization
|
||||
*/
|
||||
public async getConnectionsForOrganization(organizationId: string): Promise<AppConnection[]> {
|
||||
return await this.CAppConnection.getInstances({
|
||||
'data.organizationId': organizationId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection for a specific app and organization
|
||||
*/
|
||||
public async getConnection(
|
||||
organizationId: string,
|
||||
appId: string
|
||||
): Promise<AppConnection | null> {
|
||||
return await this.CAppConnection.getInstance({
|
||||
'data.organizationId': organizationId,
|
||||
'data.appId': appId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an app is connected to an organization
|
||||
*/
|
||||
public async isAppConnected(organizationId: string, appId: string): Promise<boolean> {
|
||||
const connection = await this.getConnection(organizationId, appId);
|
||||
return connection?.isActive() || false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import type { Reception } from './classes.reception.js';
|
||||
import { App } from './classes.app.js';
|
||||
|
||||
export class AppManager {
|
||||
public receptionRef: Reception;
|
||||
public get db() {
|
||||
return this.receptionRef.db.smartdataDb;
|
||||
}
|
||||
public typedrouter = new plugins.typedrequest.TypedRouter();
|
||||
|
||||
public CApp = plugins.smartdata.setDefaultManagerForDoc(this, App);
|
||||
|
||||
constructor(receptionRefArg: Reception) {
|
||||
this.receptionRef = receptionRefArg;
|
||||
this.receptionRef.typedrouter.addTypedRouter(this.typedrouter);
|
||||
|
||||
// Handler: Get all global apps
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalApps>(
|
||||
'getGlobalApps',
|
||||
async (requestArg) => {
|
||||
// Verify JWT
|
||||
await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||
|
||||
// Get all active global apps
|
||||
const globalApps = await this.CApp.getInstances({
|
||||
type: 'global',
|
||||
});
|
||||
|
||||
const appObjects = await Promise.all(
|
||||
globalApps.map(async (app) => await app.createSavableObject() as plugins.idpInterfaces.data.IGlobalApp)
|
||||
);
|
||||
|
||||
return {
|
||||
apps: appObjects,
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all global apps
|
||||
*/
|
||||
public async getGlobalApps(): Promise<App[]> {
|
||||
return await this.CApp.getInstances({
|
||||
type: 'global',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app by ID
|
||||
*/
|
||||
public async getAppById(appId: string): Promise<App | null> {
|
||||
return await this.CApp.getInstance({
|
||||
id: appId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed initial global apps (for development/testing)
|
||||
*/
|
||||
public async seedGlobalApps() {
|
||||
const defaultGlobalApps: Partial<plugins.idpInterfaces.data.IGlobalApp>[] = [
|
||||
{
|
||||
id: 'app-foss-global',
|
||||
type: 'global',
|
||||
data: {
|
||||
name: 'foss.global',
|
||||
description: 'Open Source Package Registry and Collaboration Platform',
|
||||
logoUrl: 'https://foss.global/assets/logo.png',
|
||||
appUrl: 'https://foss.global',
|
||||
oauthCredentials: {
|
||||
clientId: 'foss-global-client',
|
||||
clientSecretHash: '', // Will be set when OAuth is configured
|
||||
redirectUris: ['https://foss.global/auth/callback'],
|
||||
allowedScopes: ['openid', 'profile', 'email', 'organizations'],
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
},
|
||||
isActive: true,
|
||||
category: 'Development',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'app-task-vc',
|
||||
type: 'global',
|
||||
data: {
|
||||
name: 'task.vc',
|
||||
description: 'Task Management and Project Collaboration',
|
||||
logoUrl: 'https://task.vc/assets/logo.png',
|
||||
appUrl: 'https://task.vc',
|
||||
oauthCredentials: {
|
||||
clientId: 'task-vc-client',
|
||||
clientSecretHash: '',
|
||||
redirectUris: ['https://task.vc/auth/callback'],
|
||||
allowedScopes: ['openid', 'profile', 'email', 'organizations'],
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
},
|
||||
isActive: true,
|
||||
category: 'Productivity',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const appData of defaultGlobalApps) {
|
||||
const existing = await this.CApp.getInstance({ id: appData.id });
|
||||
if (!existing) {
|
||||
const app = new App();
|
||||
app.id = appData.id!;
|
||||
app.type = appData.type!;
|
||||
app.data = appData.data as any;
|
||||
await app.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { ReceptionHousekeeping } from './classes.housekeeping.js';
|
||||
import { OrganizationManager } from './classes.organizationmanager.js';
|
||||
import { RoleManager } from './classes.rolemanager.js';
|
||||
import { BillingPlanManager } from './classes.billingplanmanager.js';
|
||||
import { AppManager } from './classes.appmanager.js';
|
||||
import { AppConnectionManager } from './classes.appconnectionmanager.js';
|
||||
|
||||
export interface IReceptionOptions {
|
||||
/**
|
||||
@@ -41,6 +43,8 @@ export class Reception {
|
||||
public organizationmanager = new OrganizationManager(this);
|
||||
public roleManager = new RoleManager(this);
|
||||
public billingPlanManager = new BillingPlanManager(this);
|
||||
public appManager = new AppManager(this);
|
||||
public appConnectionManager = new AppConnectionManager(this);
|
||||
housekeeping = new ReceptionHousekeeping(this);
|
||||
|
||||
constructor(public options: IReceptionOptions) {
|
||||
|
||||
Reference in New Issue
Block a user