feat(organization): add organization rename redirects and redirect management endpoints

This commit is contained in:
2026-03-20 17:07:12 +00:00
parent c60a0ed536
commit 8cb5e4fa96
10 changed files with 224 additions and 8 deletions

View File

@@ -1,5 +1,13 @@
# Changelog # Changelog
## 2026-03-20 - 1.7.0 - feat(organization)
add organization rename redirects and redirect management endpoints
- add OrgRedirect model and resolve organizations by historical names
- support renaming organizations while preserving the previous handle as a redirect alias
- add typed requests to list and delete organization redirects with admin permission checks
- allow organization update actions to send name changes
## 2026-03-20 - 1.6.0 - feat(web-organizations) ## 2026-03-20 - 1.6.0 - feat(web-organizations)
add organization detail editing and isolate detail view state from global navigation add organization detail editing and isolate detail view state from global navigation

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@stack.gallery/registry', name: '@stack.gallery/registry',
version: '1.6.0', version: '1.7.0',
description: 'Enterprise-grade multi-protocol package registry' description: 'Enterprise-grade multi-protocol package registry'
} }

View File

@@ -15,6 +15,9 @@ export { ApiToken } from './apitoken.ts';
export { Session } from './session.ts'; export { Session } from './session.ts';
export { AuditLog } from './auditlog.ts'; export { AuditLog } from './auditlog.ts';
// Organization redirects
export { OrgRedirect } from './org.redirect.ts';
// External authentication models // External authentication models
export { AuthProvider } from './auth.provider.ts'; export { AuthProvider } from './auth.provider.ts';
export { ExternalIdentity } from './external.identity.ts'; export { ExternalIdentity } from './external.identity.ts';

59
ts/models/org.redirect.ts Normal file
View File

@@ -0,0 +1,59 @@
/**
* OrgRedirect model - stores old org handles as redirect aliases
* When an org is renamed, the old name becomes a redirect pointing to the org.
* Redirects can be explicitly deleted by org admins.
*/
import * as plugins from '../plugins.ts';
import { db } from './db.ts';
@plugins.smartdata.Collection(() => db)
export class OrgRedirect extends plugins.smartdata.SmartDataDbDoc<OrgRedirect, OrgRedirect> {
@plugins.smartdata.unI()
public id: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index({ unique: true })
public oldName: string = '';
@plugins.smartdata.svDb()
@plugins.smartdata.index()
public organizationId: string = '';
@plugins.smartdata.svDb()
public createdAt: Date = new Date();
/**
* Create a redirect from an old org name to the current org
*/
public static async create(oldName: string, organizationId: string): Promise<OrgRedirect> {
const redirect = new OrgRedirect();
redirect.id = `redirect:${oldName}`;
redirect.oldName = oldName;
redirect.organizationId = organizationId;
redirect.createdAt = new Date();
await redirect.save();
return redirect;
}
/**
* Find a redirect by the old name
*/
public static async findByName(name: string): Promise<OrgRedirect | null> {
return await OrgRedirect.getInstance({ oldName: name } as any);
}
/**
* Get all redirects for an organization
*/
public static async getByOrgId(organizationId: string): Promise<OrgRedirect[]> {
return await OrgRedirect.getInstances({ organizationId } as any);
}
/**
* Find a redirect by ID
*/
public static async findById(id: string): Promise<OrgRedirect | null> {
return await OrgRedirect.getInstance({ id } as any);
}
}

View File

@@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts';
import * as interfaces from '../../../ts_interfaces/index.ts'; import * as interfaces from '../../../ts_interfaces/index.ts';
import type { OpsServer } from '../classes.opsserver.ts'; import type { OpsServer } from '../classes.opsserver.ts';
import { requireValidIdentity } from '../helpers/guards.ts'; import { requireValidIdentity } from '../helpers/guards.ts';
import { Organization, OrganizationMember, User } from '../../models/index.ts'; import { Organization, OrganizationMember, OrgRedirect, User } from '../../models/index.ts';
import { PermissionService } from '../../services/permission.service.ts'; import { PermissionService } from '../../services/permission.service.ts';
import { AuditService } from '../../services/audit.service.ts'; import { AuditService } from '../../services/audit.service.ts';
@@ -19,9 +19,18 @@ export class OrganizationHandler {
* Helper to resolve organization by ID or name * Helper to resolve organization by ID or name
*/ */
private async resolveOrganization(idOrName: string): Promise<Organization | null> { private async resolveOrganization(idOrName: string): Promise<Organization | null> {
return idOrName.startsWith('Organization:') if (idOrName.startsWith('Organization:')) {
? await Organization.findById(idOrName) return await Organization.findById(idOrName);
: await Organization.findByName(idOrName); }
// Try direct name lookup first
const org = await Organization.findByName(idOrName);
if (org) return org;
// Check redirects
const redirect = await OrgRedirect.findByName(idOrName);
if (redirect) {
return await Organization.findById(redirect.organizationId);
}
return null;
} }
private registerHandlers(): void { private registerHandlers(): void {
@@ -232,6 +241,36 @@ export class OrganizationHandler {
throw new plugins.typedrequest.TypedResponseError('Admin access required'); throw new plugins.typedrequest.TypedResponseError('Admin access required');
} }
// Handle rename
if (dataArg.name && dataArg.name !== org.name) {
const newName = dataArg.name;
// Validate name format
if (!/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/.test(newName)) {
throw new plugins.typedrequest.TypedResponseError(
'Name must be lowercase alphanumeric with optional hyphens and dots',
);
}
// Check new name not taken by another org
const existingOrg = await Organization.findByName(newName);
if (existingOrg && existingOrg.id !== org.id) {
throw new plugins.typedrequest.TypedResponseError('Organization name already taken');
}
// Check new name not taken by a redirect pointing elsewhere
const existingRedirect = await OrgRedirect.findByName(newName);
if (existingRedirect && existingRedirect.organizationId !== org.id) {
throw new plugins.typedrequest.TypedResponseError(
'Name is reserved as a redirect for another organization',
);
}
// If new name is one of our own redirects, delete that redirect
if (existingRedirect && existingRedirect.organizationId === org.id) {
await existingRedirect.delete();
}
// Create redirect from old name
await OrgRedirect.create(org.name, org.id);
org.name = newName;
}
if (dataArg.displayName !== undefined) org.displayName = dataArg.displayName; if (dataArg.displayName !== undefined) org.displayName = dataArg.displayName;
if (dataArg.description !== undefined) org.description = dataArg.description; if (dataArg.description !== undefined) org.description = dataArg.description;
if (dataArg.avatarUrl !== undefined) org.avatarUrl = dataArg.avatarUrl; if (dataArg.avatarUrl !== undefined) org.avatarUrl = dataArg.avatarUrl;
@@ -544,5 +583,69 @@ export class OrganizationHandler {
}, },
), ),
); );
// Get Org Redirects
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_GetOrgRedirects>(
'getOrgRedirects',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const org = await this.resolveOrganization(dataArg.organizationId);
if (!org) {
throw new plugins.typedrequest.TypedResponseError('Organization not found');
}
const redirects = await OrgRedirect.getByOrgId(org.id);
return {
redirects: redirects.map((r) => ({
id: r.id,
oldName: r.oldName,
organizationId: r.organizationId,
createdAt: r.createdAt instanceof Date
? r.createdAt.toISOString()
: String(r.createdAt),
})),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get redirects');
}
},
),
);
// Delete Org Redirect
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_DeleteOrgRedirect>(
'deleteOrgRedirect',
async (dataArg) => {
await requireValidIdentity(this.opsServerRef.authHandler, dataArg);
try {
const redirect = await OrgRedirect.findById(dataArg.redirectId);
if (!redirect) {
throw new plugins.typedrequest.TypedResponseError('Redirect not found');
}
// Check permission on the org
const canManage = await this.permissionService.canManageOrganization(
dataArg.identity.userId,
redirect.organizationId,
);
if (!canManage && !dataArg.identity.isSystemAdmin) {
throw new plugins.typedrequest.TypedResponseError('Admin access required');
}
await redirect.delete();
return { message: 'Redirect deleted successfully' };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to delete redirect');
}
},
),
);
} }
} }

View File

@@ -42,6 +42,13 @@ export interface IOrganizationMember {
} | null; } | null;
} }
export interface IOrgRedirect {
id: string;
oldName: string;
organizationId: string;
createdAt: string;
}
// Re-export types used by settings // Re-export types used by settings
import type { TRepositoryVisibility } from './repository.ts'; import type { TRepositoryVisibility } from './repository.ts';
import type { TRegistryProtocol } from './package.ts'; import type { TRegistryProtocol } from './package.ts';

View File

@@ -61,6 +61,7 @@ export interface IReq_UpdateOrganization extends
request: { request: {
identity: data.IIdentity; identity: data.IIdentity;
organizationId: string; organizationId: string;
name?: string;
displayName?: string; displayName?: string;
description?: string; description?: string;
avatarUrl?: string; avatarUrl?: string;
@@ -159,3 +160,37 @@ export interface IReq_RemoveOrganizationMember extends
message: string; message: string;
}; };
} }
// ============================================================================
// Organization Redirect Requests
// ============================================================================
export interface IReq_GetOrgRedirects extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_GetOrgRedirects
> {
method: 'getOrgRedirects';
request: {
identity: data.IIdentity;
organizationId: string;
};
response: {
redirects: data.IOrgRedirect[];
};
}
export interface IReq_DeleteOrgRedirect extends
plugins.typedrequestInterfaces.implementsTR<
plugins.typedrequestInterfaces.ITypedRequest,
IReq_DeleteOrgRedirect
> {
method: 'deleteOrgRedirect';
request: {
identity: data.IIdentity;
redirectId: string;
};
response: {
message: string;
};
}

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@stack.gallery/registry', name: '@stack.gallery/registry',
version: '1.6.0', version: '1.7.0',
description: 'Enterprise-grade multi-protocol package registry' description: 'Enterprise-grade multi-protocol package registry'
} }

View File

@@ -278,6 +278,7 @@ export const createOrganizationAction = organizationsStatePart.createAction<{
export const updateOrganizationAction = organizationsStatePart.createAction<{ export const updateOrganizationAction = organizationsStatePart.createAction<{
organizationId: string; organizationId: string;
name?: string;
displayName?: string; displayName?: string;
description?: string; description?: string;
website?: string; website?: string;

View File

@@ -79,14 +79,14 @@ export class SgViewDashboard extends DeesElement {
const { type, id } = e.detail; const { type, id } = e.detail;
if (type === 'org' && id) { if (type === 'org' && id) {
appRouter.navigateToEntity('organizations', id); appRouter.navigateToEntity('organizations', id);
} else if (type === 'org') {
appRouter.navigateToView('organizations');
} else if (type === 'package' && id) { } else if (type === 'package' && id) {
appRouter.navigateToEntity('packages', id); appRouter.navigateToEntity('packages', id);
} else if (type === 'packages') { } else if (type === 'packages') {
appRouter.navigateToView('packages'); appRouter.navigateToView('packages');
} else if (type === 'tokens') { } else if (type === 'tokens') {
appRouter.navigateToView('tokens'); appRouter.navigateToView('tokens');
} else if (type === 'organizations') {
appRouter.navigateToView('organizations');
} }
} }
} }