feat(opsserver,web): replace the Angular UI and REST management layer with a TypedRequest-based ops server and bundled web frontend
This commit is contained in:
8
ts_web/00_commitinfo_data.ts
Normal file
8
ts_web/00_commitinfo_data.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* autocreated commitinfo by @push.rocks/commitinfo
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@stack.gallery/registry',
|
||||
version: '1.5.0',
|
||||
description: 'Enterprise-grade multi-protocol package registry'
|
||||
}
|
||||
663
ts_web/appstate.ts
Normal file
663
ts_web/appstate.ts
Normal file
@@ -0,0 +1,663 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as interfaces from '../ts_interfaces/index.js';
|
||||
|
||||
// ============================================================================
|
||||
// Smartstate instance
|
||||
// ============================================================================
|
||||
export const appState = new plugins.domtools.plugins.smartstate.Smartstate();
|
||||
|
||||
// ============================================================================
|
||||
// State Part Interfaces
|
||||
// ============================================================================
|
||||
|
||||
export interface ILoginState {
|
||||
identity: interfaces.data.IIdentity | null;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export interface IOrganizationsState {
|
||||
organizations: interfaces.data.IOrganization[];
|
||||
currentOrg: interfaces.data.IOrganizationDetail | null;
|
||||
repositories: interfaces.data.IRepository[];
|
||||
members: interfaces.data.IOrganizationMember[];
|
||||
}
|
||||
|
||||
export interface IPackagesState {
|
||||
packages: interfaces.data.IPackage[];
|
||||
currentPackage: interfaces.data.IPackageDetail | null;
|
||||
versions: interfaces.data.IPackageVersion[];
|
||||
total: number;
|
||||
query: string;
|
||||
protocolFilter: string;
|
||||
}
|
||||
|
||||
export interface ITokensState {
|
||||
tokens: interfaces.data.IToken[];
|
||||
}
|
||||
|
||||
export interface ISettingsState {
|
||||
user: interfaces.data.IUser | null;
|
||||
sessions: interfaces.data.ISession[];
|
||||
}
|
||||
|
||||
export interface IAdminState {
|
||||
providers: interfaces.data.IAuthProvider[];
|
||||
platformSettings: interfaces.data.IPlatformSettings | null;
|
||||
}
|
||||
|
||||
export interface IUiState {
|
||||
activeView: string;
|
||||
activeEntityId?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// State Parts
|
||||
// ============================================================================
|
||||
|
||||
export const loginStatePart = await appState.getStatePart<ILoginState>(
|
||||
'login',
|
||||
{
|
||||
identity: null,
|
||||
isLoggedIn: false,
|
||||
},
|
||||
'persistent',
|
||||
);
|
||||
|
||||
export const organizationsStatePart = await appState.getStatePart<IOrganizationsState>(
|
||||
'organizations',
|
||||
{
|
||||
organizations: [],
|
||||
currentOrg: null,
|
||||
repositories: [],
|
||||
members: [],
|
||||
},
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const packagesStatePart = await appState.getStatePart<IPackagesState>(
|
||||
'packages',
|
||||
{
|
||||
packages: [],
|
||||
currentPackage: null,
|
||||
versions: [],
|
||||
total: 0,
|
||||
query: '',
|
||||
protocolFilter: '',
|
||||
},
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const tokensStatePart = await appState.getStatePart<ITokensState>(
|
||||
'tokens',
|
||||
{ tokens: [] },
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const settingsStatePart = await appState.getStatePart<ISettingsState>(
|
||||
'settings',
|
||||
{ user: null, sessions: [] },
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const adminStatePart = await appState.getStatePart<IAdminState>(
|
||||
'admin',
|
||||
{ providers: [], platformSettings: null },
|
||||
'soft',
|
||||
);
|
||||
|
||||
export const uiStatePart = await appState.getStatePart<IUiState>(
|
||||
'ui',
|
||||
{ activeView: 'dashboard' },
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Helpers
|
||||
// ============================================================================
|
||||
|
||||
const getActionContext = () => ({
|
||||
identity: loginStatePart.getState().identity,
|
||||
});
|
||||
|
||||
function createTypedRequest<T extends plugins.domtools.plugins.typedrequest.ITypedRequest>(
|
||||
method: string,
|
||||
) {
|
||||
return new plugins.domtools.plugins.typedrequest.TypedRequest<T>('/typedrequest', method);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth Actions
|
||||
// ============================================================================
|
||||
|
||||
export const loginAction = loginStatePart.createAction<{
|
||||
email: string;
|
||||
password: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_Login>('login');
|
||||
const response = await typedRequest.fire({
|
||||
email: dataArg.email,
|
||||
password: dataArg.password,
|
||||
});
|
||||
if (response.identity) {
|
||||
return { identity: response.identity, isLoggedIn: true };
|
||||
}
|
||||
return { identity: null, isLoggedIn: false };
|
||||
} catch (err) {
|
||||
console.error('Login failed:', err);
|
||||
return { identity: null, isLoggedIn: false };
|
||||
}
|
||||
});
|
||||
|
||||
export const logoutAction = loginStatePart.createAction(async () => {
|
||||
const context = getActionContext();
|
||||
if (context.identity) {
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_Logout>('logout');
|
||||
await typedRequest.fire({ identity: context.identity });
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
}
|
||||
}
|
||||
return { identity: null, isLoggedIn: false };
|
||||
});
|
||||
|
||||
export const refreshTokenAction = loginStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_RefreshToken>('refreshToken');
|
||||
const response = await typedRequest.fire({ identity: context.identity });
|
||||
return { identity: response.identity, isLoggedIn: true };
|
||||
} catch {
|
||||
return { identity: null, isLoggedIn: false };
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchMeAction = settingsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetMe>('getMe');
|
||||
const response = await typedRequest.fire({ identity: context.identity });
|
||||
return { ...statePartArg.getState(), user: response.user };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
// Handle OAuth callback tokens
|
||||
export function handleOAuthCallback(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
sessionId: string,
|
||||
) {
|
||||
// Build a minimal identity from the callback tokens
|
||||
// The full identity will be populated when getMe is called
|
||||
loginStatePart.setState({
|
||||
identity: {
|
||||
jwt: accessToken,
|
||||
refreshJwt: refreshToken,
|
||||
userId: '',
|
||||
email: '',
|
||||
username: '',
|
||||
displayName: '',
|
||||
isSystemAdmin: false,
|
||||
expiresAt: Date.now() + 900000,
|
||||
sessionId,
|
||||
},
|
||||
isLoggedIn: true,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Organization Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchOrganizationsAction = organizationsStatePart.createAction(
|
||||
async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetOrganizations>(
|
||||
'getOrganizations',
|
||||
);
|
||||
const response = await typedRequest.fire({ identity: context.identity });
|
||||
return { ...statePartArg.getState(), organizations: response.organizations };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const fetchOrganizationAction = organizationsStatePart.createAction<{
|
||||
organizationId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetOrganization>(
|
||||
'getOrganization',
|
||||
);
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
organizationId: dataArg.organizationId,
|
||||
});
|
||||
return { ...statePartArg.getState(), currentOrg: response.organization };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const createOrganizationAction = organizationsStatePart.createAction<{
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_CreateOrganization>(
|
||||
'createOrganization',
|
||||
);
|
||||
await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
name: dataArg.name,
|
||||
displayName: dataArg.displayName,
|
||||
description: dataArg.description,
|
||||
});
|
||||
// Re-fetch list
|
||||
const listReq = createTypedRequest<interfaces.requests.IReq_GetOrganizations>(
|
||||
'getOrganizations',
|
||||
);
|
||||
const listResp = await listReq.fire({ identity: context.identity });
|
||||
return { ...statePartArg.getState(), organizations: listResp.organizations };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteOrganizationAction = organizationsStatePart.createAction<{
|
||||
organizationId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_DeleteOrganization>(
|
||||
'deleteOrganization',
|
||||
);
|
||||
await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
organizationId: dataArg.organizationId,
|
||||
});
|
||||
const listReq = createTypedRequest<interfaces.requests.IReq_GetOrganizations>(
|
||||
'getOrganizations',
|
||||
);
|
||||
const listResp = await listReq.fire({ identity: context.identity });
|
||||
return {
|
||||
...statePartArg.getState(),
|
||||
organizations: listResp.organizations,
|
||||
currentOrg: null,
|
||||
};
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchRepositoriesAction = organizationsStatePart.createAction<{
|
||||
organizationId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetRepositories>(
|
||||
'getRepositories',
|
||||
);
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
organizationId: dataArg.organizationId,
|
||||
});
|
||||
return { ...statePartArg.getState(), repositories: response.repositories };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchMembersAction = organizationsStatePart.createAction<{
|
||||
organizationId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetOrganizationMembers>(
|
||||
'getOrganizationMembers',
|
||||
);
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
organizationId: dataArg.organizationId,
|
||||
});
|
||||
return { ...statePartArg.getState(), members: response.members };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Package Actions
|
||||
// ============================================================================
|
||||
|
||||
export const searchPackagesAction = packagesStatePart.createAction<{
|
||||
query?: string;
|
||||
protocol?: string;
|
||||
offset?: number;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_SearchPackages>(
|
||||
'searchPackages',
|
||||
);
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity || undefined,
|
||||
query: dataArg.query,
|
||||
protocol: dataArg.protocol as interfaces.data.TRegistryProtocol | undefined,
|
||||
offset: dataArg.offset,
|
||||
limit: 50,
|
||||
});
|
||||
return {
|
||||
...statePartArg.getState(),
|
||||
packages: response.packages,
|
||||
total: response.total,
|
||||
query: dataArg.query || '',
|
||||
protocolFilter: dataArg.protocol || '',
|
||||
};
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchPackageAction = packagesStatePart.createAction<{
|
||||
packageId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetPackage>('getPackage');
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity || undefined,
|
||||
packageId: dataArg.packageId,
|
||||
});
|
||||
return { ...statePartArg.getState(), currentPackage: response.package };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchPackageVersionsAction = packagesStatePart.createAction<{
|
||||
packageId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetPackageVersions>(
|
||||
'getPackageVersions',
|
||||
);
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity || undefined,
|
||||
packageId: dataArg.packageId,
|
||||
});
|
||||
return { ...statePartArg.getState(), versions: response.versions };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const deletePackageAction = packagesStatePart.createAction<{
|
||||
packageId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_DeletePackage>(
|
||||
'deletePackage',
|
||||
);
|
||||
await typedRequest.fire({ identity: context.identity, packageId: dataArg.packageId });
|
||||
return { ...statePartArg.getState(), currentPackage: null };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Token Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchTokensAction = tokensStatePart.createAction<{
|
||||
organizationId?: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetTokens>('getTokens');
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
organizationId: dataArg?.organizationId,
|
||||
});
|
||||
return { tokens: response.tokens };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const createTokenAction = tokensStatePart.createAction<{
|
||||
name: string;
|
||||
protocols: interfaces.data.TRegistryProtocol[];
|
||||
scopes: interfaces.data.ITokenScope[];
|
||||
organizationId?: string;
|
||||
expiresInDays?: number;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_CreateToken>('createToken');
|
||||
await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
...dataArg,
|
||||
});
|
||||
// Re-fetch
|
||||
const listReq = createTypedRequest<interfaces.requests.IReq_GetTokens>('getTokens');
|
||||
const listResp = await listReq.fire({ identity: context.identity });
|
||||
return { tokens: listResp.tokens };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const revokeTokenAction = tokensStatePart.createAction<{
|
||||
tokenId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_RevokeToken>('revokeToken');
|
||||
await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
tokenId: dataArg.tokenId,
|
||||
});
|
||||
const listReq = createTypedRequest<interfaces.requests.IReq_GetTokens>('getTokens');
|
||||
const listResp = await listReq.fire({ identity: context.identity });
|
||||
return { tokens: listResp.tokens };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Settings Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchUserSessionsAction = settingsStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetUserSessions>(
|
||||
'getUserSessions',
|
||||
);
|
||||
const response = await typedRequest.fire({ identity: context.identity });
|
||||
return { ...statePartArg.getState(), sessions: response.sessions };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const changePasswordAction = settingsStatePart.createAction<{
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_ChangePassword>(
|
||||
'changePassword',
|
||||
);
|
||||
await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
currentPassword: dataArg.currentPassword,
|
||||
newPassword: dataArg.newPassword,
|
||||
});
|
||||
return statePartArg.getState();
|
||||
});
|
||||
|
||||
export const updateProfileAction = settingsStatePart.createAction<{
|
||||
displayName?: string;
|
||||
avatarUrl?: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_UpdateUser>('updateUser');
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
userId: context.identity.userId,
|
||||
...dataArg,
|
||||
});
|
||||
return { ...statePartArg.getState(), user: response.user };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const revokeSessionAction = settingsStatePart.createAction<{
|
||||
sessionId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_RevokeSession>(
|
||||
'revokeSession',
|
||||
);
|
||||
await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
sessionId: dataArg.sessionId,
|
||||
});
|
||||
// Re-fetch sessions
|
||||
const listReq = createTypedRequest<interfaces.requests.IReq_GetUserSessions>('getUserSessions');
|
||||
const listResp = await listReq.fire({ identity: context.identity });
|
||||
return { ...statePartArg.getState(), sessions: listResp.sessions };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Admin Actions
|
||||
// ============================================================================
|
||||
|
||||
export const fetchAdminProvidersAction = adminStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetAdminProviders>(
|
||||
'getAdminProviders',
|
||||
);
|
||||
const response = await typedRequest.fire({ identity: context.identity });
|
||||
return { ...statePartArg.getState(), providers: response.providers };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const fetchPlatformSettingsAction = adminStatePart.createAction(async (statePartArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_GetPlatformSettings>(
|
||||
'getPlatformSettings',
|
||||
);
|
||||
const response = await typedRequest.fire({ identity: context.identity });
|
||||
return { ...statePartArg.getState(), platformSettings: response.settings };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const updatePlatformSettingsAction = adminStatePart.createAction<{
|
||||
auth?: Partial<interfaces.data.IPlatformAuthSettings>;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_UpdatePlatformSettings>(
|
||||
'updatePlatformSettings',
|
||||
);
|
||||
const response = await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
auth: dataArg.auth,
|
||||
});
|
||||
return { ...statePartArg.getState(), platformSettings: response.settings };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const deleteAdminProviderAction = adminStatePart.createAction<{
|
||||
providerId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_DeleteAdminProvider>(
|
||||
'deleteAdminProvider',
|
||||
);
|
||||
await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
providerId: dataArg.providerId,
|
||||
});
|
||||
const listReq = createTypedRequest<interfaces.requests.IReq_GetAdminProviders>(
|
||||
'getAdminProviders',
|
||||
);
|
||||
const listResp = await listReq.fire({ identity: context.identity });
|
||||
return { ...statePartArg.getState(), providers: listResp.providers };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
|
||||
export const testAdminProviderAction = adminStatePart.createAction<{
|
||||
providerId: string;
|
||||
}>(async (statePartArg, dataArg) => {
|
||||
const context = getActionContext();
|
||||
if (!context.identity) return statePartArg.getState();
|
||||
try {
|
||||
const typedRequest = createTypedRequest<interfaces.requests.IReq_TestAdminProvider>(
|
||||
'testAdminProvider',
|
||||
);
|
||||
await typedRequest.fire({
|
||||
identity: context.identity,
|
||||
providerId: dataArg.providerId,
|
||||
});
|
||||
// Re-fetch to get updated test results
|
||||
const listReq = createTypedRequest<interfaces.requests.IReq_GetAdminProviders>(
|
||||
'getAdminProviders',
|
||||
);
|
||||
const listResp = await listReq.fire({ identity: context.identity });
|
||||
return { ...statePartArg.getState(), providers: listResp.providers };
|
||||
} catch {
|
||||
return statePartArg.getState();
|
||||
}
|
||||
});
|
||||
8
ts_web/elements/index.ts
Normal file
8
ts_web/elements/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './shared/index.js';
|
||||
export * from './sg-app-shell.js';
|
||||
export * from './sg-view-dashboard.js';
|
||||
export * from './sg-view-organizations.js';
|
||||
export * from './sg-view-packages.js';
|
||||
export * from './sg-view-tokens.js';
|
||||
export * from './sg-view-settings.js';
|
||||
export * from './sg-view-admin.js';
|
||||
289
ts_web/elements/sg-app-shell.ts
Normal file
289
ts_web/elements/sg-app-shell.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as appstate from '../appstate.js';
|
||||
import * as interfaces from '../../ts_interfaces/index.js';
|
||||
import { appRouter } from '../router.js';
|
||||
import {
|
||||
DeesElement,
|
||||
customElement,
|
||||
html,
|
||||
state,
|
||||
css,
|
||||
cssManager,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
import type { SgViewDashboard } from './sg-view-dashboard.js';
|
||||
import type { SgViewOrganizations } from './sg-view-organizations.js';
|
||||
import type { SgViewPackages } from './sg-view-packages.js';
|
||||
import type { SgViewTokens } from './sg-view-tokens.js';
|
||||
import type { SgViewSettings } from './sg-view-settings.js';
|
||||
import type { SgViewAdmin } from './sg-view-admin.js';
|
||||
|
||||
@customElement('sg-app-shell')
|
||||
export class SgAppShell extends DeesElement {
|
||||
@state()
|
||||
accessor loginState: appstate.ILoginState = { identity: null, isLoggedIn: false };
|
||||
|
||||
@state()
|
||||
accessor uiState: appstate.IUiState = { activeView: 'dashboard' };
|
||||
|
||||
@state()
|
||||
accessor loginLoading: boolean = false;
|
||||
|
||||
@state()
|
||||
accessor loginError: string = '';
|
||||
|
||||
@state()
|
||||
accessor authProviders: interfaces.data.IPublicAuthProvider[] = [];
|
||||
|
||||
@state()
|
||||
accessor localAuthEnabled: boolean = true;
|
||||
|
||||
private viewTabs = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
iconName: 'lucide:layoutDashboard',
|
||||
element: (async () => (await import('./sg-view-dashboard.js')).SgViewDashboard)(),
|
||||
},
|
||||
{
|
||||
name: 'Organizations',
|
||||
iconName: 'lucide:building2',
|
||||
element: (async () => (await import('./sg-view-organizations.js')).SgViewOrganizations)(),
|
||||
},
|
||||
{
|
||||
name: 'Packages',
|
||||
iconName: 'lucide:package',
|
||||
element: (async () => (await import('./sg-view-packages.js')).SgViewPackages)(),
|
||||
},
|
||||
{
|
||||
name: 'Tokens',
|
||||
iconName: 'lucide:key',
|
||||
element: (async () => (await import('./sg-view-tokens.js')).SgViewTokens)(),
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
iconName: 'lucide:settings',
|
||||
element: (async () => (await import('./sg-view-settings.js')).SgViewSettings)(),
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
iconName: 'lucide:shield',
|
||||
element: (async () => (await import('./sg-view-admin.js')).SgViewAdmin)(),
|
||||
},
|
||||
];
|
||||
|
||||
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;
|
||||
});
|
||||
this.rxSubscriptions.push(loginSubscription);
|
||||
|
||||
const uiSubscription = appstate.uiStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((uiState) => {
|
||||
this.uiState = uiState;
|
||||
this.syncAppdashView(uiState.activeView);
|
||||
});
|
||||
this.rxSubscriptions.push(uiSubscription);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
.maincontainer {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (!this.loginState.isLoggedIn) {
|
||||
return html`
|
||||
<div class="maincontainer">
|
||||
<sg-login-view
|
||||
.providers=${this.authProviders}
|
||||
.localAuthEnabled=${this.localAuthEnabled}
|
||||
.loading=${this.loginLoading}
|
||||
.error=${this.loginError}
|
||||
@login=${(e: CustomEvent) => this.handleLocalLogin(e)}
|
||||
@oauth-login=${(e: CustomEvent) => this.handleOAuthLogin(e)}
|
||||
@ldap-login=${(e: CustomEvent) => this.handleLdapLogin(e)}
|
||||
></sg-login-view>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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>
|
||||
`;
|
||||
}
|
||||
|
||||
public async firstUpdated() {
|
||||
// Fetch auth providers for login page
|
||||
this.fetchAuthProviders();
|
||||
|
||||
// Resolve async view tab imports
|
||||
const allTabs = await Promise.all(
|
||||
this.viewTabs.map(async (tab) => ({
|
||||
name: tab.name,
|
||||
iconName: tab.iconName,
|
||||
element: await tab.element,
|
||||
})),
|
||||
);
|
||||
|
||||
// Filter admin tab based on user role
|
||||
this.resolvedViewTabs = this.loginState.identity?.isSystemAdmin
|
||||
? allTabs
|
||||
: allTabs.filter((t) => t.name !== 'Admin');
|
||||
|
||||
this.requestUpdate();
|
||||
await this.updateComplete;
|
||||
|
||||
const appDash = this.shadowRoot!.querySelector('dees-simple-appdash') as any;
|
||||
if (appDash) {
|
||||
appDash.addEventListener('view-select', (e: CustomEvent) => {
|
||||
const viewName = e.detail.view.name.toLowerCase().replace(/\s+/g, '-');
|
||||
appRouter.navigateToView(viewName);
|
||||
});
|
||||
appDash.addEventListener('logout', async () => {
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
});
|
||||
|
||||
// Load initial view
|
||||
if (this.resolvedViewTabs.length > 0) {
|
||||
const currentActiveView = appstate.uiStatePart.getState().activeView;
|
||||
const initialView = this.resolvedViewTabs.find(
|
||||
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === currentActiveView,
|
||||
) || this.resolvedViewTabs[0];
|
||||
await appDash.loadView(initialView);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stored session
|
||||
const loginState = appstate.loginStatePart.getState();
|
||||
if (loginState.identity?.jwt) {
|
||||
if (loginState.identity.expiresAt > Date.now()) {
|
||||
// Validate token with server in the background
|
||||
try {
|
||||
await appstate.settingsStatePart.dispatchAction(appstate.fetchMeAction, null);
|
||||
} catch {
|
||||
console.warn('Stored session invalid, returning to login');
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
}
|
||||
} else {
|
||||
// Token expired, try refresh
|
||||
const newState = await appstate.loginStatePart.dispatchAction(
|
||||
appstate.refreshTokenAction, null,
|
||||
);
|
||||
if (!newState.isLoggedIn) {
|
||||
// Refresh failed
|
||||
await appstate.loginStatePart.dispatchAction(appstate.logoutAction, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAuthProviders() {
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_GetAuthProviders
|
||||
>('/typedrequest', 'getAuthProviders');
|
||||
const response = await typedRequest.fire({});
|
||||
this.authProviders = response.providers;
|
||||
this.localAuthEnabled = response.localAuthEnabled;
|
||||
} catch {
|
||||
// Default to local auth if we can't fetch providers
|
||||
this.localAuthEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLocalLogin(e: CustomEvent) {
|
||||
const { email, password } = e.detail;
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
try {
|
||||
const newState = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
|
||||
email,
|
||||
password,
|
||||
});
|
||||
if (!newState.isLoggedIn) {
|
||||
this.loginError = 'Invalid email or password';
|
||||
}
|
||||
} catch {
|
||||
this.loginError = 'Login failed. Please try again.';
|
||||
}
|
||||
this.loginLoading = false;
|
||||
}
|
||||
|
||||
private async handleOAuthLogin(e: CustomEvent) {
|
||||
const { providerId } = e.detail;
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_OAuthAuthorize
|
||||
>('/typedrequest', 'oauthAuthorize');
|
||||
const response = await typedRequest.fire({ providerId });
|
||||
// Redirect to OAuth provider
|
||||
window.location.href = response.redirectUrl;
|
||||
} catch {
|
||||
this.loginError = 'OAuth login failed. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
private async handleLdapLogin(e: CustomEvent) {
|
||||
const { providerId, username, password } = e.detail;
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
try {
|
||||
const typedRequest = new plugins.domtools.plugins.typedrequest.TypedRequest<
|
||||
interfaces.requests.IReq_LdapLogin
|
||||
>('/typedrequest', 'ldapLogin');
|
||||
const response = await typedRequest.fire({ providerId, username, password });
|
||||
if (response.identity) {
|
||||
appstate.loginStatePart.setState({
|
||||
identity: response.identity,
|
||||
isLoggedIn: true,
|
||||
});
|
||||
} else {
|
||||
this.loginError = response.errorMessage || 'LDAP login failed';
|
||||
}
|
||||
} catch {
|
||||
this.loginError = 'LDAP login failed. Please try again.';
|
||||
}
|
||||
this.loginLoading = false;
|
||||
}
|
||||
|
||||
private syncAppdashView(viewName: string): void {
|
||||
const appDash = this.shadowRoot?.querySelector('dees-simple-appdash') as any;
|
||||
if (!appDash || this.resolvedViewTabs.length === 0) return;
|
||||
const targetTab = this.resolvedViewTabs.find(
|
||||
(t) => t.name.toLowerCase().replace(/\s+/g, '-') === viewName,
|
||||
);
|
||||
if (!targetTab) return;
|
||||
appDash.loadView(targetTab);
|
||||
}
|
||||
}
|
||||
100
ts_web/elements/sg-view-admin.ts
Normal file
100
ts_web/elements/sg-view-admin.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
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-admin')
|
||||
export class SgViewAdmin extends DeesElement {
|
||||
@state()
|
||||
accessor adminState: appstate.IAdminState = { providers: [], platformSettings: null };
|
||||
|
||||
@state()
|
||||
accessor editingProviderId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.adminStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.adminState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await appstate.adminStatePart.dispatchAction(appstate.fetchAdminProvidersAction, null);
|
||||
await appstate.adminStatePart.dispatchAction(appstate.fetchPlatformSettingsAction, null);
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.editingProviderId !== null) {
|
||||
const provider = this.editingProviderId
|
||||
? this.adminState.providers.find((p) => p.id === this.editingProviderId) || null
|
||||
: null;
|
||||
return html`
|
||||
<sg-admin-provider-form-view
|
||||
.provider="${provider}"
|
||||
@save="${(e: CustomEvent) => this.saveProvider(e.detail)}"
|
||||
@cancel="${() => {
|
||||
this.editingProviderId = null;
|
||||
}}"
|
||||
></sg-admin-provider-form-view>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<sg-admin-providers-view
|
||||
.providers="${this.adminState.providers}"
|
||||
.settings="${this.adminState.platformSettings}"
|
||||
@create="${() => {
|
||||
this.editingProviderId = '';
|
||||
}}"
|
||||
@edit="${(e: CustomEvent) => {
|
||||
this.editingProviderId = e.detail.providerId;
|
||||
}}"
|
||||
@delete="${(e: CustomEvent) => this.deleteProvider(e.detail.providerId)}"
|
||||
@test="${(e: CustomEvent) => this.testProvider(e.detail.providerId)}"
|
||||
@save-settings="${(e: CustomEvent) => this.saveSettings(e.detail.settings)}"
|
||||
></sg-admin-providers-view>
|
||||
`;
|
||||
}
|
||||
|
||||
private async saveProvider(detail: any) {
|
||||
// TODO: implement create/update provider
|
||||
this.editingProviderId = null;
|
||||
}
|
||||
|
||||
private async deleteProvider(providerId: string) {
|
||||
await appstate.adminStatePart.dispatchAction(
|
||||
appstate.deleteAdminProviderAction,
|
||||
{ providerId },
|
||||
);
|
||||
}
|
||||
|
||||
private async testProvider(providerId: string) {
|
||||
await appstate.adminStatePart.dispatchAction(
|
||||
appstate.testAdminProviderAction,
|
||||
{ providerId },
|
||||
);
|
||||
}
|
||||
|
||||
private async saveSettings(settings: any) {
|
||||
await appstate.adminStatePart.dispatchAction(
|
||||
appstate.updatePlatformSettingsAction,
|
||||
{ auth: settings },
|
||||
);
|
||||
}
|
||||
}
|
||||
100
ts_web/elements/sg-view-dashboard.ts
Normal file
100
ts_web/elements/sg-view-dashboard.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
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-dashboard')
|
||||
export class SgViewDashboard extends DeesElement {
|
||||
@state()
|
||||
accessor organizationsState: appstate.IOrganizationsState = {
|
||||
organizations: [],
|
||||
currentOrg: null,
|
||||
repositories: [],
|
||||
members: [],
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor packagesState: appstate.IPackagesState = {
|
||||
packages: [],
|
||||
currentPackage: null,
|
||||
versions: [],
|
||||
total: 0,
|
||||
query: '',
|
||||
protocolFilter: '',
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const orgSub = appstate.organizationsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.organizationsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(orgSub);
|
||||
|
||||
const pkgSub = appstate.packagesStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.packagesState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(pkgSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await appstate.organizationsStatePart.dispatchAction(appstate.fetchOrganizationsAction, null);
|
||||
await appstate.packagesStatePart.dispatchAction(appstate.searchPackagesAction, { offset: 0 });
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<sg-dashboard-view
|
||||
.stats="${{
|
||||
organizationCount: this.organizationsState.organizations.length,
|
||||
packageCount: this.packagesState.total,
|
||||
totalDownloads: 0,
|
||||
tokenCount: 0,
|
||||
}}"
|
||||
.recentPackages="${this.packagesState.packages.slice(0, 5)}"
|
||||
.organizations="${this.organizationsState.organizations}"
|
||||
@navigate="${(e: CustomEvent) => this.handleNavigate(e)}"
|
||||
></sg-dashboard-view>
|
||||
`;
|
||||
}
|
||||
|
||||
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 === '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 };
|
||||
}
|
||||
127
ts_web/elements/sg-view-organizations.ts
Normal file
127
ts_web/elements/sg-view-organizations.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
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-organizations')
|
||||
export class SgViewOrganizations extends DeesElement {
|
||||
@state()
|
||||
accessor organizationsState: appstate.IOrganizationsState = {
|
||||
organizations: [],
|
||||
currentOrg: null,
|
||||
repositories: [],
|
||||
members: [],
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor uiState: appstate.IUiState = { activeView: 'organizations' };
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const orgSub = appstate.organizationsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.organizationsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(orgSub);
|
||||
|
||||
const uiSub = appstate.uiStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.uiState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(uiSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await appstate.organizationsStatePart.dispatchAction(appstate.fetchOrganizationsAction, null);
|
||||
// If there's an entity ID, load the detail
|
||||
if (this.uiState.activeEntityId) {
|
||||
await this.loadOrgDetail(this.uiState.activeEntityId);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadOrgDetail(orgId: string) {
|
||||
await appstate.organizationsStatePart.dispatchAction(
|
||||
appstate.fetchOrganizationAction,
|
||||
{ organizationId: orgId },
|
||||
);
|
||||
await appstate.organizationsStatePart.dispatchAction(
|
||||
appstate.fetchRepositoriesAction,
|
||||
{ organizationId: orgId },
|
||||
);
|
||||
await appstate.organizationsStatePart.dispatchAction(
|
||||
appstate.fetchMembersAction,
|
||||
{ organizationId: orgId },
|
||||
);
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.uiState.activeEntityId && this.organizationsState.currentOrg) {
|
||||
return html`
|
||||
<sg-organization-detail-view
|
||||
.organization="${this.organizationsState.currentOrg}"
|
||||
.repositories="${this.organizationsState.repositories}"
|
||||
.members="${this.organizationsState.members}"
|
||||
@back="${() => this.goBack()}"
|
||||
@select-repo="${(e: CustomEvent) => this.selectRepo(e.detail.repositoryId)}"
|
||||
@create-repo="${() => {/* TODO: create repo modal */}}"
|
||||
></sg-organization-detail-view>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<sg-organizations-list-view
|
||||
.organizations="${this.organizationsState.organizations}"
|
||||
@select="${(e: CustomEvent) => this.selectOrg(e.detail.organizationId)}"
|
||||
@create="${(e: CustomEvent) => this.createOrg(e.detail)}"
|
||||
></sg-organizations-list-view>
|
||||
`;
|
||||
}
|
||||
|
||||
private selectOrg(orgId: string) {
|
||||
appstate.uiStatePart.setState({
|
||||
...appstate.uiStatePart.getState(),
|
||||
activeEntityId: orgId,
|
||||
});
|
||||
this.loadOrgDetail(orgId);
|
||||
}
|
||||
|
||||
private selectRepo(repoId: string) {
|
||||
// Navigate to repository within org context
|
||||
// For now, we could switch to packages view
|
||||
}
|
||||
|
||||
private goBack() {
|
||||
appstate.uiStatePart.setState({
|
||||
...appstate.uiStatePart.getState(),
|
||||
activeEntityId: undefined,
|
||||
});
|
||||
appstate.organizationsStatePart.setState({
|
||||
...appstate.organizationsStatePart.getState(),
|
||||
currentOrg: null,
|
||||
repositories: [],
|
||||
members: [],
|
||||
});
|
||||
}
|
||||
|
||||
private async createOrg(data: { name: string; displayName?: string; description?: string }) {
|
||||
await appstate.organizationsStatePart.dispatchAction(
|
||||
appstate.createOrganizationAction,
|
||||
data,
|
||||
);
|
||||
}
|
||||
}
|
||||
156
ts_web/elements/sg-view-packages.ts
Normal file
156
ts_web/elements/sg-view-packages.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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-packages')
|
||||
export class SgViewPackages extends DeesElement {
|
||||
@state()
|
||||
accessor packagesState: appstate.IPackagesState = {
|
||||
packages: [],
|
||||
currentPackage: null,
|
||||
versions: [],
|
||||
total: 0,
|
||||
query: '',
|
||||
protocolFilter: '',
|
||||
};
|
||||
|
||||
@state()
|
||||
accessor uiState: appstate.IUiState = { activeView: 'packages' };
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const pkgSub = appstate.packagesStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.packagesState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(pkgSub);
|
||||
|
||||
const uiSub = appstate.uiStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.uiState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(uiSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this.uiState.activeEntityId) {
|
||||
await this.loadPackageDetail(this.uiState.activeEntityId);
|
||||
} else {
|
||||
await appstate.packagesStatePart.dispatchAction(
|
||||
appstate.searchPackagesAction,
|
||||
{ offset: 0 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadPackageDetail(packageId: string) {
|
||||
await appstate.packagesStatePart.dispatchAction(
|
||||
appstate.fetchPackageAction,
|
||||
{ packageId },
|
||||
);
|
||||
await appstate.packagesStatePart.dispatchAction(
|
||||
appstate.fetchPackageVersionsAction,
|
||||
{ packageId },
|
||||
);
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
if (this.uiState.activeEntityId && this.packagesState.currentPackage) {
|
||||
return html`
|
||||
<sg-package-detail-view
|
||||
.package="${this.packagesState.currentPackage}"
|
||||
.versions="${this.packagesState.versions}"
|
||||
@back="${() => this.goBack()}"
|
||||
@delete="${(e: CustomEvent) => this.deletePackage(e.detail.packageId)}"
|
||||
@delete-version="${(e: CustomEvent) => this.deleteVersion(e.detail)}"
|
||||
></sg-package-detail-view>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<sg-packages-list-view
|
||||
.packages="${this.packagesState.packages}"
|
||||
.total="${this.packagesState.total}"
|
||||
.query="${this.packagesState.query}"
|
||||
.protocols="${['npm', 'oci', 'maven', 'cargo', 'pypi', 'composer', 'rubygems']}"
|
||||
@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-packages-list-view>
|
||||
`;
|
||||
}
|
||||
|
||||
private selectPackage(packageId: string) {
|
||||
appstate.uiStatePart.setState({
|
||||
...appstate.uiStatePart.getState(),
|
||||
activeEntityId: packageId,
|
||||
});
|
||||
this.loadPackageDetail(packageId);
|
||||
}
|
||||
|
||||
private goBack() {
|
||||
appstate.uiStatePart.setState({
|
||||
...appstate.uiStatePart.getState(),
|
||||
activeEntityId: undefined,
|
||||
});
|
||||
appstate.packagesStatePart.setState({
|
||||
...appstate.packagesStatePart.getState(),
|
||||
currentPackage: null,
|
||||
versions: [],
|
||||
});
|
||||
}
|
||||
|
||||
private async search(query: string) {
|
||||
await appstate.packagesStatePart.dispatchAction(
|
||||
appstate.searchPackagesAction,
|
||||
{ query, protocol: this.packagesState.protocolFilter, offset: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
private async filter(protocol: string) {
|
||||
await appstate.packagesStatePart.dispatchAction(
|
||||
appstate.searchPackagesAction,
|
||||
{ query: this.packagesState.query, protocol, offset: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
private async paginate(offset: number) {
|
||||
await appstate.packagesStatePart.dispatchAction(
|
||||
appstate.searchPackagesAction,
|
||||
{
|
||||
query: this.packagesState.query,
|
||||
protocol: this.packagesState.protocolFilter,
|
||||
offset,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private async deletePackage(packageId: string) {
|
||||
await appstate.packagesStatePart.dispatchAction(
|
||||
appstate.deletePackageAction,
|
||||
{ packageId },
|
||||
);
|
||||
this.goBack();
|
||||
}
|
||||
|
||||
private async deleteVersion(detail: { packageId: string; version: string }) {
|
||||
// TODO: implement deletePackageVersion action
|
||||
}
|
||||
}
|
||||
67
ts_web/elements/sg-view-settings.ts
Normal file
67
ts_web/elements/sg-view-settings.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
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-settings')
|
||||
export class SgViewSettings extends DeesElement {
|
||||
@state()
|
||||
accessor settingsState: appstate.ISettingsState = { user: null, sessions: [] };
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const sub = appstate.settingsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.settingsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(sub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await appstate.settingsStatePart.dispatchAction(appstate.fetchMeAction, null);
|
||||
await appstate.settingsStatePart.dispatchAction(appstate.fetchUserSessionsAction, null);
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<sg-settings-view
|
||||
.user="${this.settingsState.user}"
|
||||
.sessions="${this.settingsState.sessions}"
|
||||
@save-profile="${(e: CustomEvent) => this.saveProfile(e.detail)}"
|
||||
@change-password="${(e: CustomEvent) => this.changePassword(e.detail)}"
|
||||
@revoke-session="${(e: CustomEvent) => this.revokeSession(e.detail.sessionId)}"
|
||||
@delete-account="${(e: CustomEvent) => this.deleteAccount(e.detail.password)}"
|
||||
></sg-settings-view>
|
||||
`;
|
||||
}
|
||||
|
||||
private async saveProfile(detail: { displayName?: string; avatarUrl?: string }) {
|
||||
await appstate.settingsStatePart.dispatchAction(appstate.updateProfileAction, detail);
|
||||
}
|
||||
|
||||
private async changePassword(detail: { currentPassword: string; newPassword: string }) {
|
||||
await appstate.settingsStatePart.dispatchAction(appstate.changePasswordAction, detail);
|
||||
}
|
||||
|
||||
private async revokeSession(sessionId: string) {
|
||||
await appstate.settingsStatePart.dispatchAction(appstate.revokeSessionAction, { sessionId });
|
||||
}
|
||||
|
||||
private async deleteAccount(password: string) {
|
||||
// TODO: implement delete account action
|
||||
}
|
||||
}
|
||||
72
ts_web/elements/sg-view-tokens.ts
Normal file
72
ts_web/elements/sg-view-tokens.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
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-tokens')
|
||||
export class SgViewTokens extends DeesElement {
|
||||
@state()
|
||||
accessor tokensState: appstate.ITokensState = { tokens: [] };
|
||||
|
||||
@state()
|
||||
accessor organizationsState: appstate.IOrganizationsState = {
|
||||
organizations: [],
|
||||
currentOrg: null,
|
||||
repositories: [],
|
||||
members: [],
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const tokenSub = appstate.tokensStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.tokensState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(tokenSub);
|
||||
|
||||
const orgSub = appstate.organizationsStatePart
|
||||
.select((s) => s)
|
||||
.subscribe((s) => {
|
||||
this.organizationsState = s;
|
||||
});
|
||||
this.rxSubscriptions.push(orgSub);
|
||||
}
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
shared.viewHostCss,
|
||||
];
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await appstate.tokensStatePart.dispatchAction(appstate.fetchTokensAction, {});
|
||||
await appstate.organizationsStatePart.dispatchAction(appstate.fetchOrganizationsAction, null);
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<sg-tokens-view
|
||||
.tokens="${this.tokensState.tokens}"
|
||||
.organizations="${this.organizationsState.organizations}"
|
||||
@create="${(e: CustomEvent) => this.createToken(e.detail)}"
|
||||
@revoke="${(e: CustomEvent) => this.revokeToken(e.detail.tokenId)}"
|
||||
></sg-tokens-view>
|
||||
`;
|
||||
}
|
||||
|
||||
private async createToken(detail: any) {
|
||||
await appstate.tokensStatePart.dispatchAction(appstate.createTokenAction, detail);
|
||||
}
|
||||
|
||||
private async revokeToken(tokenId: string) {
|
||||
await appstate.tokensStatePart.dispatchAction(appstate.revokeTokenAction, { tokenId });
|
||||
}
|
||||
}
|
||||
12
ts_web/elements/shared/css.ts
Normal file
12
ts_web/elements/shared/css.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
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;
|
||||
}
|
||||
`;
|
||||
2
ts_web/elements/shared/index.ts
Normal file
2
ts_web/elements/shared/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './css.js';
|
||||
export * from './sg-sectionheading.js';
|
||||
43
ts_web/elements/shared/sg-sectionheading.ts
Normal file
43
ts_web/elements/shared/sg-sectionheading.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
css,
|
||||
cssManager,
|
||||
customElement,
|
||||
DeesElement,
|
||||
html,
|
||||
type TemplateResult,
|
||||
} from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sg-sectionheading': SgSectionheading;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sg-sectionheading')
|
||||
export class SgSectionheading extends DeesElement {
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.heading {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: ${cssManager.bdTheme('#111', '#fff')};
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 2px solid ${cssManager.bdTheme('#111', '#fff')};
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
<div class="heading">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
14
ts_web/index.ts
Normal file
14
ts_web/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import { html } from '@design.estate/dees-element';
|
||||
import './elements/index.js';
|
||||
import { appRouter } from './router.js';
|
||||
|
||||
// Initialize router before rendering (handles initial URL -> state)
|
||||
appRouter.init();
|
||||
|
||||
plugins.deesElement.render(
|
||||
html`
|
||||
<sg-app-shell></sg-app-shell>
|
||||
`,
|
||||
document.body,
|
||||
);
|
||||
11
ts_web/plugins.ts
Normal file
11
ts_web/plugins.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// @design.estate scope
|
||||
import * as deesElement from '@design.estate/dees-element';
|
||||
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||
|
||||
// @stack.gallery scope — side-effect import registers all sg-* custom elements
|
||||
import '@stack.gallery/catalog';
|
||||
|
||||
export { deesCatalog, deesElement };
|
||||
|
||||
// domtools gives us TypedRequest, smartstate, smartrouter, and other utilities
|
||||
export const domtools = deesElement.domtools;
|
||||
155
ts_web/router.ts
Normal file
155
ts_web/router.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as plugins from './plugins.js';
|
||||
import * as appstate from './appstate.js';
|
||||
|
||||
const SmartRouter = plugins.domtools.plugins.smartrouter.SmartRouter;
|
||||
|
||||
export const validViews = [
|
||||
'dashboard',
|
||||
'organizations',
|
||||
'packages',
|
||||
'tokens',
|
||||
'settings',
|
||||
'admin',
|
||||
] as const;
|
||||
|
||||
export type TValidView = typeof validViews[number];
|
||||
|
||||
class AppRouter {
|
||||
private router: InstanceType<typeof SmartRouter>;
|
||||
private initialized = false;
|
||||
private suppressStateUpdate = false;
|
||||
|
||||
constructor() {
|
||||
this.router = new SmartRouter({ debug: false });
|
||||
}
|
||||
|
||||
public init(): void {
|
||||
if (this.initialized) return;
|
||||
this.setupRoutes();
|
||||
this.setupStateSync();
|
||||
this.handleInitialRoute();
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private setupRoutes(): void {
|
||||
for (const view of validViews) {
|
||||
this.router.on(`/${view}`, async () => {
|
||||
this.updateViewState(view);
|
||||
});
|
||||
}
|
||||
|
||||
// Root redirect
|
||||
this.router.on('/', async () => {
|
||||
this.navigateTo('/dashboard');
|
||||
});
|
||||
}
|
||||
|
||||
private setupStateSync(): void {
|
||||
appstate.uiStatePart.select((s) => s.activeView).subscribe((activeView) => {
|
||||
if (this.suppressStateUpdate) return;
|
||||
|
||||
const currentPath = window.location.pathname;
|
||||
const expectedPath = `/${activeView}`;
|
||||
|
||||
if (currentPath !== expectedPath) {
|
||||
this.suppressStateUpdate = true;
|
||||
this.router.pushUrl(expectedPath);
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private handleInitialRoute(): void {
|
||||
const path = window.location.pathname;
|
||||
|
||||
// Handle OAuth callback
|
||||
if (path === '/oauth-callback') {
|
||||
this.handleOAuthCallback();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!path || path === '/') {
|
||||
this.router.pushUrl('/dashboard');
|
||||
} else {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
const view = segments[0];
|
||||
|
||||
if (validViews.includes(view as TValidView)) {
|
||||
this.updateViewState(view as TValidView);
|
||||
// If there's a sub-path, store the entity ID
|
||||
if (segments[1]) {
|
||||
const currentState = appstate.uiStatePart.getState();
|
||||
appstate.uiStatePart.setState({
|
||||
...currentState,
|
||||
activeEntityId: segments[1],
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.router.pushUrl('/dashboard');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleOAuthCallback(): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const accessToken = params.get('accessToken');
|
||||
const refreshToken = params.get('refreshToken');
|
||||
const sessionId = params.get('sessionId');
|
||||
|
||||
if (accessToken && refreshToken && sessionId) {
|
||||
// Store tokens and redirect to dashboard
|
||||
// The app shell will pick up the identity from loginStatePart
|
||||
appstate.handleOAuthCallback(accessToken, refreshToken, sessionId);
|
||||
}
|
||||
|
||||
// Redirect to dashboard
|
||||
this.navigateTo('/dashboard');
|
||||
}
|
||||
|
||||
private updateViewState(view: string): void {
|
||||
this.suppressStateUpdate = true;
|
||||
const currentState = appstate.uiStatePart.getState();
|
||||
if (currentState.activeView !== view) {
|
||||
appstate.uiStatePart.setState({
|
||||
...currentState,
|
||||
activeView: view,
|
||||
activeEntityId: undefined,
|
||||
});
|
||||
}
|
||||
this.suppressStateUpdate = false;
|
||||
}
|
||||
|
||||
public navigateTo(path: string): void {
|
||||
this.router.pushUrl(path);
|
||||
}
|
||||
|
||||
public navigateToView(view: string): void {
|
||||
const normalized = view.toLowerCase().replace(/\s+/g, '-');
|
||||
if (validViews.includes(normalized as TValidView)) {
|
||||
this.navigateTo(`/${normalized}`);
|
||||
} else {
|
||||
this.navigateTo('/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
public navigateToEntity(view: string, entityId: string): void {
|
||||
const currentState = appstate.uiStatePart.getState();
|
||||
appstate.uiStatePart.setState({
|
||||
...currentState,
|
||||
activeView: view,
|
||||
activeEntityId: entityId,
|
||||
});
|
||||
this.router.pushUrl(`/${view}/${entityId}`);
|
||||
}
|
||||
|
||||
public getCurrentView(): string {
|
||||
return appstate.uiStatePart.getState().activeView;
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
this.router.destroy();
|
||||
this.initialized = false;
|
||||
}
|
||||
}
|
||||
|
||||
export const appRouter = new AppRouter();
|
||||
Reference in New Issue
Block a user