mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-05-08 18:21:55 +00:00
271 lines
8.1 KiB
Go
271 lines
8.1 KiB
Go
package portal
|
|
|
|
import (
|
|
"errors"
|
|
"html/template"
|
|
"net/http"
|
|
"time"
|
|
|
|
cpauth "github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/auth"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/cpsec"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/cloudcp/registry"
|
|
"github.com/rs/zerolog/log"
|
|
)
|
|
|
|
// portalPageWorkspace holds per-workspace display data for the portal template.
|
|
type portalPageWorkspace struct {
|
|
ID string
|
|
DisplayName string
|
|
State string
|
|
Healthy bool
|
|
HealthStatus string
|
|
LastHealthCheck *time.Time
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type portalPageMember struct {
|
|
UserID string
|
|
Email string
|
|
Role registry.MemberRole
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// portalPageAccount holds per-account display data including the user's role.
|
|
type portalPageAccount struct {
|
|
ID string
|
|
Kind string
|
|
KindLabel string
|
|
Name string
|
|
Role string
|
|
CanManage bool // owner or admin
|
|
HasBilling bool // true when a Stripe customer exists for the account
|
|
Workspaces []portalPageWorkspace
|
|
Members []portalPageMember
|
|
}
|
|
|
|
// portalPageData is passed to the portal HTML template.
|
|
type portalPageData struct {
|
|
Nonce string
|
|
FaviconHref string
|
|
Styles template.CSS
|
|
ShellScript template.JS
|
|
BootstrapJSON template.JS
|
|
}
|
|
|
|
const (
|
|
defaultPublicSiteURL = "https://pulserelay.pro"
|
|
defaultSupportEmail = "support@pulserelay.pro"
|
|
defaultCommercialAPIBaseURL = PortalCommercialAPIBasePath
|
|
defaultPortalPath = PortalPagePath
|
|
defaultLogoutPath = PortalLogoutPath
|
|
defaultAccountAPIBasePath = PortalAccountAPIBasePath
|
|
defaultPortalAPIBasePath = PortalAPIBasePath
|
|
)
|
|
|
|
var errPortalAuthRequired = errors.New("portal auth required")
|
|
|
|
// HandlePortalPage serves the MSP/Cloud portal dashboard (browser-facing HTML).
|
|
// Route: GET /portal
|
|
// - No session or invalid session -> shows a magic-link login form
|
|
// - Valid session -> shows workspace list with management actions
|
|
func HandlePortalPage(sessionSvc *cpauth.Service, reg *registry.TenantRegistry, commercialLookup CommercialIdentityLookup, faviconHref string) http.HandlerFunc {
|
|
return HandlePortalPageWithSignupPath(sessionSvc, reg, commercialLookup, faviconHref, PortalSignupPath)
|
|
}
|
|
|
|
func HandlePortalPageWithSignupPath(sessionSvc *cpauth.Service, reg *registry.TenantRegistry, commercialLookup CommercialIdentityLookup, faviconHref string, signupPath string) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodGet {
|
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
nonce := cpsec.NonceFromContext(r.Context())
|
|
|
|
claims, err := validatePortalSessionClaims(r, sessionSvc, reg)
|
|
switch {
|
|
case err == nil:
|
|
accounts, loadErr := loadPortalAccountsForUser(reg, claims.UserID)
|
|
if loadErr != nil {
|
|
log.Error().Err(loadErr).Str("user_id", claims.UserID).Msg("cloudcp.portal.page: load accounts")
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
renderPortalPage(w, nonce, faviconHref, BuildBootstrapDataWithSignupPath(true, claims.Email, accounts, resolveSelfHostedCommercial(r.Context(), commercialLookup, claims.Email, accounts), signupPath))
|
|
case errors.Is(err, errPortalAuthRequired):
|
|
renderPortalPage(w, nonce, faviconHref, BuildAnonymousBootstrapDataWithSignupPath(signupPath))
|
|
default:
|
|
log.Error().Err(err).Msg("cloudcp.portal.page: validate session")
|
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
}
|
|
|
|
func validatePortalSessionClaims(r *http.Request, sessionSvc *cpauth.Service, reg *registry.TenantRegistry) (*cpauth.SessionClaims, error) {
|
|
if r == nil || sessionSvc == nil || reg == nil {
|
|
return nil, errPortalAuthRequired
|
|
}
|
|
|
|
token := cpauth.SessionTokenFromRequest(r)
|
|
if token == "" {
|
|
return nil, errPortalAuthRequired
|
|
}
|
|
|
|
claims, err := sessionSvc.ValidateSessionToken(token)
|
|
if err != nil {
|
|
return nil, errPortalAuthRequired
|
|
}
|
|
|
|
sessionVersion, err := reg.GetUserSessionVersion(claims.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if claims.SessionVersion != sessionVersion {
|
|
return nil, errPortalAuthRequired
|
|
}
|
|
return claims, nil
|
|
}
|
|
|
|
func loadPortalAccountsForUser(reg *registry.TenantRegistry, userID string) ([]portalPageAccount, error) {
|
|
accountIDs, err := reg.ListAccountsByUser(userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
accounts := make([]portalPageAccount, 0, len(accountIDs))
|
|
for _, accountID := range accountIDs {
|
|
a, err := reg.GetAccount(accountID)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("account_id", accountID).Msg("cloudcp.portal.page: get account")
|
|
continue
|
|
}
|
|
if a == nil {
|
|
continue
|
|
}
|
|
|
|
m, err := reg.GetMembership(accountID, userID)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("account_id", accountID).Str("user_id", userID).Msg("cloudcp.portal.page: get membership")
|
|
continue
|
|
}
|
|
if m == nil {
|
|
continue
|
|
}
|
|
|
|
tenants, err := reg.ListByAccountID(accountID)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("account_id", accountID).Msg("cloudcp.portal.page: list tenants")
|
|
continue
|
|
}
|
|
members, err := loadPortalAccountMembers(reg, accountID)
|
|
if err != nil {
|
|
log.Error().Err(err).Str("account_id", accountID).Msg("cloudcp.portal.page: list members")
|
|
continue
|
|
}
|
|
|
|
workspaces := make([]portalPageWorkspace, 0, len(tenants))
|
|
for _, t := range tenants {
|
|
if t == nil {
|
|
continue
|
|
}
|
|
if t.State == registry.TenantStateDeleted || t.State == registry.TenantStateDeleting {
|
|
continue
|
|
}
|
|
workspaces = append(workspaces, portalPageWorkspace{
|
|
ID: t.ID,
|
|
DisplayName: t.DisplayName,
|
|
State: string(t.State),
|
|
Healthy: t.HealthCheckOK,
|
|
HealthStatus: workspaceHealthStatus(t.HealthCheckOK, t.LastHealthCheck),
|
|
LastHealthCheck: t.LastHealthCheck,
|
|
CreatedAt: t.CreatedAt,
|
|
})
|
|
}
|
|
|
|
kindLabel := "Cloud"
|
|
if a.Kind == registry.AccountKindMSP {
|
|
kindLabel = "MSP"
|
|
}
|
|
|
|
hasBilling := false
|
|
if sa, saErr := reg.GetStripeAccount(accountID); saErr != nil {
|
|
log.Warn().Err(saErr).Str("account_id", accountID).Msg("cloudcp.portal.page: lookup stripe account for billing button")
|
|
} else if sa != nil && sa.StripeCustomerID != "" {
|
|
hasBilling = true
|
|
}
|
|
|
|
accounts = append(accounts, portalPageAccount{
|
|
ID: a.ID,
|
|
Kind: string(a.Kind),
|
|
KindLabel: kindLabel,
|
|
Name: a.DisplayName,
|
|
Role: string(m.Role),
|
|
CanManage: m.Role == registry.MemberRoleOwner || m.Role == registry.MemberRoleAdmin,
|
|
HasBilling: hasBilling,
|
|
Workspaces: workspaces,
|
|
Members: members,
|
|
})
|
|
}
|
|
|
|
return accounts, nil
|
|
}
|
|
|
|
func loadPortalAccountMembers(reg *registry.TenantRegistry, accountID string) ([]portalPageMember, error) {
|
|
memberships, err := reg.ListMembersByAccount(accountID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if memberships == nil {
|
|
return []portalPageMember{}, nil
|
|
}
|
|
|
|
members := make([]portalPageMember, 0, len(memberships))
|
|
for _, membership := range memberships {
|
|
if membership == nil {
|
|
continue
|
|
}
|
|
user, err := reg.GetUser(membership.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return nil, errors.New("portal member user not found")
|
|
}
|
|
members = append(members, portalPageMember{
|
|
UserID: membership.UserID,
|
|
Email: user.Email,
|
|
Role: membership.Role,
|
|
CreatedAt: membership.CreatedAt,
|
|
})
|
|
}
|
|
|
|
return members, nil
|
|
}
|
|
|
|
func workspaceHealthStatus(healthy bool, lastHealthCheck *time.Time) string {
|
|
if healthy {
|
|
return "healthy"
|
|
}
|
|
if lastHealthCheck == nil {
|
|
return "checking"
|
|
}
|
|
return "unhealthy"
|
|
}
|
|
|
|
func renderPortalPage(w http.ResponseWriter, nonce string, faviconHref string, bootstrapData BootstrapData) {
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.WriteHeader(http.StatusOK)
|
|
bootstrapJSON, err := MarshalBootstrapJSON(bootstrapData)
|
|
if err != nil {
|
|
log.Error().Err(err).Msg("cloudcp.portal.page: marshal bootstrap data")
|
|
bootstrapJSON = template.JS(`{}`)
|
|
}
|
|
if err := portalPageTmpl.Execute(w, portalPageData{
|
|
Nonce: nonce,
|
|
FaviconHref: faviconHref,
|
|
Styles: portalStyles,
|
|
ShellScript: portalShellScript,
|
|
BootstrapJSON: bootstrapJSON,
|
|
}); err != nil {
|
|
log.Error().Err(err).Msg("cloudcp.portal.page: render portal page")
|
|
}
|
|
}
|