diff --git a/changelog.md b/changelog.md index 8181c85..8787fcd 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,13 @@ # 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) add organization detail editing and isolate detail view state from global navigation diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 4643bfe..cc26e43 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@stack.gallery/registry', - version: '1.6.0', + version: '1.7.0', description: 'Enterprise-grade multi-protocol package registry' } diff --git a/ts/models/index.ts b/ts/models/index.ts index aaa2b81..fa96c0f 100644 --- a/ts/models/index.ts +++ b/ts/models/index.ts @@ -15,6 +15,9 @@ export { ApiToken } from './apitoken.ts'; export { Session } from './session.ts'; export { AuditLog } from './auditlog.ts'; +// Organization redirects +export { OrgRedirect } from './org.redirect.ts'; + // External authentication models export { AuthProvider } from './auth.provider.ts'; export { ExternalIdentity } from './external.identity.ts'; diff --git a/ts/models/org.redirect.ts b/ts/models/org.redirect.ts new file mode 100644 index 0000000..cb70972 --- /dev/null +++ b/ts/models/org.redirect.ts @@ -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 { + @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 { + 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 { + return await OrgRedirect.getInstance({ oldName: name } as any); + } + + /** + * Get all redirects for an organization + */ + public static async getByOrgId(organizationId: string): Promise { + return await OrgRedirect.getInstances({ organizationId } as any); + } + + /** + * Find a redirect by ID + */ + public static async findById(id: string): Promise { + return await OrgRedirect.getInstance({ id } as any); + } +} diff --git a/ts/opsserver/handlers/organization.handler.ts b/ts/opsserver/handlers/organization.handler.ts index 2c08367..4ae453c 100644 --- a/ts/opsserver/handlers/organization.handler.ts +++ b/ts/opsserver/handlers/organization.handler.ts @@ -2,7 +2,7 @@ import * as plugins from '../../plugins.ts'; import * as interfaces from '../../../ts_interfaces/index.ts'; import type { OpsServer } from '../classes.opsserver.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 { AuditService } from '../../services/audit.service.ts'; @@ -19,9 +19,18 @@ export class OrganizationHandler { * Helper to resolve organization by ID or name */ private async resolveOrganization(idOrName: string): Promise { - return idOrName.startsWith('Organization:') - ? await Organization.findById(idOrName) - : await Organization.findByName(idOrName); + if (idOrName.startsWith('Organization:')) { + return await Organization.findById(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 { @@ -232,6 +241,36 @@ export class OrganizationHandler { 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.description !== undefined) org.description = dataArg.description; 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( + '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( + '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'); + } + }, + ), + ); } } diff --git a/ts_interfaces/data/organization.ts b/ts_interfaces/data/organization.ts index c5bc9a1..aef3d40 100644 --- a/ts_interfaces/data/organization.ts +++ b/ts_interfaces/data/organization.ts @@ -42,6 +42,13 @@ export interface IOrganizationMember { } | null; } +export interface IOrgRedirect { + id: string; + oldName: string; + organizationId: string; + createdAt: string; +} + // Re-export types used by settings import type { TRepositoryVisibility } from './repository.ts'; import type { TRegistryProtocol } from './package.ts'; diff --git a/ts_interfaces/requests/organizations.ts b/ts_interfaces/requests/organizations.ts index 03fcc73..ff15764 100644 --- a/ts_interfaces/requests/organizations.ts +++ b/ts_interfaces/requests/organizations.ts @@ -61,6 +61,7 @@ export interface IReq_UpdateOrganization extends request: { identity: data.IIdentity; organizationId: string; + name?: string; displayName?: string; description?: string; avatarUrl?: string; @@ -159,3 +160,37 @@ export interface IReq_RemoveOrganizationMember extends 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; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 4643bfe..cc26e43 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@stack.gallery/registry', - version: '1.6.0', + version: '1.7.0', description: 'Enterprise-grade multi-protocol package registry' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 2f3845d..8d77a47 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -278,6 +278,7 @@ export const createOrganizationAction = organizationsStatePart.createAction<{ export const updateOrganizationAction = organizationsStatePart.createAction<{ organizationId: string; + name?: string; displayName?: string; description?: string; website?: string; diff --git a/ts_web/elements/sg-view-dashboard.ts b/ts_web/elements/sg-view-dashboard.ts index 2c08a7f..b476c16 100644 --- a/ts_web/elements/sg-view-dashboard.ts +++ b/ts_web/elements/sg-view-dashboard.ts @@ -79,14 +79,14 @@ export class SgViewDashboard extends DeesElement { const { type, id } = e.detail; if (type === 'org' && id) { appRouter.navigateToEntity('organizations', id); + } else if (type === 'org') { + appRouter.navigateToView('organizations'); } else if (type === 'package' && id) { appRouter.navigateToEntity('packages', id); } else if (type === 'packages') { appRouter.navigateToView('packages'); } else if (type === 'tokens') { appRouter.navigateToView('tokens'); - } else if (type === 'organizations') { - appRouter.navigateToView('organizations'); } } }