feat(app): wire dashboard administration flows

This commit is contained in:
2026-05-07 15:35:37 +00:00
parent e9eb9b4172
commit 91f06ccae1
91 changed files with 4087 additions and 5863 deletions
+312
View File
@@ -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();
}
}
}
+3
View File
@@ -0,0 +1,3 @@
import { runCli } from './index.js';
await runCli();
+136
View File
@@ -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();
}
};
+11
View File
@@ -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 };
+22
View File
@@ -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`
+3
View File
@@ -0,0 +1,3 @@
{
"order": 6
}