16 Commits

Author SHA1 Message Date
2d84470688 v1.8.4
All checks were successful
Release / build-and-release (push) Successful in 2m29s
2026-03-21 11:06:14 +00:00
883fc1d22b fix(deps): bump @stack.gallery/catalog to ^1.0.2 and remove committed test fixture auth token 2026-03-21 11:06:14 +00:00
6961ac7e27 v1.8.3
Some checks failed
Release / build-and-release (push) Failing after 12s
2026-03-21 11:01:14 +00:00
fae8147414 fix(test-fixtures): update npm fixture registry configuration for scoped package installs 2026-03-21 11:01:14 +00:00
c589476590 v1.8.2
Some checks failed
Release / build-and-release (push) Failing after 14s
2026-03-21 11:00:24 +00:00
03529bc140 fix(deps): replace local catalog dependency with published version and simplify npm fixture auth config 2026-03-21 11:00:24 +00:00
ffade4d5ca v1.8.1
Some checks failed
Release / build-and-release (push) Failing after 19s
2026-03-21 10:58:44 +00:00
9c4636906a fix(release,test): streamline release UI bundling and add npm fixture registry configuration 2026-03-21 10:58:44 +00:00
f44b03b47d v1.8.0
Some checks failed
Release / build-and-release (push) Failing after 19s
2026-03-21 10:54:10 +00:00
6d6ed61e70 feat(web): add public package browsing and organization redirect management 2026-03-21 10:54:10 +00:00
392060bf23 v1.7.0
Some checks failed
Release / build-and-release (push) Failing after 7s
2026-03-20 17:07:12 +00:00
8cb5e4fa96 feat(organization): add organization rename redirects and redirect management endpoints 2026-03-20 17:07:12 +00:00
c60a0ed536 v1.6.0
Some checks failed
Release / build-and-release (push) Failing after 23s
2026-03-20 16:48:04 +00:00
087b8c0bb3 feat(web-organizations): add organization detail editing and isolate detail view state from global navigation 2026-03-20 16:48:04 +00:00
ffe7ffbde9 v1.5.1
Some checks failed
Release / build-and-release (push) Failing after 26s
2026-03-20 16:44:44 +00:00
b9a3d79b5f fix(web-app): update dashboard navigation to use the router directly and refresh admin tabs on login changes 2026-03-20 16:44:44 +00:00
24 changed files with 616 additions and 81 deletions

View File

@@ -33,9 +33,6 @@ jobs:
- name: Install root dependencies
run: pnpm install --ignore-scripts
- name: Install UI dependencies
run: cd ui && pnpm install
- name: Get version from tag
id: version
run: |
@@ -56,11 +53,8 @@ jobs:
exit 1
fi
- name: Build Angular UI
run: cd ui && pnpm run build
- name: Bundle UI into TypeScript
run: deno run --allow-all scripts/bundle-ui.ts
- name: Build UI
run: npx tsbundle
- name: Compile binaries for all platforms
run: mkdir -p dist/binaries && npx tsdeno compile

View File

@@ -1,5 +1,59 @@
# Changelog
## 2026-03-21 - 1.8.4 - fix(deps)
bump @stack.gallery/catalog to ^1.0.2 and remove committed test fixture auth token
- Updates the @stack.gallery/catalog dependency from ^1.0.1 to ^1.0.2.
- Removes the .npmrc auth token from the npm test fixture package to avoid keeping credentials in the repository.
## 2026-03-21 - 1.8.3 - fix(test-fixtures)
update npm fixture registry configuration for scoped package installs
- refreshes the test fixture auth token in .npmrc
- adds the @stack-test scoped registry mapping to the npm fixture configuration
## 2026-03-21 - 1.8.2 - fix(deps)
replace local catalog dependency with published version and simplify npm fixture auth config
- switch @stack.gallery/catalog from a local file reference to the published ^1.0.1 release
- update the npm test fixture .npmrc to use only an auth token entry
## 2026-03-21 - 1.8.1 - fix(release,test)
streamline release UI bundling and add npm fixture registry configuration
- Update the release workflow to build the UI with tsbundle directly instead of installing UI-specific dependencies and running a separate bundling script
- Add an .npmrc fixture for the demo npm package to configure the scoped registry and authentication token for local registry tests
## 2026-03-21 - 1.8.0 - feat(web)
add public package browsing and organization redirect management
- introduces a public packages view and root route behavior for unauthenticated users
- updates the app shell to support public browsing mode with an optional sign-in flow
- adds organization redirect state, fetching, and deletion in the organization detail view
## 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
- adds an update organization action to persist organization detail edits from the detail view
- updates organization and package views to track selected detail entities locally instead of mutating global ui state
- preserves resolved app shell tabs for role-based filtering after async tab loading
- includes type-cast fixes for admin auth provider responses and bundled file Response bodies
## 2026-03-20 - 1.5.1 - fix(web-app)
update dashboard navigation to use the router directly and refresh admin tabs on login changes
- removes the global router workaround in the dashboard and imports appRouter directly
- re-filters resolved view tabs when login state changes so the Admin tab matches system admin access
- adds dashboard navigation support for the organizations view
## 2026-03-20 - 1.5.0 - feat(opsserver,web)
replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend

View File

@@ -1,6 +1,6 @@
{
"name": "@stack.gallery/registry",
"version": "1.5.0",
"version": "1.8.4",
"exports": "./mod.ts",
"nodeModulesDir": "auto",
"tasks": {

14
deno.lock generated
View File

@@ -43,6 +43,7 @@
"npm:@push.rocks/smartrx@^3.0.10": "3.0.10",
"npm:@push.rocks/smartstring@^4.1.0": "4.1.0",
"npm:@push.rocks/smartunique@^3.0.9": "3.0.9",
"npm:@stack.gallery/catalog@^1.0.1": "1.0.1",
"npm:@tsclass/tsclass@^9.5.0": "9.5.0"
},
"jsr": {
@@ -3036,6 +3037,16 @@
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
"tarball": "https://verdaccio.lossless.digital/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz"
},
"@stack.gallery/catalog@1.0.1": {
"integrity": "sha512-9wlSACeahEVWKTqcLKQq/kbjOz7p0v5l5NQ0UbhFMYcpFtcGx2mZMHGdSUNrHUHQh7zUDiU5qW6E8GlLJBP43A==",
"dependencies": [
"@design.estate/dees-catalog",
"@design.estate/dees-domtools",
"@design.estate/dees-element",
"@design.estate/dees-wcctools"
],
"tarball": "https://verdaccio.lossless.digital/@stack.gallery/catalog/-/catalog-1.0.1.tgz"
},
"@tempfix/idb@8.0.3": {
"integrity": "sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==",
"tarball": "https://verdaccio.lossless.digital/@tempfix/idb/-/idb-8.0.3.tgz"
@@ -6796,7 +6807,8 @@
"npm:@git.zone/tsbundle@^2.8.3",
"npm:@git.zone/tsdeno@^1.2.0",
"npm:@git.zone/tswatch@^3.1.0",
"npm:@push.rocks/smartguard@^3.1.0"
"npm:@push.rocks/smartguard@^3.1.0",
"npm:@stack.gallery/catalog@^1.0.1"
]
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@stack.gallery/registry",
"version": "1.5.0",
"version": "1.8.4",
"private": true,
"description": "Enterprise-grade multi-protocol package registry",
"type": "module",
@@ -33,7 +33,7 @@
"@design.estate/dees-catalog": "^3.43.0",
"@design.estate/dees-element": "^2.1.6",
"@push.rocks/smartguard": "^3.1.0",
"@stack.gallery/catalog": "file:../catalog"
"@stack.gallery/catalog": "^1.0.2"
},
"devDependencies": {
"@git.zone/tsbundle": "^2.8.3",

10
pnpm-lock.yaml generated
View File

@@ -27,8 +27,8 @@ importers:
specifier: ^3.1.0
version: 3.1.0
'@stack.gallery/catalog':
specifier: file:../catalog
version: file:../catalog(@tiptap/pm@2.27.2)
specifier: ^1.0.2
version: 1.0.2(@tiptap/pm@2.27.2)
devDependencies:
'@git.zone/tsbundle':
specifier: ^2.8.3
@@ -845,8 +845,8 @@ packages:
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
'@stack.gallery/catalog@file:../catalog':
resolution: {directory: ../catalog, type: directory}
'@stack.gallery/catalog@1.0.2':
resolution: {integrity: sha512-alPyu2YwpIwaM0hYcLnW05PcCYishWDitYVWDkk7+HDcy3q32LGizkS0eUu/l7Za6w/to06OXGgEmZMhZY4nuQ==}
'@tempfix/idb@8.0.3':
resolution: {integrity: sha512-hPJQKO7+oAIY+pDNImrZ9QAINbz9KmwT+yO4iRVwdPanok2YKpaUxdJzIvCUwY0YgAawlvYdffbLvRLV5hbs2g==}
@@ -3483,7 +3483,7 @@ snapshots:
'@sec-ant/readable-stream@0.4.1': {}
'@stack.gallery/catalog@file:../catalog(@tiptap/pm@2.27.2)':
'@stack.gallery/catalog@1.0.2(@tiptap/pm@2.27.2)':
dependencies:
'@design.estate/dees-catalog': 3.49.0(@tiptap/pm@2.27.2)
'@design.estate/dees-domtools': 2.5.1

View File

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

View File

@@ -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';

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

@@ -26,7 +26,7 @@ export class AdminHandler {
try {
const providers = await AuthProvider.getAllProviders();
return {
providers: providers.map((p) => p.toAdminInfo()),
providers: providers.map((p) => p.toAdminInfo() as unknown as interfaces.data.IAuthProvider),
};
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
@@ -124,7 +124,7 @@ export class AdminHandler {
},
});
return { provider: provider.toAdminInfo() };
return { provider: provider.toAdminInfo() as unknown as interfaces.data.IAuthProvider };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to create provider');
@@ -146,7 +146,7 @@ export class AdminHandler {
throw new plugins.typedrequest.TypedResponseError('Provider not found');
}
return { provider: provider.toAdminInfo() };
return { provider: provider.toAdminInfo() as unknown as interfaces.data.IAuthProvider };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to get provider');
@@ -235,7 +235,7 @@ export class AdminHandler {
metadata: { providerName: provider.name },
});
return { provider: provider.toAdminInfo() };
return { provider: provider.toAdminInfo() as unknown as interfaces.data.IAuthProvider };
} catch (error) {
if (error instanceof plugins.typedrequest.TypedResponseError) throw error;
throw new plugins.typedrequest.TypedResponseError('Failed to update provider');

View File

@@ -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<Organization | null> {
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<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

@@ -325,7 +325,7 @@ export class StackGalleryRegistry {
// Get bundled file
const file = bundledFileMap.get(filePath);
if (file) {
return new Response(file.data, {
return new Response(file.data as unknown as BodyInit, {
status: 200,
headers: { 'Content-Type': file.contentType },
});
@@ -334,7 +334,7 @@ export class StackGalleryRegistry {
// SPA fallback: serve index.html for unknown paths
const indexFile = bundledFileMap.get('/index.html');
if (indexFile) {
return new Response(indexFile.data, {
return new Response(indexFile.data as unknown as BodyInit, {
status: 200,
headers: { 'Content-Type': 'text/html' },
});

View File

@@ -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';

View File

@@ -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;
};
}

View File

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

View File

@@ -20,6 +20,7 @@ export interface IOrganizationsState {
currentOrg: interfaces.data.IOrganizationDetail | null;
repositories: interfaces.data.IRepository[];
members: interfaces.data.IOrganizationMember[];
redirects: interfaces.data.IOrgRedirect[];
}
export interface IPackagesState {
@@ -70,6 +71,7 @@ export const organizationsStatePart = await appState.getStatePart<IOrganizations
currentOrg: null,
repositories: [],
members: [],
redirects: [],
},
'soft',
);
@@ -276,6 +278,31 @@ export const createOrganizationAction = organizationsStatePart.createAction<{
}
});
export const updateOrganizationAction = organizationsStatePart.createAction<{
organizationId: string;
name?: string;
displayName?: string;
description?: string;
website?: string;
isPublic?: boolean;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
if (!context.identity) return statePartArg.getState();
try {
const typedRequest = createTypedRequest<interfaces.requests.IReq_UpdateOrganization>(
'updateOrganization',
);
const response = await typedRequest.fire({
identity: context.identity,
...dataArg,
});
// Update the current org in state
return { ...statePartArg.getState(), currentOrg: response.organization };
} catch {
return statePartArg.getState();
}
});
export const deleteOrganizationAction = organizationsStatePart.createAction<{
organizationId: string;
}>(async (statePartArg, dataArg) => {
@@ -341,6 +368,53 @@ export const fetchMembersAction = organizationsStatePart.createAction<{
}
});
export const fetchRedirectsAction = organizationsStatePart.createAction<{
organizationId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
if (!context.identity) return statePartArg.getState();
try {
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetOrgRedirects>(
'getOrgRedirects',
);
const response = await typedRequest.fire({
identity: context.identity,
organizationId: dataArg.organizationId,
});
return { ...statePartArg.getState(), redirects: response.redirects };
} catch {
return statePartArg.getState();
}
});
export const deleteRedirectAction = organizationsStatePart.createAction<{
redirectId: string;
organizationId: string;
}>(async (statePartArg, dataArg) => {
const context = getActionContext();
if (!context.identity) return statePartArg.getState();
try {
const typedRequest = createTypedRequest<interfaces.requests.IReq_DeleteOrgRedirect>(
'deleteOrgRedirect',
);
await typedRequest.fire({
identity: context.identity,
redirectId: dataArg.redirectId,
});
// Re-fetch redirects
const listReq = createTypedRequest<interfaces.requests.IReq_GetOrgRedirects>(
'getOrgRedirects',
);
const listResp = await listReq.fire({
identity: context.identity,
organizationId: dataArg.organizationId,
});
return { ...statePartArg.getState(), redirects: listResp.redirects };
} catch {
return statePartArg.getState();
}
});
// ============================================================================
// Package Actions
// ============================================================================

View File

@@ -6,3 +6,4 @@ export * from './sg-view-packages.js';
export * from './sg-view-tokens.js';
export * from './sg-view-settings.js';
export * from './sg-view-admin.js';
export * from './sg-view-public-packages.js';

View File

@@ -39,6 +39,9 @@ export class SgAppShell extends DeesElement {
@state()
accessor localAuthEnabled: boolean = true;
@state()
accessor showLoginForm: boolean = false;
private viewTabs = [
{
name: 'Dashboard',
@@ -72,19 +75,23 @@ export class SgAppShell extends DeesElement {
},
];
private allResolvedViewTabs: Array<{ name: string; iconName?: string; element: any }> = [];
private resolvedViewTabs: Array<{ name: string; iconName?: string; element: any }> = [];
constructor() {
super();
document.title = 'Stack.Gallery Registry';
// Make appRouter globally accessible for view elements
(globalThis as any).__sgAppRouter = appRouter;
const loginSubscription = appstate.loginStatePart
.select((s) => s)
.subscribe((loginState) => {
this.loginState = loginState;
// Re-filter tabs when login state changes
if (loginState.isLoggedIn && this.allResolvedViewTabs.length > 0) {
this.resolvedViewTabs = loginState.identity?.isSystemAdmin
? this.allResolvedViewTabs
: this.allResolvedViewTabs.filter((t) => t.name !== 'Admin');
}
});
this.rxSubscriptions.push(loginSubscription);
@@ -113,7 +120,24 @@ export class SgAppShell extends DeesElement {
];
public render(): TemplateResult {
if (!this.loginState.isLoggedIn) {
// Authenticated: full appdash
if (this.loginState.isLoggedIn) {
return html`
<div class="maincontainer">
<dees-simple-appdash
name="Stack.Gallery"
.viewTabs=${this.resolvedViewTabs}
.selectedView=${this.resolvedViewTabs.find(
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView,
) || this.resolvedViewTabs[0]}
>
</dees-simple-appdash>
</div>
`;
}
// Login form requested
if (this.showLoginForm) {
return html`
<div class="maincontainer">
<sg-login-view
@@ -129,16 +153,14 @@ export class SgAppShell extends DeesElement {
`;
}
// Public browsing mode: package search + detail
return html`
<div class="maincontainer">
<dees-simple-appdash
name="Stack.Gallery"
.viewTabs=${this.resolvedViewTabs}
.selectedView=${this.resolvedViewTabs.find(
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView,
) || this.resolvedViewTabs[0]}
<sg-public-layout
@sign-in=${() => { this.showLoginForm = true; }}
>
</dees-simple-appdash>
<sg-view-public-packages></sg-view-public-packages>
</sg-public-layout>
</div>
`;
}
@@ -148,7 +170,7 @@ export class SgAppShell extends DeesElement {
this.fetchAuthProviders();
// Resolve async view tab imports
const allTabs = await Promise.all(
this.allResolvedViewTabs = await Promise.all(
this.viewTabs.map(async (tab) => ({
name: tab.name,
iconName: tab.iconName,
@@ -158,8 +180,8 @@ export class SgAppShell extends DeesElement {
// Filter admin tab based on user role
this.resolvedViewTabs = this.loginState.identity?.isSystemAdmin
? allTabs
: allTabs.filter((t) => t.name !== 'Admin');
? this.allResolvedViewTabs
: this.allResolvedViewTabs.filter((t) => t.name !== 'Admin');
this.requestUpdate();
await this.updateComplete;

View File

@@ -1,5 +1,6 @@
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import { appRouter } from '../router.js';
import {
css,
cssManager,
@@ -77,24 +78,15 @@ export class SgViewDashboard extends DeesElement {
private handleNavigate(e: CustomEvent) {
const { type, id } = e.detail;
if (type === 'org' && id) {
const { appRouter } = await_import_router();
appRouter.navigateToEntity('organizations', id);
} else if (type === 'org') {
appRouter.navigateToView('organizations');
} else if (type === 'package' && id) {
const { appRouter } = await_import_router();
appRouter.navigateToEntity('packages', id);
} else if (type === 'packages') {
const { appRouter } = await_import_router();
appRouter.navigateToView('packages');
} else if (type === 'tokens') {
const { appRouter } = await_import_router();
appRouter.navigateToView('tokens');
}
}
}
// Lazy import to avoid circular dependency
function await_import_router() {
// Dynamic import not needed here since router is a separate module
// We use a workaround by importing at the module level
return { appRouter: (globalThis as any).__sgAppRouter };
}

View File

@@ -1,5 +1,6 @@
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import { appRouter } from '../router.js';
import {
css,
cssManager,
@@ -18,11 +19,15 @@ export class SgViewOrganizations extends DeesElement {
currentOrg: null,
repositories: [],
members: [],
redirects: [],
};
@state()
accessor uiState: appstate.IUiState = { activeView: 'organizations' };
@state()
accessor detailOrgId: string | null = null;
constructor() {
super();
const orgSub = appstate.organizationsStatePart
@@ -48,9 +53,10 @@ export class SgViewOrganizations extends DeesElement {
async connectedCallback() {
super.connectedCallback();
await appstate.organizationsStatePart.dispatchAction(appstate.fetchOrganizationsAction, null);
// If there's an entity ID, load the detail
// If there's an entity ID from the URL, copy it to internal state
if (this.uiState.activeEntityId) {
await this.loadOrgDetail(this.uiState.activeEntityId);
this.detailOrgId = this.uiState.activeEntityId;
await this.loadOrgDetail(this.detailOrgId);
}
}
@@ -67,18 +73,26 @@ export class SgViewOrganizations extends DeesElement {
appstate.fetchMembersAction,
{ organizationId: orgId },
);
await appstate.organizationsStatePart.dispatchAction(
appstate.fetchRedirectsAction,
{ organizationId: orgId },
);
}
public render(): TemplateResult {
if (this.uiState.activeEntityId && this.organizationsState.currentOrg) {
if (this.detailOrgId && this.organizationsState.currentOrg) {
return html`
<sg-organization-detail-view
.organization="${this.organizationsState.currentOrg}"
.repositories="${this.organizationsState.repositories}"
.members="${this.organizationsState.members}"
.redirects="${this.organizationsState.redirects}"
@back="${() => this.goBack()}"
@select-repo="${(e: CustomEvent) => this.selectRepo(e.detail.repositoryId)}"
@create-repo="${() => {/* TODO: create repo modal */}}"
@edit="${(e: CustomEvent) => this.handleEditOrg(e.detail)}"
@delete="${(e: CustomEvent) => this.handleDeleteOrg(e.detail.organizationId)}"
@delete-redirect="${(e: CustomEvent) => this.handleDeleteRedirect(e.detail.redirectId)}"
></sg-organization-detail-view>
`;
}
@@ -93,10 +107,7 @@ export class SgViewOrganizations extends DeesElement {
}
private selectOrg(orgId: string) {
appstate.uiStatePart.setState({
...appstate.uiStatePart.getState(),
activeEntityId: orgId,
});
this.detailOrgId = orgId;
this.loadOrgDetail(orgId);
}
@@ -106,18 +117,50 @@ export class SgViewOrganizations extends DeesElement {
}
private goBack() {
appstate.uiStatePart.setState({
...appstate.uiStatePart.getState(),
activeEntityId: undefined,
});
this.detailOrgId = null;
appstate.organizationsStatePart.setState({
...appstate.organizationsStatePart.getState(),
currentOrg: null,
repositories: [],
members: [],
redirects: [],
});
}
private async handleDeleteRedirect(redirectId: string) {
if (!this.detailOrgId) return;
await appstate.organizationsStatePart.dispatchAction(
appstate.deleteRedirectAction,
{ redirectId, organizationId: this.detailOrgId },
);
}
private async handleEditOrg(data: {
organizationId: string;
name?: string;
displayName?: string;
description?: string;
website?: string;
isPublic?: boolean;
}) {
await appstate.organizationsStatePart.dispatchAction(
appstate.updateOrganizationAction,
data,
);
// Re-load detail to reflect changes
if (this.detailOrgId) {
await this.loadOrgDetail(this.detailOrgId);
}
}
private async handleDeleteOrg(organizationId: string) {
await appstate.organizationsStatePart.dispatchAction(
appstate.deleteOrganizationAction,
{ organizationId },
);
this.goBack();
}
private async createOrg(data: { name: string; displayName?: string; description?: string }) {
await appstate.organizationsStatePart.dispatchAction(
appstate.createOrganizationAction,

View File

@@ -1,5 +1,6 @@
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import { appRouter } from '../router.js';
import {
css,
cssManager,
@@ -25,6 +26,9 @@ export class SgViewPackages extends DeesElement {
@state()
accessor uiState: appstate.IUiState = { activeView: 'packages' };
@state()
accessor detailPackageId: string | null = null;
constructor() {
super();
const pkgSub = appstate.packagesStatePart
@@ -49,8 +53,10 @@ export class SgViewPackages extends DeesElement {
async connectedCallback() {
super.connectedCallback();
// If there's an entity ID from the URL, copy it to internal state
if (this.uiState.activeEntityId) {
await this.loadPackageDetail(this.uiState.activeEntityId);
this.detailPackageId = this.uiState.activeEntityId;
await this.loadPackageDetail(this.detailPackageId);
} else {
await appstate.packagesStatePart.dispatchAction(
appstate.searchPackagesAction,
@@ -71,7 +77,7 @@ export class SgViewPackages extends DeesElement {
}
public render(): TemplateResult {
if (this.uiState.activeEntityId && this.packagesState.currentPackage) {
if (this.detailPackageId && this.packagesState.currentPackage) {
return html`
<sg-package-detail-view
.package="${this.packagesState.currentPackage}"
@@ -98,18 +104,12 @@ export class SgViewPackages extends DeesElement {
}
private selectPackage(packageId: string) {
appstate.uiStatePart.setState({
...appstate.uiStatePart.getState(),
activeEntityId: packageId,
});
this.detailPackageId = packageId;
this.loadPackageDetail(packageId);
}
private goBack() {
appstate.uiStatePart.setState({
...appstate.uiStatePart.getState(),
activeEntityId: undefined,
});
this.detailPackageId = null;
appstate.packagesStatePart.setState({
...appstate.packagesStatePart.getState(),
currentPackage: null,

View File

@@ -0,0 +1,132 @@
import * as appstate from '../appstate.js';
import * as shared from './shared/index.js';
import {
css,
cssManager,
customElement,
DeesElement,
html,
state,
type TemplateResult,
} from '@design.estate/dees-element';
@customElement('sg-view-public-packages')
export class SgViewPublicPackages extends DeesElement {
@state()
accessor packagesState: appstate.IPackagesState = {
packages: [],
currentPackage: null,
versions: [],
total: 0,
query: '',
protocolFilter: '',
};
@state()
accessor detailPackageId: string | null = null;
@state()
accessor loading: boolean = false;
constructor() {
super();
const pkgSub = appstate.packagesStatePart
.select((s) => s)
.subscribe((s) => {
this.packagesState = s;
this.loading = false;
});
this.rxSubscriptions.push(pkgSub);
}
public static styles = [
cssManager.defaultStyles,
shared.viewHostCss,
];
async connectedCallback() {
super.connectedCallback();
this.loading = true;
await appstate.packagesStatePart.dispatchAction(
appstate.searchPackagesAction,
{ offset: 0 },
);
this.loading = false;
}
public render(): TemplateResult {
if (this.detailPackageId && this.packagesState.currentPackage) {
return html`
<sg-package-detail-view
.package="${this.packagesState.currentPackage}"
.versions="${this.packagesState.versions}"
@back="${() => this.goBack()}"
></sg-package-detail-view>
`;
}
return html`
<sg-public-search-view
.packages="${this.packagesState.packages}"
.total="${this.packagesState.total}"
.query="${this.packagesState.query}"
.activeProtocol="${this.packagesState.protocolFilter}"
.protocols="${['npm', 'oci', 'maven', 'cargo', 'pypi', 'composer', 'rubygems']}"
.loading="${this.loading}"
@search="${(e: CustomEvent) => this.search(e.detail.query)}"
@filter="${(e: CustomEvent) => this.filter(e.detail.protocol)}"
@select="${(e: CustomEvent) => this.selectPackage(e.detail.packageId)}"
@page="${(e: CustomEvent) => this.paginate(e.detail.offset)}"
></sg-public-search-view>
`;
}
private async selectPackage(packageId: string) {
this.detailPackageId = packageId;
await appstate.packagesStatePart.dispatchAction(
appstate.fetchPackageAction,
{ packageId },
);
await appstate.packagesStatePart.dispatchAction(
appstate.fetchPackageVersionsAction,
{ packageId },
);
}
private goBack() {
this.detailPackageId = null;
appstate.packagesStatePart.setState({
...appstate.packagesStatePart.getState(),
currentPackage: null,
versions: [],
});
}
private async search(query: string) {
this.loading = true;
await appstate.packagesStatePart.dispatchAction(
appstate.searchPackagesAction,
{ query, protocol: this.packagesState.protocolFilter, offset: 0 },
);
}
private async filter(protocol: string) {
this.loading = true;
await appstate.packagesStatePart.dispatchAction(
appstate.searchPackagesAction,
{ query: this.packagesState.query, protocol, offset: 0 },
);
}
private async paginate(offset: number) {
this.loading = true;
await appstate.packagesStatePart.dispatchAction(
appstate.searchPackagesAction,
{
query: this.packagesState.query,
protocol: this.packagesState.protocolFilter,
offset,
},
);
}
}

View File

@@ -3,10 +3,8 @@ import { css } from '@design.estate/dees-element';
export const viewHostCss = css`
:host {
display: block;
width: 100%;
height: 100%;
overflow-y: auto;
padding: 24px;
box-sizing: border-box;
margin: auto;
max-width: 1280px;
padding: 16px 16px;
}
`;

View File

@@ -68,8 +68,14 @@ class AppRouter {
return;
}
// Check if user is logged in to decide default route
const isLoggedIn = appstate.loginStatePart.getState().isLoggedIn;
if (!path || path === '/') {
this.router.pushUrl('/dashboard');
if (isLoggedIn) {
this.router.pushUrl('/dashboard');
}
// If not logged in, stay on / for public browsing
} else {
const segments = path.split('/').filter(Boolean);
const view = segments[0];