feat(web): add public package browsing and organization redirect management
This commit is contained in:
@@ -1,5 +1,12 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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)
|
## 2026-03-20 - 1.7.0 - feat(organization)
|
||||||
add organization rename redirects and redirect management endpoints
|
add organization rename redirects and redirect management endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@stack.gallery/registry',
|
name: '@stack.gallery/registry',
|
||||||
version: '1.7.0',
|
version: '1.8.0',
|
||||||
description: 'Enterprise-grade multi-protocol package registry'
|
description: 'Enterprise-grade multi-protocol package registry'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@stack.gallery/registry',
|
name: '@stack.gallery/registry',
|
||||||
version: '1.7.0',
|
version: '1.8.0',
|
||||||
description: 'Enterprise-grade multi-protocol package registry'
|
description: 'Enterprise-grade multi-protocol package registry'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface IOrganizationsState {
|
|||||||
currentOrg: interfaces.data.IOrganizationDetail | null;
|
currentOrg: interfaces.data.IOrganizationDetail | null;
|
||||||
repositories: interfaces.data.IRepository[];
|
repositories: interfaces.data.IRepository[];
|
||||||
members: interfaces.data.IOrganizationMember[];
|
members: interfaces.data.IOrganizationMember[];
|
||||||
|
redirects: interfaces.data.IOrgRedirect[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IPackagesState {
|
export interface IPackagesState {
|
||||||
@@ -70,6 +71,7 @@ export const organizationsStatePart = await appState.getStatePart<IOrganizations
|
|||||||
currentOrg: null,
|
currentOrg: null,
|
||||||
repositories: [],
|
repositories: [],
|
||||||
members: [],
|
members: [],
|
||||||
|
redirects: [],
|
||||||
},
|
},
|
||||||
'soft',
|
'soft',
|
||||||
);
|
);
|
||||||
@@ -366,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
|
// Package Actions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ export * from './sg-view-packages.js';
|
|||||||
export * from './sg-view-tokens.js';
|
export * from './sg-view-tokens.js';
|
||||||
export * from './sg-view-settings.js';
|
export * from './sg-view-settings.js';
|
||||||
export * from './sg-view-admin.js';
|
export * from './sg-view-admin.js';
|
||||||
|
export * from './sg-view-public-packages.js';
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export class SgAppShell extends DeesElement {
|
|||||||
@state()
|
@state()
|
||||||
accessor localAuthEnabled: boolean = true;
|
accessor localAuthEnabled: boolean = true;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor showLoginForm: boolean = false;
|
||||||
|
|
||||||
private viewTabs = [
|
private viewTabs = [
|
||||||
{
|
{
|
||||||
name: 'Dashboard',
|
name: 'Dashboard',
|
||||||
@@ -117,7 +120,24 @@ export class SgAppShell extends DeesElement {
|
|||||||
];
|
];
|
||||||
|
|
||||||
public render(): TemplateResult {
|
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`
|
return html`
|
||||||
<div class="maincontainer">
|
<div class="maincontainer">
|
||||||
<sg-login-view
|
<sg-login-view
|
||||||
@@ -133,16 +153,14 @@ export class SgAppShell extends DeesElement {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public browsing mode: package search + detail
|
||||||
return html`
|
return html`
|
||||||
<div class="maincontainer">
|
<div class="maincontainer">
|
||||||
<dees-simple-appdash
|
<sg-public-layout
|
||||||
name="Stack.Gallery"
|
@sign-in=${() => { this.showLoginForm = true; }}
|
||||||
.viewTabs=${this.resolvedViewTabs}
|
|
||||||
.selectedView=${this.resolvedViewTabs.find(
|
|
||||||
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView,
|
|
||||||
) || this.resolvedViewTabs[0]}
|
|
||||||
>
|
>
|
||||||
</dees-simple-appdash>
|
<sg-view-public-packages></sg-view-public-packages>
|
||||||
|
</sg-public-layout>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export class SgViewOrganizations extends DeesElement {
|
|||||||
currentOrg: null,
|
currentOrg: null,
|
||||||
repositories: [],
|
repositories: [],
|
||||||
members: [],
|
members: [],
|
||||||
|
redirects: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
@state()
|
@state()
|
||||||
@@ -72,6 +73,10 @@ export class SgViewOrganizations extends DeesElement {
|
|||||||
appstate.fetchMembersAction,
|
appstate.fetchMembersAction,
|
||||||
{ organizationId: orgId },
|
{ organizationId: orgId },
|
||||||
);
|
);
|
||||||
|
await appstate.organizationsStatePart.dispatchAction(
|
||||||
|
appstate.fetchRedirectsAction,
|
||||||
|
{ organizationId: orgId },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
@@ -81,11 +86,13 @@ export class SgViewOrganizations extends DeesElement {
|
|||||||
.organization="${this.organizationsState.currentOrg}"
|
.organization="${this.organizationsState.currentOrg}"
|
||||||
.repositories="${this.organizationsState.repositories}"
|
.repositories="${this.organizationsState.repositories}"
|
||||||
.members="${this.organizationsState.members}"
|
.members="${this.organizationsState.members}"
|
||||||
|
.redirects="${this.organizationsState.redirects}"
|
||||||
@back="${() => this.goBack()}"
|
@back="${() => this.goBack()}"
|
||||||
@select-repo="${(e: CustomEvent) => this.selectRepo(e.detail.repositoryId)}"
|
@select-repo="${(e: CustomEvent) => this.selectRepo(e.detail.repositoryId)}"
|
||||||
@create-repo="${() => {/* TODO: create repo modal */}}"
|
@create-repo="${() => {/* TODO: create repo modal */}}"
|
||||||
@edit="${(e: CustomEvent) => this.handleEditOrg(e.detail)}"
|
@edit="${(e: CustomEvent) => this.handleEditOrg(e.detail)}"
|
||||||
@delete="${(e: CustomEvent) => this.handleDeleteOrg(e.detail.organizationId)}"
|
@delete="${(e: CustomEvent) => this.handleDeleteOrg(e.detail.organizationId)}"
|
||||||
|
@delete-redirect="${(e: CustomEvent) => this.handleDeleteRedirect(e.detail.redirectId)}"
|
||||||
></sg-organization-detail-view>
|
></sg-organization-detail-view>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -116,11 +123,21 @@ export class SgViewOrganizations extends DeesElement {
|
|||||||
currentOrg: null,
|
currentOrg: null,
|
||||||
repositories: [],
|
repositories: [],
|
||||||
members: [],
|
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: {
|
private async handleEditOrg(data: {
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
|
name?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
|
|||||||
132
ts_web/elements/sg-view-public-packages.ts
Normal file
132
ts_web/elements/sg-view-public-packages.ts
Normal 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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,8 @@ import { css } from '@design.estate/dees-element';
|
|||||||
export const viewHostCss = css`
|
export const viewHostCss = css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
margin: auto;
|
||||||
height: 100%;
|
max-width: 1280px;
|
||||||
overflow-y: auto;
|
padding: 16px 16px;
|
||||||
padding: 24px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -68,8 +68,14 @@ class AppRouter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user is logged in to decide default route
|
||||||
|
const isLoggedIn = appstate.loginStatePart.getState().isLoggedIn;
|
||||||
|
|
||||||
if (!path || path === '/') {
|
if (!path || path === '/') {
|
||||||
|
if (isLoggedIn) {
|
||||||
this.router.pushUrl('/dashboard');
|
this.router.pushUrl('/dashboard');
|
||||||
|
}
|
||||||
|
// If not logged in, stay on / for public browsing
|
||||||
} else {
|
} else {
|
||||||
const segments = path.split('/').filter(Boolean);
|
const segments = path.split('/').filter(Boolean);
|
||||||
const view = segments[0];
|
const view = segments[0];
|
||||||
|
|||||||
Reference in New Issue
Block a user