diff --git a/changelog.md b/changelog.md index 8787fcd..04d0315 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,12 @@ # 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) add organization rename redirects and redirect management endpoints diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index cc26e43..fc979bd 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.7.0', + version: '1.8.0', description: 'Enterprise-grade multi-protocol package registry' } diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index cc26e43..fc979bd 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.7.0', + version: '1.8.0', description: 'Enterprise-grade multi-protocol package registry' } diff --git a/ts_web/appstate.ts b/ts_web/appstate.ts index 8d77a47..5470120 100644 --- a/ts_web/appstate.ts +++ b/ts_web/appstate.ts @@ -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(async (statePartArg, dataArg) => { + const context = getActionContext(); + if (!context.identity) return statePartArg.getState(); + try { + const typedRequest = createTypedRequest( + '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( + 'deleteOrgRedirect', + ); + await typedRequest.fire({ + identity: context.identity, + redirectId: dataArg.redirectId, + }); + // Re-fetch redirects + const listReq = createTypedRequest( + 'getOrgRedirects', + ); + const listResp = await listReq.fire({ + identity: context.identity, + organizationId: dataArg.organizationId, + }); + return { ...statePartArg.getState(), redirects: listResp.redirects }; + } catch { + return statePartArg.getState(); + } +}); + // ============================================================================ // Package Actions // ============================================================================ diff --git a/ts_web/elements/index.ts b/ts_web/elements/index.ts index 8f00b64..becda6a 100644 --- a/ts_web/elements/index.ts +++ b/ts_web/elements/index.ts @@ -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'; diff --git a/ts_web/elements/sg-app-shell.ts b/ts_web/elements/sg-app-shell.ts index cea8de8..4e65269 100644 --- a/ts_web/elements/sg-app-shell.ts +++ b/ts_web/elements/sg-app-shell.ts @@ -39,6 +39,9 @@ export class SgAppShell extends DeesElement { @state() accessor localAuthEnabled: boolean = true; + @state() + accessor showLoginForm: boolean = false; + private viewTabs = [ { name: 'Dashboard', @@ -117,7 +120,24 @@ export class SgAppShell extends DeesElement { ]; public render(): TemplateResult { - if (!this.loginState.isLoggedIn) { + // Authenticated: full appdash + if (this.loginState.isLoggedIn) { + return html` +
+ t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView, + ) || this.resolvedViewTabs[0]} + > + +
+ `; + } + + // Login form requested + if (this.showLoginForm) { return html`
- t.name.toLowerCase().replace(/\s+/g, '-') === this.uiState.activeView, - ) || this.resolvedViewTabs[0]} + { this.showLoginForm = true; }} > - + +
`; } diff --git a/ts_web/elements/sg-view-organizations.ts b/ts_web/elements/sg-view-organizations.ts index 51219b2..a0a0327 100644 --- a/ts_web/elements/sg-view-organizations.ts +++ b/ts_web/elements/sg-view-organizations.ts @@ -19,6 +19,7 @@ export class SgViewOrganizations extends DeesElement { currentOrg: null, repositories: [], members: [], + redirects: [], }; @state() @@ -72,6 +73,10 @@ export class SgViewOrganizations extends DeesElement { appstate.fetchMembersAction, { organizationId: orgId }, ); + await appstate.organizationsStatePart.dispatchAction( + appstate.fetchRedirectsAction, + { organizationId: orgId }, + ); } public render(): TemplateResult { @@ -81,11 +86,13 @@ export class SgViewOrganizations extends DeesElement { .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)}" > `; } @@ -116,11 +123,21 @@ export class SgViewOrganizations extends DeesElement { 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; diff --git a/ts_web/elements/sg-view-public-packages.ts b/ts_web/elements/sg-view-public-packages.ts new file mode 100644 index 0000000..20cb69d --- /dev/null +++ b/ts_web/elements/sg-view-public-packages.ts @@ -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` + + `; + } + + return html` + + `; + } + + 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, + }, + ); + } +} diff --git a/ts_web/elements/shared/css.ts b/ts_web/elements/shared/css.ts index 54e6227..f3255e5 100644 --- a/ts_web/elements/shared/css.ts +++ b/ts_web/elements/shared/css.ts @@ -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; } `; diff --git a/ts_web/router.ts b/ts_web/router.ts index 67d762e..367b9de 100644 --- a/ts_web/router.ts +++ b/ts_web/router.ts @@ -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];