feat(organization): add organization rename redirects and redirect management endpoints
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
59
ts/models/org.redirect.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user