feat(app): wire dashboard administration flows
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { App } from '../ts/reception/classes.app.js';
|
||||
import { Organization } from '../ts/reception/classes.organization.js';
|
||||
import { Role } from '../ts/reception/classes.role.js';
|
||||
import { User } from '../ts/reception/classes.user.js';
|
||||
|
||||
export type TSeedScenario = 'admin' | 'workspace' | 'globalApps';
|
||||
|
||||
export interface ISeedOptions {
|
||||
scenario: TSeedScenario;
|
||||
adminEmail: string;
|
||||
adminPassword: string;
|
||||
adminName: string;
|
||||
organizationName: string;
|
||||
organizationSlug: string;
|
||||
}
|
||||
|
||||
export class SeedRunner {
|
||||
public qenv = new plugins.qenv.Qenv('./', './.nogit', false);
|
||||
public smartdataDb: plugins.smartdata.SmartdataDb;
|
||||
|
||||
public CUser = plugins.smartdata.setDefaultManagerForDoc(this, User);
|
||||
public COrganization = plugins.smartdata.setDefaultManagerForDoc(this, Organization);
|
||||
public CRole = plugins.smartdata.setDefaultManagerForDoc(this, Role);
|
||||
public CApp = plugins.smartdata.setDefaultManagerForDoc(this, App);
|
||||
|
||||
public get db() {
|
||||
return this.smartdataDb;
|
||||
}
|
||||
|
||||
public async start() {
|
||||
const mongoDbUrl = await this.qenv.getEnvVarOnDemandStrict('MONGODB_URL');
|
||||
this.smartdataDb = new plugins.smartdata.SmartdataDb({ mongoDbUrl });
|
||||
await this.smartdataDb.init();
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
if (this.smartdataDb) {
|
||||
await this.smartdataDb.close();
|
||||
}
|
||||
}
|
||||
|
||||
public async seed(optionsArg: ISeedOptions) {
|
||||
if (optionsArg.scenario === 'globalApps') {
|
||||
await this.seedGlobalApps();
|
||||
return;
|
||||
}
|
||||
|
||||
const adminUser = await this.seedAdminUser(optionsArg);
|
||||
const organization = await this.seedOrganization(optionsArg, adminUser.id);
|
||||
await this.seedOwnerRole(adminUser.id, organization.id);
|
||||
await this.seedGlobalApps();
|
||||
|
||||
if (optionsArg.scenario === 'workspace') {
|
||||
await this.seedWorkspaceUsers(organization.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async seedAdminUser(optionsArg: ISeedOptions) {
|
||||
let adminUser = await this.CUser.getInstance({
|
||||
data: {
|
||||
email: optionsArg.adminEmail,
|
||||
},
|
||||
});
|
||||
|
||||
if (!adminUser) {
|
||||
adminUser = await this.CUser.createNewUserForUserData({
|
||||
name: optionsArg.adminName,
|
||||
username: optionsArg.adminEmail,
|
||||
email: optionsArg.adminEmail,
|
||||
password: optionsArg.adminPassword,
|
||||
status: 'active',
|
||||
connectedOrgs: [],
|
||||
});
|
||||
}
|
||||
|
||||
adminUser.data.name = optionsArg.adminName;
|
||||
adminUser.data.username = optionsArg.adminEmail;
|
||||
adminUser.data.email = optionsArg.adminEmail;
|
||||
adminUser.data.status = 'active';
|
||||
adminUser.data.isGlobalAdmin = true;
|
||||
adminUser.data.passwordHash = await this.CUser.hashPassword(optionsArg.adminPassword);
|
||||
await adminUser.save();
|
||||
|
||||
return adminUser;
|
||||
}
|
||||
|
||||
private async seedOrganization(optionsArg: ISeedOptions, adminUserIdArg: string) {
|
||||
let organization = await this.COrganization.getInstance({
|
||||
data: {
|
||||
slug: optionsArg.organizationSlug,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization) {
|
||||
organization = await this.COrganization.createNewOrganizationForUser(
|
||||
this as any,
|
||||
adminUserIdArg,
|
||||
optionsArg.organizationName,
|
||||
optionsArg.organizationSlug,
|
||||
);
|
||||
}
|
||||
|
||||
organization.data.name = optionsArg.organizationName;
|
||||
organization.data.slug = optionsArg.organizationSlug;
|
||||
organization.data.roleIds = organization.data.roleIds || [];
|
||||
this.seedDefaultOrgRoleDefinitions(organization);
|
||||
await organization.save();
|
||||
|
||||
const adminUser = await this.CUser.getInstance({ id: adminUserIdArg });
|
||||
if (adminUser && !adminUser.data.connectedOrgs.includes(organization.id)) {
|
||||
adminUser.data.connectedOrgs.push(organization.id);
|
||||
await adminUser.save();
|
||||
}
|
||||
|
||||
return organization;
|
||||
}
|
||||
|
||||
private seedDefaultOrgRoleDefinitions(organizationArg: Organization) {
|
||||
const now = Date.now();
|
||||
const defaultRoleDefinitions = [
|
||||
{ key: 'finance', name: 'Finance', description: 'Billing, invoice, and procurement access.' },
|
||||
{ key: 'engineering', name: 'Engineering', description: 'Developer and infrastructure access.' },
|
||||
{ key: 'support', name: 'Support', description: 'Customer and incident support access.' },
|
||||
{ key: 'contractor', name: 'Contractor', description: 'Limited temporary external access.' },
|
||||
];
|
||||
const roleDefinitions = organizationArg.data.roleDefinitions || [];
|
||||
for (const defaultRoleDefinition of defaultRoleDefinitions) {
|
||||
const existingRoleDefinition = roleDefinitions.find((roleDefinitionArg) => roleDefinitionArg.key === defaultRoleDefinition.key);
|
||||
if (existingRoleDefinition) {
|
||||
existingRoleDefinition.name = defaultRoleDefinition.name;
|
||||
existingRoleDefinition.description = defaultRoleDefinition.description;
|
||||
existingRoleDefinition.updatedAt = now;
|
||||
} else {
|
||||
roleDefinitions.push({
|
||||
...defaultRoleDefinition,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
}
|
||||
organizationArg.data.roleDefinitions = roleDefinitions.sort((leftArg, rightArg) => leftArg.name.localeCompare(rightArg.name));
|
||||
}
|
||||
|
||||
private async seedOwnerRole(userIdArg: string, organizationIdArg: string) {
|
||||
let role = await this.CRole.getInstance({
|
||||
data: {
|
||||
userId: userIdArg,
|
||||
organizationId: organizationIdArg,
|
||||
},
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
role = new this.CRole();
|
||||
role.id = plugins.smartunique.shortId();
|
||||
role.data = {
|
||||
userId: userIdArg,
|
||||
organizationId: organizationIdArg,
|
||||
roles: ['owner', 'admin'],
|
||||
};
|
||||
} else {
|
||||
role.data.roles = [...new Set([...role.data.roles, 'owner', 'admin'])];
|
||||
}
|
||||
await role.save();
|
||||
|
||||
const organization = await this.COrganization.getInstance({ id: organizationIdArg });
|
||||
if (organization && !organization.data.roleIds.includes(role.id)) {
|
||||
organization.data.roleIds.push(role.id);
|
||||
await organization.save();
|
||||
}
|
||||
}
|
||||
|
||||
private async seedWorkspaceUsers(organizationIdArg: string) {
|
||||
const users = [
|
||||
{
|
||||
email: 'alex@idp.global',
|
||||
name: 'Alex Mercer',
|
||||
roles: ['admin'],
|
||||
},
|
||||
{
|
||||
email: 'jane@idp.global',
|
||||
name: 'Jane Doe',
|
||||
roles: ['editor'],
|
||||
},
|
||||
{
|
||||
email: 'sam@idp.global',
|
||||
name: 'Sam Chen',
|
||||
roles: ['viewer'],
|
||||
},
|
||||
];
|
||||
|
||||
for (const userData of users) {
|
||||
let user = await this.CUser.getInstance({
|
||||
data: {
|
||||
email: userData.email,
|
||||
},
|
||||
});
|
||||
if (!user) {
|
||||
user = await this.CUser.createNewUserForUserData({
|
||||
name: userData.name,
|
||||
username: userData.email,
|
||||
email: userData.email,
|
||||
password: 'idp.global',
|
||||
status: 'active',
|
||||
connectedOrgs: [],
|
||||
});
|
||||
}
|
||||
user.data.name = userData.name;
|
||||
user.data.username = userData.email;
|
||||
user.data.status = 'active';
|
||||
user.data.passwordHash = await this.CUser.hashPassword('idp.global');
|
||||
if (!user.data.connectedOrgs.includes(organizationIdArg)) {
|
||||
user.data.connectedOrgs.push(organizationIdArg);
|
||||
}
|
||||
await user.save();
|
||||
|
||||
let role = await this.CRole.getInstance({
|
||||
data: {
|
||||
userId: user.id,
|
||||
organizationId: organizationIdArg,
|
||||
},
|
||||
});
|
||||
if (!role) {
|
||||
role = new this.CRole();
|
||||
role.id = plugins.smartunique.shortId();
|
||||
}
|
||||
role.data = {
|
||||
userId: user.id,
|
||||
organizationId: organizationIdArg,
|
||||
roles: userData.roles,
|
||||
};
|
||||
await role.save();
|
||||
|
||||
const organization = await this.COrganization.getInstance({ id: organizationIdArg });
|
||||
if (organization && !organization.data.roleIds.includes(role.id)) {
|
||||
organization.data.roleIds.push(role.id);
|
||||
await organization.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async seedGlobalApps() {
|
||||
const defaultGlobalApps: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logoUrl: string;
|
||||
appUrl: string;
|
||||
clientId: string;
|
||||
redirectUris: string[];
|
||||
category: string;
|
||||
}> = [
|
||||
{
|
||||
id: 'app-foss-global',
|
||||
name: 'foss.global',
|
||||
description: 'Open Source Package Registry and Collaboration Platform',
|
||||
logoUrl: 'https://foss.global/assets/logo.png',
|
||||
appUrl: 'https://foss.global',
|
||||
clientId: 'foss-global-client',
|
||||
redirectUris: ['https://foss.global/auth/callback'],
|
||||
category: 'Development',
|
||||
},
|
||||
{
|
||||
id: 'app-task-vc',
|
||||
name: 'task.vc',
|
||||
description: 'Task Management and Project Collaboration',
|
||||
logoUrl: 'https://task.vc/assets/logo.png',
|
||||
appUrl: 'https://task.vc',
|
||||
clientId: 'task-vc-client',
|
||||
redirectUris: ['https://task.vc/auth/callback'],
|
||||
category: 'Productivity',
|
||||
},
|
||||
{
|
||||
id: 'app-hetzner-cloud',
|
||||
name: 'Hetzner Cloud',
|
||||
description: 'Cloud infrastructure console access',
|
||||
logoUrl: 'https://www.hetzner.com/favicon.ico',
|
||||
appUrl: 'https://console.hetzner.cloud',
|
||||
clientId: 'hetzner-cloud-client',
|
||||
redirectUris: ['https://console.hetzner.cloud/oauth/callback'],
|
||||
category: 'Infrastructure',
|
||||
},
|
||||
];
|
||||
|
||||
for (const appData of defaultGlobalApps) {
|
||||
let app = await this.CApp.getInstance({ id: appData.id });
|
||||
if (!app) {
|
||||
app = new this.CApp();
|
||||
app.id = appData.id;
|
||||
app.type = 'global';
|
||||
}
|
||||
app.data = {
|
||||
name: appData.name,
|
||||
description: appData.description,
|
||||
logoUrl: appData.logoUrl,
|
||||
appUrl: appData.appUrl,
|
||||
oauthCredentials: {
|
||||
clientId: appData.clientId,
|
||||
clientSecretHash: '',
|
||||
redirectUris: appData.redirectUris,
|
||||
allowedScopes: ['openid', 'profile', 'email', 'organizations'],
|
||||
grantTypes: ['authorization_code', 'refresh_token'],
|
||||
},
|
||||
isActive: true,
|
||||
category: appData.category,
|
||||
createdAt: Date.now(),
|
||||
createdByUserId: 'seed',
|
||||
};
|
||||
await app.save();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { runCli } from './index.js';
|
||||
|
||||
await runCli();
|
||||
@@ -0,0 +1,136 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { SeedRunner, type ISeedOptions, type TSeedScenario } from './classes.seedrunner.js';
|
||||
|
||||
export { SeedRunner } from './classes.seedrunner.js';
|
||||
|
||||
const defaults: ISeedOptions = {
|
||||
scenario: 'workspace',
|
||||
adminEmail: 'admin@idp.global',
|
||||
adminPassword: 'idp.global',
|
||||
adminName: 'IDP Global Admin',
|
||||
organizationName: 'Lossless GmbH',
|
||||
organizationSlug: 'lossless',
|
||||
};
|
||||
|
||||
const scenarios: TSeedScenario[] = ['admin', 'workspace', 'globalApps'];
|
||||
|
||||
const getArgValue = (nameArg: string) => {
|
||||
const prefix = `--${nameArg}=`;
|
||||
const prefixedArg = plugins.process.argv.find((arg) => arg.startsWith(prefix));
|
||||
if (prefixedArg) {
|
||||
return prefixedArg.slice(prefix.length);
|
||||
}
|
||||
const argIndex = plugins.process.argv.indexOf(`--${nameArg}`);
|
||||
return argIndex >= 0 ? plugins.process.argv[argIndex + 1] : undefined;
|
||||
};
|
||||
|
||||
const getScenarioFromArgs = (): TSeedScenario | null => {
|
||||
const scenarioArg = getArgValue('scenario') as TSeedScenario | undefined;
|
||||
return scenarioArg && scenarios.includes(scenarioArg) ? scenarioArg : null;
|
||||
};
|
||||
|
||||
export const runCli = async () => {
|
||||
const skipPrompts = plugins.process.argv.includes('--yes') || plugins.process.argv.includes('-y');
|
||||
if (skipPrompts) {
|
||||
const scenario = getScenarioFromArgs() || defaults.scenario;
|
||||
const runner = new SeedRunner();
|
||||
await runner.start();
|
||||
try {
|
||||
await runner.seed({
|
||||
...defaults,
|
||||
scenario,
|
||||
adminEmail: getArgValue('adminEmail') || plugins.process.env.IDP_DEMO_ADMIN_EMAIL || defaults.adminEmail,
|
||||
adminPassword: getArgValue('adminPassword') || plugins.process.env.IDP_DEMO_ADMIN_PASSWORD || defaults.adminPassword,
|
||||
adminName: getArgValue('adminName') || plugins.process.env.IDP_DEMO_ADMIN_NAME || defaults.adminName,
|
||||
organizationName: getArgValue('organizationName') || plugins.process.env.IDP_DEMO_ORG_NAME || defaults.organizationName,
|
||||
organizationSlug: getArgValue('organizationSlug') || plugins.process.env.IDP_DEMO_ORG_SLUG || defaults.organizationSlug,
|
||||
});
|
||||
console.log('Seed complete.');
|
||||
} finally {
|
||||
await runner.stop();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const interact = new plugins.smartinteract.SmartInteract();
|
||||
|
||||
const scenarioAnswer = await interact.askQuestion({
|
||||
name: 'scenario',
|
||||
type: 'list',
|
||||
message: 'Which seed scenario do you want to apply?',
|
||||
default: defaults.scenario,
|
||||
choices: [
|
||||
{ name: 'Demo workspace (admin, org, demo users, global apps)', value: 'workspace' },
|
||||
{ name: 'Admin only (admin, org, global apps)', value: 'admin' },
|
||||
{ name: 'Global apps only', value: 'globalApps' },
|
||||
],
|
||||
});
|
||||
|
||||
const scenario = scenarioAnswer.value as TSeedScenario;
|
||||
const options: ISeedOptions = {
|
||||
...defaults,
|
||||
scenario,
|
||||
};
|
||||
|
||||
if (scenario !== 'globalApps') {
|
||||
options.adminEmail = (await interact.askQuestion({
|
||||
name: 'adminEmail',
|
||||
type: 'input',
|
||||
message: 'Admin email:',
|
||||
default: defaults.adminEmail,
|
||||
})).value as string;
|
||||
|
||||
options.adminPassword = (await interact.askQuestion({
|
||||
name: 'adminPassword',
|
||||
type: 'password',
|
||||
message: 'Admin password:',
|
||||
default: defaults.adminPassword,
|
||||
})).value as string;
|
||||
|
||||
options.adminName = (await interact.askQuestion({
|
||||
name: 'adminName',
|
||||
type: 'input',
|
||||
message: 'Admin display name:',
|
||||
default: defaults.adminName,
|
||||
})).value as string;
|
||||
|
||||
options.organizationName = (await interact.askQuestion({
|
||||
name: 'organizationName',
|
||||
type: 'input',
|
||||
message: 'Organization name:',
|
||||
default: defaults.organizationName,
|
||||
})).value as string;
|
||||
|
||||
options.organizationSlug = (await interact.askQuestion({
|
||||
name: 'organizationSlug',
|
||||
type: 'input',
|
||||
message: 'Organization slug:',
|
||||
default: defaults.organizationSlug,
|
||||
})).value as string;
|
||||
}
|
||||
|
||||
const confirmAnswer = await interact.askQuestion({
|
||||
name: 'confirm',
|
||||
type: 'confirm',
|
||||
message: `Apply ${scenario} seed data to the configured database?`,
|
||||
default: false,
|
||||
});
|
||||
|
||||
if (!confirmAnswer.value) {
|
||||
console.log('Seed cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
const runner = new SeedRunner();
|
||||
await runner.start();
|
||||
try {
|
||||
await runner.seed(options);
|
||||
console.log('Seed complete.');
|
||||
if (scenario !== 'globalApps') {
|
||||
console.log(`Admin email: ${options.adminEmail}`);
|
||||
console.log(`Admin password: ${options.adminPassword}`);
|
||||
}
|
||||
} finally {
|
||||
await runner.stop();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
// Node scope
|
||||
import * as process from 'node:process';
|
||||
export { process };
|
||||
|
||||
// @push.rocks scope
|
||||
import * as qenv from '@push.rocks/qenv';
|
||||
import * as smartdata from '@push.rocks/smartdata';
|
||||
import * as smartinteract from '@push.rocks/smartinteract';
|
||||
import * as smartunique from '@push.rocks/smartunique';
|
||||
|
||||
export { qenv, smartdata, smartinteract, smartunique };
|
||||
@@ -0,0 +1,22 @@
|
||||
# ts_seed
|
||||
|
||||
Interactive development seed tooling for local idp.global databases.
|
||||
|
||||
Run from the app repository root:
|
||||
|
||||
```bash
|
||||
pnpm run seed
|
||||
```
|
||||
|
||||
The CLI reads the same qenv setup as the app, including `.nogit/env.json`, and asks before writing data.
|
||||
|
||||
Available scenarios:
|
||||
|
||||
- Demo workspace: global admin, organization, demo users, and global OAuth apps.
|
||||
- Admin only: global admin, organization, and global OAuth apps.
|
||||
- Global apps only: first-party/global OAuth app records.
|
||||
|
||||
Default development admin credentials when accepted unchanged:
|
||||
|
||||
- Email: `admin@idp.global`
|
||||
- Password: `idp.global`
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"order": 6
|
||||
}
|
||||
Reference in New Issue
Block a user