diff --git a/services/backend/cmd/server/main.go b/services/backend/cmd/server/main.go index bbebbb22..9fad6e40 100644 --- a/services/backend/cmd/server/main.go +++ b/services/backend/cmd/server/main.go @@ -25,7 +25,7 @@ func main() { databaseURL := os.Getenv("JUST_GATE_DATABASE_URL") svc, err := service.New(service.Config{ - Version: "1.2.0", + Version: "1.3.0", AdminJWTSecret: adminJWTSecret, DatabaseURL: databaseURL, TenantHeaderName: tenantHeaderName, @@ -34,7 +34,7 @@ func main() { OIDCClientSecret: os.Getenv("JUST_GATE_OIDC_CLIENT_SECRET"), OIDCDisplayName: os.Getenv("JUST_GATE_OIDC_NAME"), ExtraCAFile: os.Getenv("JUST_GATE_EXTRA_CA_FILE"), - InitialPlatformAdminEmail: os.Getenv("JUSTGATE_INITIAL_ADMIN_EMAIL"), + InitialPlatformAdminEmail: os.Getenv("JUST_GATE_INITIAL_ADMIN_EMAIL"), RedisURL: os.Getenv("JUST_GATE_REDIS_URL"), InstanceID: os.Getenv("JUST_GATE_INSTANCE_ID"), Region: os.Getenv("JUST_GATE_REGION"), diff --git a/services/backend/go.mod b/services/backend/go.mod index 71b81474..7031e3c3 100644 --- a/services/backend/go.mod +++ b/services/backend/go.mod @@ -5,10 +5,9 @@ go 1.25.0 require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/uuid v1.6.0 - github.com/gorilla/websocket v1.5.3 - github.com/jackc/pgx/v5 v5.8.0 - golang.org/x/crypto v0.48.0 - modernc.org/sqlite v1.46.1 + github.com/jackc/pgx/v5 v5.9.1 + golang.org/x/crypto v0.50.0 + modernc.org/sqlite v1.48.2 ) require ( @@ -19,11 +18,10 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect - modernc.org/libc v1.67.6 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/services/backend/go.sum b/services/backend/go.sum index bb92c7e0..f7494589 100644 --- a/services/backend/go.sum +++ b/services/backend/go.sum @@ -9,16 +9,14 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= -github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= +github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -34,39 +32,37 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= -golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= -modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= -modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= -modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= -modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= -modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -75,8 +71,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= -modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= +modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/services/backend/internal/service/extra_handlers.go b/services/backend/internal/service/extra_handlers.go new file mode 100644 index 00000000..12945ad6 --- /dev/null +++ b/services/backend/internal/service/extra_handlers.go @@ -0,0 +1,399 @@ +package service + +// extra_handlers.go — implements the new endpoints added in v3: +// +// GET /api/v1/admin/orgs/{orgID}/invites list pending invites (owner) +// GET /api/v1/invite-preview?code=… public invite preview +// PATCH /api/v1/admin/orgs/{orgID}/members/{userID} change member role (owner) +// POST /api/v1/admin/tokens/{tokenID}/extend extend token expiry +// POST /api/v1/admin/routes/{routeID}/duplicate duplicate route +// GET /api/v1/admin/export export org config +// POST /api/v1/admin/import import org config + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// ── Invite list ──────────────────────────────────────────────────────── + +// handleListOrgInvites: GET /api/v1/admin/orgs/{orgID}/invites +// Called from handleOrgByID when subPath == "invites" and method == GET. +func (s *Service) handleListOrgInvites(writer http.ResponseWriter, request *http.Request, orgID, callerRole string) { + if request.Method != http.MethodGet { + // POST is handled by existing handleOrgInvites, so just list here. + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if callerRole != "owner" { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "only owners can list invites"}) + return + } + invites, err := s.store.ListOrgInvites(request.Context(), orgID) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "failed to list invites"}) + return + } + type inviteSummary struct { + ID string `json:"id"` + Code string `json:"code"` + ExpiresAt string `json:"expiresAt"` + MaxUses int `json:"maxUses"` + UseCount int `json:"useCount"` + CreatedBy string `json:"createdBy"` + CreatedAt string `json:"createdAt"` + } + items := make([]inviteSummary, 0, len(invites)) + for _, inv := range invites { + items = append(items, inviteSummary{ + ID: inv.ID, + Code: inv.Code, + ExpiresAt: inv.ExpiresAt.UTC().Format(time.RFC3339), + MaxUses: inv.MaxUses, + UseCount: inv.UseCount, + CreatedBy: inv.CreatedBy, + CreatedAt: inv.CreatedAt.UTC().Format(time.RFC3339), + }) + } + writeJSON(writer, http.StatusOK, items) +} + +// handleDeleteOrgInvite: DELETE /api/v1/admin/orgs/{orgID}/invites/{inviteID} +func (s *Service) handleDeleteOrgInvite(writer http.ResponseWriter, request *http.Request, orgID, inviteID, callerRole string) { + if request.Method != http.MethodDelete { + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if callerRole != "owner" { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "only owners can revoke invites"}) + return + } + if err := s.store.DeleteOrgInvite(request.Context(), orgID, inviteID); err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "failed to revoke invite"}) + return + } + writer.WriteHeader(http.StatusNoContent) +} + +// ── Public invite preview ────────────────────────────────────────────── + +// handleInvitePreview: GET /api/v1/invite-preview?code=… +// No auth required — used by /join page to show org name before accepting. +func (s *Service) handleInvitePreview(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + code := strings.TrimSpace(request.URL.Query().Get("code")) + if code == "" { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "code is required"}) + return + } + invite, ok, err := s.store.GetOrgInviteByCode(request.Context(), code) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "lookup failed"}) + return + } + if !ok { + writeJSON(writer, http.StatusNotFound, map[string]string{"error": "invite not found"}) + return + } + if invite.ExpiresAt.Before(time.Now().UTC()) { + writeJSON(writer, http.StatusGone, map[string]string{"error": "invite has expired"}) + return + } + // Fetch the org name. + org, orgOk, orgErr := s.store.GetOrgByID(request.Context(), invite.OrgID) + orgName := invite.OrgID + if orgErr == nil && orgOk { + orgName = org.Name + } + writeJSON(writer, http.StatusOK, map[string]any{ + "orgID": invite.OrgID, + "orgName": orgName, + "expiresAt": invite.ExpiresAt.UTC().Format(time.RFC3339), + "maxUses": invite.MaxUses, + "useCount": invite.UseCount, + }) +} + +// ── Member role change ───────────────────────────────────────────────── + +type changeMemberRoleRequest struct { + Role string `json:"role"` +} + +// handleChangeMemberRole: PATCH /api/v1/admin/orgs/{orgID}/members/{userID} +func (s *Service) handleChangeMemberRole(writer http.ResponseWriter, request *http.Request, orgID, memberID, callerRole string) { + if request.Method != http.MethodPatch { + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if callerRole != "owner" { + writeJSON(writer, http.StatusForbidden, map[string]string{"error": "only owners can change member roles"}) + return + } + var payload changeMemberRoleRequest + if err := decodeJSON(request, &payload); err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + role := strings.TrimSpace(payload.Role) + if role != "owner" && role != "member" { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "role must be 'owner' or 'member'"}) + return + } + if err := s.store.UpdateOrgMemberRole(request.Context(), orgID, memberID, role); err != nil { + status := http.StatusInternalServerError + if err.Error() == "member not found" { + status = http.StatusNotFound + } + writeJSON(writer, status, map[string]string{"error": err.Error()}) + return + } + s.recordAdminAction(request.Context(), "change_member_role", "org_member", memberID, fmt.Sprintf("role=%s org=%s", role, orgID)) + writeJSON(writer, http.StatusOK, map[string]string{"role": role}) +} + +// ── Token expiry extension ───────────────────────────────────────────── + +type extendTokenRequest struct { + NewExpiresAt string `json:"newExpiresAt"` +} + +// handleExtendToken: POST /api/v1/admin/tokens/{tokenID}/extend +func (s *Service) handleExtendToken(writer http.ResponseWriter, request *http.Request, tokenID string) { + var payload extendTokenRequest + if err := decodeJSON(request, &payload); err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + payload.NewExpiresAt = strings.TrimSpace(payload.NewExpiresAt) + if payload.NewExpiresAt == "" { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "newExpiresAt is required"}) + return + } + newExpiry, err := parseTimestamp(payload.NewExpiresAt) + if err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "newExpiresAt must be RFC3339 or datetime-local"}) + return + } + if !newExpiry.After(time.Now().UTC()) { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "newExpiresAt must be in the future"}) + return + } + token, ok, err := s.store.GetTokenByID(request.Context(), tokenID) + if err != nil || !ok { + writeJSON(writer, http.StatusNotFound, map[string]string{"error": "token not found"}) + return + } + if err := s.store.ExtendTokenExpiry(request.Context(), tokenID, newExpiry); err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "failed to extend token"}) + return + } + s.recordAdminAction(request.Context(), "extend_token", "token", tokenID, fmt.Sprintf("until=%s", newExpiry.Format(time.RFC3339))) + writeJSON(writer, http.StatusOK, tokenSummary{ + ID: token.ID, + Name: token.Name, + TenantID: token.TenantID, + Scopes: token.Scopes, + ExpiresAt: newExpiry.Format(time.RFC3339), + LastUsedAt: token.LastUsedAt.Format(time.RFC3339), + CreatedAt: token.CreatedAt.Format(time.RFC3339), + Preview: token.Preview, + Active: token.Active, + RateLimitRPM: token.RateLimitRPM, + RateLimitBurst: token.RateLimitBurst, + }) +} + +// ── Route duplication ────────────────────────────────────────────────── + +// handleDuplicateRoute: POST /api/v1/admin/routes/{routeID}/duplicate +func (s *Service) handleDuplicateRoute(writer http.ResponseWriter, request *http.Request, routeID string) { + route, ok, err := s.store.GetRouteByID(request.Context(), routeID) + if err != nil || !ok { + writeJSON(writer, http.StatusNotFound, map[string]string{"error": "route not found"}) + return + } + // Build a unique slug for the copy. + copySlug := route.Slug + "-copy" + existing, _, _ := s.store.RouteBySlug(request.Context(), copySlug) + if existing.ID != "" { + copySlug = fmt.Sprintf("%s-copy-%d", route.Slug, time.Now().Unix()%10000) + } + + payload := createRouteRequest{ + Slug: copySlug, + TargetPath: route.TargetPath, + TenantID: route.TenantID, + UpstreamURL: route.UpstreamURL, + HealthCheckPath: route.HealthCheckPath, + RequiredScope: route.RequiredScope, + RateLimitRPM: route.RateLimitRPM, + RateLimitBurst: route.RateLimitBurst, + AllowCIDRs: route.AllowCIDRs, + DenyCIDRs: route.DenyCIDRs, + } + methods := route.Methods + if len(methods) == 0 { + methods = []string{"GET"} + } + newRoute, err := s.store.CreateRoute(request.Context(), payload, methods) + if err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + s.recordAdminAction(request.Context(), "duplicate_route", "route", newRoute.ID, fmt.Sprintf("from=%s", routeID)) + writeJSON(writer, http.StatusCreated, routeSummary{ + ID: newRoute.ID, + Slug: newRoute.Slug, + TargetPath: newRoute.TargetPath, + TenantID: newRoute.TenantID, + UpstreamURL: newRoute.UpstreamURL, + HealthCheckPath: newRoute.HealthCheckPath, + RequiredScope: newRoute.RequiredScope, + Methods: newRoute.Methods, + RateLimitRPM: newRoute.RateLimitRPM, + RateLimitBurst: newRoute.RateLimitBurst, + AllowCIDRs: newRoute.AllowCIDRs, + DenyCIDRs: newRoute.DenyCIDRs, + }) +} + +// ── Org config export / import ───────────────────────────────────────── + +type exportedOrgConfig struct { + ExportedAt string `json:"exportedAt"` + Version string `json:"version"` + Tenants []tenantSummary `json:"tenants"` + Routes []routeSummary `json:"routes"` +} + +// handleExportOrgConfig: GET /api/v1/admin/export +func (s *Service) handleExportOrgConfig(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodGet { + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + tenants, err := s.store.ListTenants(request.Context()) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "failed to load tenants"}) + return + } + routes, err := s.store.ListRoutes(request.Context()) + if err != nil { + writeJSON(writer, http.StatusInternalServerError, map[string]string{"error": "failed to load routes"}) + return + } + + tSummaries := make([]tenantSummary, 0, len(tenants)) + for _, t := range tenants { + tSummaries = append(tSummaries, tenantSummary{ + ID: t.ID, + Name: t.Name, + TenantID: t.TenantID, + AuthMode: t.AuthMode, + HeaderName: t.HeaderName, + OrgID: t.OrgID, + }) + } + rSummaries := make([]routeSummary, 0, len(routes)) + for _, r := range routes { + rSummaries = append(rSummaries, routeSummary{ + ID: r.ID, + Slug: r.Slug, + TargetPath: r.TargetPath, + TenantID: r.TenantID, + UpstreamURL: r.UpstreamURL, + HealthCheckPath: r.HealthCheckPath, + RequiredScope: r.RequiredScope, + Methods: r.Methods, + RateLimitRPM: r.RateLimitRPM, + RateLimitBurst: r.RateLimitBurst, + AllowCIDRs: r.AllowCIDRs, + DenyCIDRs: r.DenyCIDRs, + }) + } + + cfg := exportedOrgConfig{ + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Version: "1", + Tenants: tSummaries, + Routes: rSummaries, + } + b, _ := json.MarshalIndent(cfg, "", " ") + writer.Header().Set("Content-Type", "application/json") + writer.Header().Set("Content-Disposition", `attachment; filename="justgate-config.json"`) + writer.WriteHeader(http.StatusOK) + _, _ = writer.Write(b) +} + +type importOrgConfig struct { + Tenants []createTenantRequest `json:"tenants"` + Routes []json.RawMessage `json:"routes"` +} + +type importResult struct { + TenantsCreated int `json:"tenantsCreated"` + RoutesCreated int `json:"routesCreated"` + Errors []string `json:"errors"` +} + +// handleImportOrgConfig: POST /api/v1/admin/import +func (s *Service) handleImportOrgConfig(writer http.ResponseWriter, request *http.Request) { + if request.Method != http.MethodPost { + writeJSON(writer, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + var body struct { + Tenants []createTenantRequest `json:"tenants"` + Routes []createRouteRequest `json:"routes"` + } + if err := decodeJSON(request, &body); err != nil { + writeJSON(writer, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + result := importResult{} + for i := range body.Tenants { + payload := body.Tenants[i] + if err := normalizeTenantPayload(&payload, s.config.TenantHeaderName); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("tenant[%d]: %s", i, err.Error())) + continue + } + if _, err := s.store.CreateTenant(request.Context(), payload); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("tenant %q: %s", payload.TenantID, err.Error())) + continue + } + result.TenantsCreated++ + } + for i, rp := range body.Routes { + rp.Slug = strings.TrimSpace(rp.Slug) + rp.TargetPath = strings.TrimSpace(rp.TargetPath) + rp.TenantID = strings.TrimSpace(rp.TenantID) + rp.UpstreamURL = strings.TrimSpace(rp.UpstreamURL) + if rp.Slug == "" || rp.TargetPath == "" || rp.TenantID == "" || rp.UpstreamURL == "" { + result.Errors = append(result.Errors, fmt.Sprintf("route[%d]: missing required fields", i)) + continue + } + methods, err := normalizeStringList(rp.Methods) + if err != nil || len(methods) == 0 { + methods = []string{"GET"} + } + methods = normalizeMethods(methods) + if !strings.HasPrefix(rp.TargetPath, "/") { + rp.TargetPath = "/" + rp.TargetPath + } + if _, err := s.store.CreateRoute(request.Context(), rp, methods); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("route %q: %s", rp.Slug, err.Error())) + continue + } + result.RoutesCreated++ + } + s.recordAdminAction(request.Context(), "import_config", "org", orgIDFromContext(request.Context()), + fmt.Sprintf("tenants=%d routes=%d errors=%d", result.TenantsCreated, result.RoutesCreated, len(result.Errors))) + writeJSON(writer, http.StatusOK, result) +} diff --git a/services/backend/internal/service/gateway_handlers.go b/services/backend/internal/service/gateway_handlers.go index ce454b3c..63498491 100644 --- a/services/backend/internal/service/gateway_handlers.go +++ b/services/backend/internal/service/gateway_handlers.go @@ -917,6 +917,7 @@ func (s *Service) handleSearch(writer http.ResponseWriter, request *http.Request TenantID: t.TenantID, AuthMode: t.AuthMode, HeaderName: t.HeaderName, + OrgID: t.OrgID, }) } } diff --git a/services/backend/internal/service/orgs.go b/services/backend/internal/service/orgs.go index 8135e030..6b29b360 100644 --- a/services/backend/internal/service/orgs.go +++ b/services/backend/internal/service/orgs.go @@ -96,9 +96,19 @@ func (s *Service) handleOrgByID(writer http.ResponseWriter, request *http.Reques switch subPath { case "members": - s.handleOrgMembers(writer, request, orgID, subID, membership.Role) + if request.Method == http.MethodPatch && subID != "" { + s.handleChangeMemberRole(writer, request, orgID, subID, membership.Role) + } else { + s.handleOrgMembers(writer, request, orgID, subID, membership.Role) + } case "invites": - s.handleOrgInvites(writer, request, orgID, adminID, membership.Role) + if request.Method == http.MethodGet { + s.handleListOrgInvites(writer, request, orgID, membership.Role) + } else if request.Method == http.MethodDelete && subID != "" { + s.handleDeleteOrgInvite(writer, request, orgID, subID, membership.Role) + } else { + s.handleOrgInvites(writer, request, orgID, adminID, membership.Role) + } default: writeJSON(writer, http.StatusNotFound, map[string]string{"error": "not found"}) } diff --git a/services/backend/internal/service/service.go b/services/backend/internal/service/service.go index f1579ea8..9487919c 100644 --- a/services/backend/internal/service/service.go +++ b/services/backend/internal/service/service.go @@ -93,6 +93,12 @@ type dataStore interface { CreateOrgInvite(ctx context.Context, orgID, createdBy string, expiresAt time.Time, maxUses int) (orgInviteRecord, error) GetOrgInviteByCode(ctx context.Context, code string) (orgInviteRecord, bool, error) ConsumeOrgInvite(ctx context.Context, code, userID string) (string, error) + ListOrgInvites(ctx context.Context, orgID string) ([]orgInviteRecord, error) + DeleteOrgInvite(ctx context.Context, orgID, inviteID string) error + GetOrgByID(ctx context.Context, orgID string) (orgRecord, bool, error) + UpdateOrgMemberRole(ctx context.Context, orgID, userID, role string) error + ExtendTokenExpiry(ctx context.Context, tokenID string, newExpiry time.Time) error + GetRouteByID(ctx context.Context, routeID string) (routeRecord, bool, error) // OIDC config GetOIDCConfig(ctx context.Context) (oidcConfigRecord, bool, error) UpsertOIDCConfig(ctx context.Context, cfg oidcConfigRecord) error @@ -257,6 +263,7 @@ type tenantSummary struct { TenantID string `json:"tenantID"` AuthMode string `json:"authMode"` HeaderName string `json:"headerName"` + OrgID string `json:"orgID"` } type routeSummary struct { @@ -1001,6 +1008,11 @@ func (s *Service) Handler() http.Handler { // Org IP allowlist mux.HandleFunc("/api/v1/admin/org-ip-rules", s.withAdminAuth(s.withOrgContext(s.handleOrgIPRules))) mux.HandleFunc("/api/v1/admin/org-ip-rules/", s.withAdminAuth(s.withOrgContext(s.handleOrgIPRuleByID))) + // Org config export / import + mux.HandleFunc("/api/v1/admin/export", s.withAdminAuth(s.withOrgContext(s.handleExportOrgConfig))) + mux.HandleFunc("/api/v1/admin/import", s.withAdminAuth(s.withOrgContext(s.handleImportOrgConfig))) + // Public invite preview (no auth) + mux.HandleFunc("/api/v1/invite-preview", s.handleInvitePreview) return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { startedAt := time.Now() @@ -1320,6 +1332,7 @@ func (s *Service) handleTenants(writer http.ResponseWriter, request *http.Reques TenantID: tenant.TenantID, AuthMode: tenant.AuthMode, HeaderName: tenant.HeaderName, + OrgID: tenant.OrgID, }) } @@ -1532,6 +1545,7 @@ func (s *Service) buildTopologyResponse(ctx context.Context) (topologyResponse, TenantID: tenant.TenantID, AuthMode: tenant.AuthMode, HeaderName: tenant.HeaderName, + OrgID: tenant.OrgID, }) } @@ -1812,6 +1826,7 @@ func (s *Service) handleCreateTenant(writer http.ResponseWriter, request *http.R TenantID: tenant.TenantID, AuthMode: tenant.AuthMode, HeaderName: tenant.HeaderName, + OrgID: tenant.OrgID, }) } @@ -1850,6 +1865,7 @@ func (s *Service) handleTenantByID(writer http.ResponseWriter, request *http.Req TenantID: tenant.TenantID, AuthMode: tenant.AuthMode, HeaderName: tenant.HeaderName, + OrgID: tenant.OrgID, }) case http.MethodDelete: if err := s.store.DeleteTenant(request.Context(), tenantID); err != nil { @@ -1993,8 +2009,21 @@ func (s *Service) handleCreateToken(writer http.ResponseWriter, request *http.Re } func (s *Service) handleRouteByID(writer http.ResponseWriter, request *http.Request) { - routeID := strings.Trim(strings.TrimPrefix(request.URL.Path, "/api/v1/admin/routes/"), "/") - if routeID == "" || strings.Contains(routeID, "/") { + path := strings.Trim(strings.TrimPrefix(request.URL.Path, "/api/v1/admin/routes/"), "/") + // Sub-resource routing: /api/v1/admin/routes/{id}/duplicate + if idx := strings.Index(path, "/"); idx != -1 { + routeID := path[:idx] + sub := path[idx+1:] + switch sub { + case "duplicate": + s.handleDuplicateRoute(writer, request, routeID) + default: + writeJSON(writer, http.StatusNotFound, map[string]string{"error": "not found"}) + } + return + } + routeID := path + if routeID == "" { writeJSON(writer, http.StatusNotFound, map[string]string{"error": "route not found"}) return } @@ -2090,6 +2119,8 @@ func (s *Service) handleTokenByID(writer http.ResponseWriter, request *http.Requ s.handleTokenStats(writer, request) case "rotate": s.handleRotateToken(writer, request) + case "extend": + s.handleExtendToken(writer, request, tokenID) default: writeJSON(writer, http.StatusNotFound, map[string]string{"error": "not found"}) } diff --git a/services/backend/internal/service/sql_store.go b/services/backend/internal/service/sql_store.go index 1742148d..0ffed111 100644 --- a/services/backend/internal/service/sql_store.go +++ b/services/backend/internal/service/sql_store.go @@ -2289,6 +2289,94 @@ func (store *sqlStore) PurgeTrafficStats(ctx context.Context, olderThan time.Tim return result.RowsAffected() } +// ── New methods added for v3 feature set ────────────────────────────── + +func (store *sqlStore) ListOrgInvites(ctx context.Context, orgID string) ([]orgInviteRecord, error) { + rows, err := store.queryContext(ctx, + `SELECT id, org_id, code, created_by, expires_at, max_uses, use_count, created_at + FROM org_invites WHERE org_id = ? ORDER BY created_at DESC`, orgID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []orgInviteRecord + for rows.Next() { + var inv orgInviteRecord + if err := rows.Scan(&inv.ID, &inv.OrgID, &inv.Code, &inv.CreatedBy, &inv.ExpiresAt, &inv.MaxUses, &inv.UseCount, &inv.CreatedAt); err != nil { + return nil, err + } + items = append(items, inv) + } + return items, rows.Err() +} + +func (store *sqlStore) DeleteOrgInvite(ctx context.Context, orgID, inviteID string) error { + result, err := store.execContext(ctx, + `DELETE FROM org_invites WHERE id = ? AND org_id = ?`, inviteID, orgID) + if err != nil { + return err + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("invite not found") + } + return nil +} + +func (store *sqlStore) GetOrgByID(ctx context.Context, orgID string) (orgRecord, bool, error) { + var o orgRecord + err := store.queryRowContext(ctx, + `SELECT id, name, created_by, created_at FROM organizations WHERE id = ? LIMIT 1`, orgID). + Scan(&o.ID, &o.Name, &o.CreatedBy, &o.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return orgRecord{}, false, nil + } + if err != nil { + return orgRecord{}, false, err + } + return o, true, nil +} + +func (store *sqlStore) UpdateOrgMemberRole(ctx context.Context, orgID, userID, role string) error { + result, err := store.execContext(ctx, + `UPDATE org_memberships SET role = ? WHERE org_id = ? AND user_id = ?`, role, orgID, userID) + if err != nil { + return err + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("member not found") + } + return nil +} + +func (store *sqlStore) ExtendTokenExpiry(ctx context.Context, tokenID string, newExpiry time.Time) error { + result, err := store.execContext(ctx, + `UPDATE tokens SET expires_at = ? WHERE id = ?`, newExpiry, tokenID) + if err != nil { + return err + } + n, _ := result.RowsAffected() + if n == 0 { + return fmt.Errorf("token not found") + } + return nil +} + +func (store *sqlStore) GetRouteByID(ctx context.Context, routeID string) (routeRecord, bool, error) { + row := store.queryRowContext(ctx, + `SELECT id, slug, target_path, tenant_id, required_scope, methods_json, upstream_url, rate_limit_rpm, rate_limit_burst, allow_cidrs, deny_cidrs, health_check_path + FROM routes WHERE id = ? LIMIT 1`, routeID) + route, err := scanRoute(row) + if errors.Is(err, sql.ErrNoRows) { + return routeRecord{}, false, nil + } + if err != nil { + return routeRecord{}, false, err + } + return route, true, nil +} + func scanGrant(scanner interface{ Scan(dest ...any) error }) (provisioningGrantRecord, error) { var g provisioningGrantRecord var scopesJSON string diff --git a/services/frontend/app/api/admin/export/route.ts b/services/frontend/app/api/admin/export/route.ts new file mode 100644 index 00000000..34436d70 --- /dev/null +++ b/services/frontend/app/api/admin/export/route.ts @@ -0,0 +1,24 @@ +import { getAdminRequestHeaders, getBackendBaseUrl, hasAdminRequestAuthorization } from "@/lib/backend-server"; +import { NextResponse } from "next/server"; + +export async function GET(_request: Request) { + const headers = await getAdminRequestHeaders(); + if (!hasAdminRequestAuthorization(headers)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const response = await fetch(`${getBackendBaseUrl()}/api/v1/admin/export`, { + method: "GET", + headers, + cache: "no-store", + }); + + const contentDisposition = response.headers.get("content-disposition") || 'attachment; filename="justgate-config.json"'; + return new NextResponse(await response.text(), { + status: response.status, + headers: { + "content-type": "application/json", + "content-disposition": contentDisposition, + }, + }); +} diff --git a/services/frontend/app/api/admin/import/route.ts b/services/frontend/app/api/admin/import/route.ts new file mode 100644 index 00000000..907ea8b8 --- /dev/null +++ b/services/frontend/app/api/admin/import/route.ts @@ -0,0 +1,22 @@ +import { getAdminRequestHeaders, getBackendBaseUrl, hasAdminRequestAuthorization } from "@/lib/backend-server"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + const headers = await getAdminRequestHeaders(); + if (!hasAdminRequestAuthorization(headers)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const body = await request.text(); + const response = await fetch(`${getBackendBaseUrl()}/api/v1/admin/import`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body, + cache: "no-store", + }); + + return new NextResponse(await response.text(), { + status: response.status, + headers: { "content-type": response.headers.get("content-type") || "application/json" }, + }); +} diff --git a/services/frontend/app/api/admin/orgs/[orgID]/invites/[inviteID]/route.ts b/services/frontend/app/api/admin/orgs/[orgID]/invites/[inviteID]/route.ts new file mode 100644 index 00000000..b9b24101 --- /dev/null +++ b/services/frontend/app/api/admin/orgs/[orgID]/invites/[inviteID]/route.ts @@ -0,0 +1,26 @@ +import { getAdminRequestHeaders, getBackendBaseUrl, hasAdminRequestAuthorization } from "@/lib/backend-server"; +import { NextResponse } from "next/server"; + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ orgID: string; inviteID: string }> }, +) { + const headers = await getAdminRequestHeaders(); + if (!hasAdminRequestAuthorization(headers)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const { orgID, inviteID } = await params; + const response = await fetch( + `${getBackendBaseUrl()}/api/v1/admin/orgs/${orgID}/invites/${inviteID}`, + { method: "DELETE", headers, cache: "no-store" }, + ); + + if (response.status === 204) { + return new NextResponse(null, { status: 204 }); + } + return new NextResponse(await response.text(), { + status: response.status, + headers: { "content-type": response.headers.get("content-type") || "application/json" }, + }); +} diff --git a/services/frontend/app/api/admin/orgs/[orgID]/invites/route.ts b/services/frontend/app/api/admin/orgs/[orgID]/invites/route.ts index 5d14e108..a51bac2e 100644 --- a/services/frontend/app/api/admin/orgs/[orgID]/invites/route.ts +++ b/services/frontend/app/api/admin/orgs/[orgID]/invites/route.ts @@ -1,6 +1,28 @@ import { getAdminRequestHeaders, getBackendBaseUrl, hasAdminRequestAuthorization } from "@/lib/backend-server"; import { NextResponse } from "next/server"; +export async function GET( + _request: Request, + { params }: { params: Promise<{ orgID: string }> }, +) { + const headers = await getAdminRequestHeaders(); + if (!hasAdminRequestAuthorization(headers)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const { orgID } = await params; + const response = await fetch(`${getBackendBaseUrl()}/api/v1/admin/orgs/${orgID}/invites`, { + method: "GET", + headers, + cache: "no-store", + }); + + return new NextResponse(await response.text(), { + status: response.status, + headers: { "content-type": response.headers.get("content-type") || "application/json" }, + }); +} + export async function POST( _request: Request, { params }: { params: Promise<{ orgID: string }> }, diff --git a/services/frontend/app/api/admin/orgs/[orgID]/members/[userID]/route.ts b/services/frontend/app/api/admin/orgs/[orgID]/members/[userID]/route.ts index 8e5f5b79..b61a1a1f 100644 --- a/services/frontend/app/api/admin/orgs/[orgID]/members/[userID]/route.ts +++ b/services/frontend/app/api/admin/orgs/[orgID]/members/[userID]/route.ts @@ -1,6 +1,33 @@ import { getAdminRequestHeaders, getBackendBaseUrl, hasAdminRequestAuthorization } from "@/lib/backend-server"; import { NextResponse } from "next/server"; +export async function PATCH( + request: Request, + { params }: { params: Promise<{ orgID: string; userID: string }> }, +) { + const headers = await getAdminRequestHeaders(); + if (!hasAdminRequestAuthorization(headers)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const { orgID, userID } = await params; + const body = await request.text(); + const response = await fetch( + `${getBackendBaseUrl()}/api/v1/admin/orgs/${orgID}/members/${userID}`, + { + method: "PATCH", + headers: { "content-type": "application/json", ...headers }, + body, + cache: "no-store", + }, + ); + + return new NextResponse(await response.text(), { + status: response.status, + headers: { "content-type": response.headers.get("content-type") || "application/json" }, + }); +} + export async function DELETE( _request: Request, { params }: { params: Promise<{ orgID: string; userID: string }> }, diff --git a/services/frontend/app/api/admin/routes/[routeID]/duplicate/route.ts b/services/frontend/app/api/admin/routes/[routeID]/duplicate/route.ts new file mode 100644 index 00000000..187249f3 --- /dev/null +++ b/services/frontend/app/api/admin/routes/[routeID]/duplicate/route.ts @@ -0,0 +1,23 @@ +import { getAdminRequestHeaders, getBackendBaseUrl, hasAdminRequestAuthorization } from "@/lib/backend-server"; +import { NextResponse } from "next/server"; + +export async function POST( + _request: Request, + { params }: { params: Promise<{ routeID: string }> }, +) { + const headers = await getAdminRequestHeaders(); + if (!hasAdminRequestAuthorization(headers)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const { routeID } = await params; + const response = await fetch( + `${getBackendBaseUrl()}/api/v1/admin/routes/${routeID}/duplicate`, + { method: "POST", headers, cache: "no-store" }, + ); + + return new NextResponse(await response.text(), { + status: response.status, + headers: { "content-type": response.headers.get("content-type") || "application/json" }, + }); +} diff --git a/services/frontend/app/api/admin/tokens/[tokenID]/extend/route.ts b/services/frontend/app/api/admin/tokens/[tokenID]/extend/route.ts new file mode 100644 index 00000000..817c2c1d --- /dev/null +++ b/services/frontend/app/api/admin/tokens/[tokenID]/extend/route.ts @@ -0,0 +1,29 @@ +import { getAdminRequestHeaders, getBackendBaseUrl, hasAdminRequestAuthorization } from "@/lib/backend-server"; +import { NextResponse } from "next/server"; + +export async function POST( + request: Request, + { params }: { params: Promise<{ tokenID: string }> }, +) { + const headers = await getAdminRequestHeaders(); + if (!hasAdminRequestAuthorization(headers)) { + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); + } + + const { tokenID } = await params; + const body = await request.text(); + const response = await fetch( + `${getBackendBaseUrl()}/api/v1/admin/tokens/${tokenID}/extend`, + { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body, + cache: "no-store", + }, + ); + + return new NextResponse(await response.text(), { + status: response.status, + headers: { "content-type": response.headers.get("content-type") || "application/json" }, + }); +} diff --git a/services/frontend/app/api/invite-preview/route.ts b/services/frontend/app/api/invite-preview/route.ts new file mode 100644 index 00000000..1d45cb31 --- /dev/null +++ b/services/frontend/app/api/invite-preview/route.ts @@ -0,0 +1,23 @@ +import { getBackendBaseUrl } from "@/lib/backend-server"; +import { NextResponse } from "next/server"; + +// Public endpoint — no admin auth required, used by the join page +// to preview the org name before accepting an invite. +export async function GET(request: Request) { + const url = new URL(request.url); + const code = url.searchParams.get("code") ?? ""; + + if (!code) { + return NextResponse.json({ error: "code is required" }, { status: 400 }); + } + + const response = await fetch( + `${getBackendBaseUrl()}/api/v1/invite-preview?code=${encodeURIComponent(code)}`, + { cache: "no-store" }, + ); + + return new NextResponse(await response.text(), { + status: response.status, + headers: { "content-type": response.headers.get("content-type") || "application/json" }, + }); +} diff --git a/services/frontend/app/apps/error.tsx b/services/frontend/app/apps/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/apps/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/apps/loading.tsx b/services/frontend/app/apps/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/apps/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/apps/page.tsx b/services/frontend/app/apps/page.tsx index 4e252cfb..58e3b228 100644 --- a/services/frontend/app/apps/page.tsx +++ b/services/frontend/app/apps/page.tsx @@ -2,10 +2,20 @@ import { CreateAppModal } from "@/components/admin/create-app-modal"; import { ProtectedAppsTable } from "@/components/admin/protected-apps-table"; import { SectionPage } from "@/components/admin/section-page"; import { UpstreamConfigGuide } from "@/components/admin/upstream-config-guide"; -import { getProtectedApps } from "@/lib/backend-client"; +import { getAppSessions, getAppTokens, getProtectedApps } from "@/lib/backend-client"; export default async function ProtectedAppsPage() { const result = await getProtectedApps(); + const apps = result.data; + + // Fetch session + token counts for all apps in parallel + const [sessionResults, tokenResults] = await Promise.all([ + Promise.all(apps.map((a) => getAppSessions(a.id).then((r) => ({ id: a.id, count: r.data.length })))), + Promise.all(apps.map((a) => getAppTokens(a.id).then((r) => ({ id: a.id, count: r.data.filter((t) => t.active).length })))), + ]); + + const sessionCountByApp = Object.fromEntries(sessionResults.map((r) => [r.id, r.count])); + const tokenCountByApp = Object.fromEntries(tokenResults.map((r) => [r.id, r.count])); return (
- {result.data.length} app{result.data.length !== 1 ? "s" : ""} configured + {apps.length} app{apps.length !== 1 ? "s" : ""} configured
- +
- +
diff --git a/services/frontend/app/audit/error.tsx b/services/frontend/app/audit/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/audit/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/audit/loading.tsx b/services/frontend/app/audit/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/audit/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/audit/page.tsx b/services/frontend/app/audit/page.tsx index d6fb16a5..bdceafc5 100644 --- a/services/frontend/app/audit/page.tsx +++ b/services/frontend/app/audit/page.tsx @@ -1,7 +1,8 @@ +import { AdminAuditView } from "@/components/admin/admin-audit-view"; import { AuditView } from "@/components/admin/audit-view"; import { LiveAuditStream } from "@/components/admin/live-audit-stream"; import { SectionPage } from "@/components/admin/section-page"; -import { getAuditEventsPaginated, getAuditEventsPaginatedFiltered } from "@/lib/backend-client"; +import { getAdminAuditEvents, getAuditEventsPaginated, getAuditEventsPaginatedFiltered } from "@/lib/backend-client"; const PAGE_SIZE = 20; @@ -15,17 +16,24 @@ export default async function AuditPage({ const statusFilter = String(sp.status ?? "all"); const tenantFilter = String(sp.tenant ?? ""); const routeFilter = String(sp.route ?? ""); + const fromFilter = String(sp.from ?? ""); + const toFilter = String(sp.to ?? ""); + const activeTab = String(sp.tab ?? "proxy"); - const hasFilters = statusFilter !== "all" || tenantFilter !== "" || routeFilter !== ""; + const hasFilters = statusFilter !== "all" || tenantFilter !== "" || routeFilter !== "" || fromFilter !== "" || toFilter !== ""; - const result = hasFilters - ? await getAuditEventsPaginatedFiltered(page, PAGE_SIZE, { - status: statusFilter, - tenantID: tenantFilter, - routeSlug: routeFilter, - }) - : await getAuditEventsPaginated(page, PAGE_SIZE); + const [proxyResult, adminResult] = await Promise.all([ + hasFilters + ? getAuditEventsPaginatedFiltered(page, PAGE_SIZE, { + status: statusFilter, + tenantID: tenantFilter, + routeSlug: routeFilter, + }) + : getAuditEventsPaginated(page, PAGE_SIZE), + getAdminAuditEvents(page, PAGE_SIZE), + ]); + const result = proxyResult; const { items, total, pageSize } = result.data; const totalPages = Math.max(1, Math.ceil(total / pageSize)); @@ -37,19 +45,54 @@ export default async function AuditPage({ source={result.source} error={result.error} > -
- +
+ {/* Tab bar */} +
+ {[{ id: "proxy", label: "Proxy Traffic" }, { id: "admin", label: "Admin Activity" }].map((tab) => ( + + {tab.label} + + ))} +
+ + {activeTab === "proxy" && ( + <> +
+ +
+ + + )} + + {activeTab === "admin" && ( + + )}
- ); } diff --git a/services/frontend/app/dashboard/error.tsx b/services/frontend/app/dashboard/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/dashboard/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/dashboard/loading.tsx b/services/frontend/app/dashboard/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/dashboard/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/grants/error.tsx b/services/frontend/app/grants/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/grants/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/grants/loading.tsx b/services/frontend/app/grants/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/grants/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/join/page.tsx b/services/frontend/app/join/page.tsx index 1656af42..c86e23d1 100644 --- a/services/frontend/app/join/page.tsx +++ b/services/frontend/app/join/page.tsx @@ -4,6 +4,14 @@ import { useSession } from "next-auth/react"; import { useRouter, useSearchParams } from "next/navigation"; import { Suspense, useEffect, useState } from "react"; +interface Preview { + orgID: string; + orgName: string; + expiresAt: string; + maxUses: number; + useCount: number; +} + function JoinPageInner() { const router = useRouter(); const params = useSearchParams(); @@ -11,18 +19,33 @@ function JoinPageInner() { const code = params.get("code") ?? ""; - const [status, setStatus] = useState<"idle" | "joining" | "done" | "error">("idle"); - const [message, setMessage] = useState(); + const [preview, setPreview] = useState(null); + const [status, setStatus] = useState<"loading" | "confirm" | "joining" | "done" | "error">(() => code ? "loading" : "error"); + const [message, setMessage] = useState(() => code ? undefined : "No invite code provided."); + const [now] = useState(Date.now); useEffect(() => { - if (!code) { - setStatus("error"); - setMessage("No invite code provided."); - return; - } + if (!code) return; - if (status !== "idle") return; + fetch(`/api/invite-preview?code=${encodeURIComponent(code)}`) + .then(async (r) => { + const data = await r.json().catch(() => null); + if (!r.ok) { + setStatus("error"); + setMessage(data?.error || "Invalid or expired invite."); + return; + } + setPreview(data as Preview); + setStatus("confirm"); + }) + .catch(() => { + setStatus("error"); + setMessage("Could not load invite details. Please try again."); + }); + }, [code]); + function handleAccept() { + if (status !== "confirm") return; setStatus("joining"); fetch("/api/admin/orgs/join", { @@ -45,19 +68,66 @@ function JoinPageInner() { setStatus("error"); setMessage("Unexpected error. Please try again."); }); - // Only run once when code is available - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [code]); + } + + function expiresLabel(iso: string) { + const diff = new Date(iso).getTime() - now; + if (diff < 0) return "Expired"; + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + if (days === 0) return "Expires today"; + return `Expires in ${days} day${days !== 1 ? "s" : ""}`; + } return (
+ {status === "loading" && ( + <> +
+

Loading invite…

+ + )} + + {status === "confirm" && preview && ( + <> +
+
+

You're invited to join

+

{preview.orgName}

+
+
+ + {preview.useCount} / {preview.maxUses === 0 ? "∞" : preview.maxUses} uses + + + {expiresLabel(preview.expiresAt)} + +
+
+ + + + )} + {status === "joining" && ( <>

Accepting invite…

)} + {status === "done" && ( <>
@@ -65,11 +135,12 @@ function JoinPageInner() {

Redirecting to dashboard…

)} + {status === "error" && ( <>

Invite failed

-

{message}

+

{message || "Invalid or expired invite."}

+
+ ); +} diff --git a/services/frontend/app/platform/admins/loading.tsx b/services/frontend/app/platform/admins/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/platform/admins/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/platform/orgs/error.tsx b/services/frontend/app/platform/orgs/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/platform/orgs/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/platform/orgs/loading.tsx b/services/frontend/app/platform/orgs/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/platform/orgs/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/platform/orgs/page.tsx b/services/frontend/app/platform/orgs/page.tsx index 5d8db71c..63f51faa 100644 --- a/services/frontend/app/platform/orgs/page.tsx +++ b/services/frontend/app/platform/orgs/page.tsx @@ -1,7 +1,7 @@ import { PlatformOrgsTable } from "@/components/admin/platform-orgs-table"; import { SectionPage } from "@/components/admin/section-page"; import { auth } from "@/lib/auth"; -import { getAdminOrgs } from "@/lib/backend-client"; +import { getAdminOrgs, getRoutes, getTenants } from "@/lib/backend-client"; import { redirect } from "next/navigation"; export default async function PlatformOrgsPage() { @@ -10,7 +10,34 @@ export default async function PlatformOrgsPage() { redirect("/"); } - const result = await getAdminOrgs(); + const [result, tenantsResult, routesResult] = await Promise.all([ + getAdminOrgs(), + getTenants(), + getRoutes(), + ]); + + // Compute per-org tenant and route counts using orgID from tenants + const tenantCountByOrg: Record = {}; + const routeCountByOrg: Record = {}; + + for (const tenant of tenantsResult.data) { + if (tenant.orgID) { + tenantCountByOrg[tenant.orgID] = (tenantCountByOrg[tenant.orgID] ?? 0) + 1; + } + } + + // Build a set of tenantIDs per orgID for route lookups + const tenantIDToOrgID: Record = {}; + for (const tenant of tenantsResult.data) { + if (tenant.orgID) tenantIDToOrgID[tenant.tenantID] = tenant.orgID; + } + + for (const route of routesResult.data) { + const orgID = tenantIDToOrgID[route.tenantID]; + if (orgID) { + routeCountByOrg[orgID] = (routeCountByOrg[orgID] ?? 0) + 1; + } + } return ( {result.data.length} organisation{result.data.length !== 1 ? "s" : ""} total
- +
); diff --git a/services/frontend/app/platform/users/error.tsx b/services/frontend/app/platform/users/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/platform/users/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/platform/users/loading.tsx b/services/frontend/app/platform/users/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/platform/users/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/routes/error.tsx b/services/frontend/app/routes/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/routes/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/routes/loading.tsx b/services/frontend/app/routes/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/routes/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/routes/page.tsx b/services/frontend/app/routes/page.tsx index 0aedf8db..de77ebdf 100644 --- a/services/frontend/app/routes/page.tsx +++ b/services/frontend/app/routes/page.tsx @@ -29,7 +29,7 @@ export default async function RoutesPage() {
- +
{result.source !== "backend" && ( diff --git a/services/frontend/app/security/error.tsx b/services/frontend/app/security/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/security/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/security/loading.tsx b/services/frontend/app/security/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/security/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/sessions/error.tsx b/services/frontend/app/sessions/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/sessions/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/sessions/loading.tsx b/services/frontend/app/sessions/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/sessions/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/settings/error.tsx b/services/frontend/app/settings/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/settings/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/settings/loading.tsx b/services/frontend/app/settings/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/settings/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/settings/page.tsx b/services/frontend/app/settings/page.tsx index 4eb324db..3bedf16a 100644 --- a/services/frontend/app/settings/page.tsx +++ b/services/frontend/app/settings/page.tsx @@ -2,7 +2,9 @@ import { DataRetentionPanel } from "@/components/admin/data-retention-panel"; import { OIDCOrgMappings } from "@/components/admin/oidc-org-mappings"; import { OIDCProviderDocs } from "@/components/admin/oidc-provider-docs"; import { OIDCSettingsForm } from "@/components/admin/oidc-settings-form"; +import { OrgConfigExportImport } from "@/components/admin/org-config-export-import"; import { SectionPage } from "@/components/admin/section-page"; +import { SettingsTabs } from "@/components/admin/settings-tabs"; import { auth } from "@/lib/auth"; import { getOIDCConfig, getOIDCOrgMappings, getRetentionSettings } from "@/lib/backend-client"; import { redirect } from "next/navigation"; @@ -27,12 +29,13 @@ export default async function SettingsPage() { source={oidcResult.source} error={oidcResult.error} > -
- - - - -
+ } + providerDocs={} + orgMappings={} + dataRetention={} + exportImport={} + /> ); } diff --git a/services/frontend/app/team/error.tsx b/services/frontend/app/team/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/team/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/team/loading.tsx b/services/frontend/app/team/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/team/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/team/page.tsx b/services/frontend/app/team/page.tsx index c5afbc65..47e0b372 100644 --- a/services/frontend/app/team/page.tsx +++ b/services/frontend/app/team/page.tsx @@ -1,24 +1,25 @@ import { AddMemberModal } from "@/components/admin/add-member-modal"; import { InviteModal } from "@/components/admin/invite-modal"; +import { PendingInvitesList } from "@/components/admin/pending-invites-list"; import { SectionPage } from "@/components/admin/section-page"; import { TeamMembersTable } from "@/components/admin/team-members-table"; import { auth } from "@/lib/auth"; -import { getOrgMembers, getOrgs } from "@/lib/backend-client"; +import { getOrgInvites, getOrgMembers, getOrgs } from "@/lib/backend-client"; import { redirect } from "next/navigation"; export default async function TeamPage() { const session = await auth(); if (!session?.activeOrgId) { - // No org selected — send to home where onboarding modal will guide setup redirect("/"); } const orgID = session.activeOrgId; - const [orgsResult, membersResult] = await Promise.all([ + const [orgsResult, membersResult, invitesResult] = await Promise.all([ getOrgs(), getOrgMembers(orgID), + getOrgInvites(orgID), ]); const activeOrg = orgsResult.data.find((o) => o.id === orgID); @@ -51,6 +52,10 @@ export default async function TeamPage() { currentUserID={currentUserID} isOwner={isOwner ?? false} /> + + {isOwner && invitesResult.data.length > 0 && ( + + )}
); diff --git a/services/frontend/app/tenants/error.tsx b/services/frontend/app/tenants/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/tenants/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/tenants/loading.tsx b/services/frontend/app/tenants/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/tenants/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/tenants/page.tsx b/services/frontend/app/tenants/page.tsx index cea927f3..012cf266 100644 --- a/services/frontend/app/tenants/page.tsx +++ b/services/frontend/app/tenants/page.tsx @@ -1,12 +1,25 @@ import { CreateTenantForm } from "@/components/admin/create-tenant-form"; import { SectionPage } from "@/components/admin/section-page"; import { TenantCard } from "@/components/admin/tenant-card"; -import { getTenants } from "@/lib/backend-client"; +import { getRoutes, getTenants, getTokens } from "@/lib/backend-client"; import { ArrowRight, Building2 } from "lucide-react"; import Link from "next/link"; export default async function TenantsPage() { - const result = await getTenants(); + const [result, routesResult, tokensResult] = await Promise.all([ + getTenants(), + getRoutes(), + getTokens(), + ]); + + const routeCountByTenant: Record = {}; + const tokenCountByTenant: Record = {}; + for (const r of routesResult.data) { + routeCountByTenant[r.tenantID] = (routeCountByTenant[r.tenantID] ?? 0) + 1; + } + for (const t of tokensResult.data) { + tokenCountByTenant[t.tenantID] = (tokenCountByTenant[t.tenantID] ?? 0) + 1; + } return ( ))}
diff --git a/services/frontend/app/tokens/error.tsx b/services/frontend/app/tokens/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/tokens/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/tokens/loading.tsx b/services/frontend/app/tokens/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/tokens/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/app/topology/error.tsx b/services/frontend/app/topology/error.tsx new file mode 100644 index 00000000..2fc1b181 --- /dev/null +++ b/services/frontend/app/topology/error.tsx @@ -0,0 +1,20 @@ +"use client"; +import { Button } from "@heroui/react"; + +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+
Something went wrong
+

{error.message}

+ +
+ ); +} diff --git a/services/frontend/app/topology/loading.tsx b/services/frontend/app/topology/loading.tsx new file mode 100644 index 00000000..ceed12ef --- /dev/null +++ b/services/frontend/app/topology/loading.tsx @@ -0,0 +1,9 @@ +export default function Loading() { + return ( +
+
+
+
+
+ ); +} diff --git a/services/frontend/components/admin/admin-audit-view.tsx b/services/frontend/components/admin/admin-audit-view.tsx new file mode 100644 index 00000000..dd76dab2 --- /dev/null +++ b/services/frontend/components/admin/admin-audit-view.tsx @@ -0,0 +1,99 @@ +"use client"; + +import type { AdminAuditEvent } from "@/lib/contracts"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useRouter } from "next/navigation"; + +interface AdminAuditViewProps { + events: AdminAuditEvent[]; + page: number; + pageSize: number; + total: number; + totalPages: number; +} + +export function AdminAuditView({ events, page, pageSize, total, totalPages }: AdminAuditViewProps) { + const router = useRouter(); + + function goToPage(p: number) { + const params = new URLSearchParams(window.location.search); + params.set("page", String(p)); + router.push(`?${params.toString()}`); + } + + const pageStart = (page - 1) * pageSize + 1; + const pageEnd = Math.min(page * pageSize, total); + + return ( +
+ {/* Result count */} +
+
+ {total > 0 ? `${pageStart}–${pageEnd} of ${total} event${total !== 1 ? "s" : ""}` : "No admin events recorded yet"} +
+ {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ + {/* Table */} +
+ {events.length === 0 ? ( +
No admin activity recorded.
+ ) : ( + + + + + + + + + + + + {events.map((ev) => ( + + + + + + + + ))} + +
TimeUserActionResourceDetails
+ {new Date(ev.timestamp).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })} + {ev.userEmail || ev.userID} + + {ev.action} + + + {ev.resourceType} + {ev.resourceID ? {ev.resourceID.slice(0, 8)} : null} + {ev.details || "—"}
+ )} +
+
+ ); +} diff --git a/services/frontend/components/admin/audit-view.tsx b/services/frontend/components/admin/audit-view.tsx index d0e1608d..de95ecac 100644 --- a/services/frontend/components/admin/audit-view.tsx +++ b/services/frontend/components/admin/audit-view.tsx @@ -2,9 +2,23 @@ import { AuditTable } from "@/components/admin/audit-table"; import type { AuditEvent } from "@/lib/contracts"; +import { DateField, DateRangePicker, Input, RangeCalendar } from "@heroui/react"; +import { parseDateTime } from "@internationalized/date"; import { ChevronLeft, ChevronRight, RefreshCw, X } from "lucide-react"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type ComponentProps } from "react"; + +type DateRange = NonNullable["value"]>; + +function parseDateFilter(str: string) { + if (!str) return null; + try { + // datetime-local format: "2024-01-15T14:30" — parseDateTime needs seconds + return parseDateTime(str.length === 16 ? `${str}:00` : str); + } catch { + return null; + } +} const POLL_INTERVAL_MS = 15_000; @@ -19,6 +33,8 @@ interface AuditViewProps { initialStatusFilter?: string; initialTenantFilter?: string; initialRouteFilter?: string; + initialFrom?: string; + initialTo?: string; } export function AuditView({ @@ -30,6 +46,8 @@ export function AuditView({ initialStatusFilter = "all", initialTenantFilter = "", initialRouteFilter = "", + initialFrom = "", + initialTo = "", }: AuditViewProps) { const router = useRouter(); const [statusFilter, setStatusFilter] = useState( @@ -37,13 +55,15 @@ export function AuditView({ ); const [tenantFilter, setTenantFilter] = useState(initialTenantFilter); const [routeFilter, setRouteFilter] = useState(initialRouteFilter); + const [fromFilter, setFromFilter] = useState(initialFrom); + const [toFilter, setToFilter] = useState(initialTo); const [lastRefreshed, setLastRefreshed] = useState(new Date()); const [isRefreshing, setIsRefreshing] = useState(false); const intervalRef = useRef | null>(null); function buildParams(overrides: Record = {}) { const params = new URLSearchParams(window.location.search); - const merged = { status: statusFilter, tenant: tenantFilter, route: routeFilter, ...overrides }; + const merged = { status: statusFilter, tenant: tenantFilter, route: routeFilter, from: fromFilter, to: toFilter, ...overrides }; params.set("page", "1"); if (merged.status && merged.status !== "all") { params.set("status", merged.status); @@ -60,6 +80,16 @@ export function AuditView({ } else { params.delete("route"); } + if (merged.from) { + params.set("from", merged.from); + } else { + params.delete("from"); + } + if (merged.to) { + params.set("to", merged.to); + } else { + params.delete("to"); + } return params.toString(); } @@ -87,16 +117,20 @@ export function AuditView({ }; }, [refresh]); - const hasFilters = statusFilter !== "all" || tenantFilter !== "" || routeFilter !== ""; + const hasFilters = statusFilter !== "all" || tenantFilter !== "" || routeFilter !== "" || fromFilter !== "" || toFilter !== ""; function clearFilters() { setStatusFilter("all"); setTenantFilter(""); setRouteFilter(""); + setFromFilter(""); + setToFilter(""); const params = new URLSearchParams(window.location.search); params.delete("status"); params.delete("tenant"); params.delete("route"); + params.delete("from"); + params.delete("to"); params.set("page", "1"); router.push(`?${params.toString()}`); } @@ -138,8 +172,7 @@ export function AuditView({
{/* Tenant filter (text input – server-side LIKE search) */} - setTenantFilter(e.target.value)} @@ -151,8 +184,7 @@ export function AuditView({ /> {/* Route slug search */} - setRouteFilter(e.target.value)} @@ -163,6 +195,64 @@ export function AuditView({ className="h-8 rounded-lg border border-border bg-panel px-2.5 text-[12px] text-foreground placeholder:text-muted-foreground outline-none focus:border-accent" /> + {/* Date range */} + { + const start = parseDateFilter(fromFilter); + const end = parseDateFilter(toFilter); + return start && end ? ({ start, end } as unknown as DateRange) : null; + })()} + onChange={(range) => { + const fromStr = range ? range.start.toString().slice(0, 16) : ""; + const toStr = range ? range.end.toString().slice(0, 16) : ""; + setFromFilter(fromStr); + setToFilter(toStr); + router.push(`?${buildParams({ from: fromStr, to: toStr })}`); + }} + > + + + {(segment) => } + + + + {(segment) => } + + + + + + + + + + + + + + + + + + + + {(day) => {day}} + + + {(date) => } + + + + + {({ year }) => } + + + + + + {/* Clear filters */} {hasFilters && (