package externaladmin import ( "hyapp-admin-server/internal/integration/userclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/appconfig" "hyapp-admin-server/internal/modules/appuser" "hyapp-admin-server/internal/modules/hostorg" "hyapp-admin-server/internal/modules/prettyid" "hyapp-admin-server/internal/modules/resource" "hyapp-admin-server/internal/modules/roomadmin" "hyapp-admin-server/internal/modules/upload" "hyapp-admin-server/internal/modules/vipconfig" "github.com/gin-gonic/gin" ) type BusinessHandlers struct { AppUser *appuser.Handler HostOrg *hostorg.Handler RoomAdmin *roomadmin.Handler Resource *resource.Handler PrettyID *prettyid.Handler AppConfig *appconfig.Handler VIPConfig *vipconfig.Handler Upload *upload.Handler } func RegisterAdminRoutes(protected *gin.RouterGroup, h *Handler) { if protected == nil || h == nil { return } protected.GET("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:view"), h.ListAccounts) protected.GET("/admin/external-admin-users/permission-catalog", middleware.RequirePermission("external-admin-user:permissions"), h.ListPermissionCatalog) protected.GET("/admin/external-admin-users/parent-candidates", middleware.RequirePermission("external-admin-user:permissions"), h.ListParentCandidates) protected.GET("/admin/external-admin-users/:id/permissions", middleware.RequirePermission("external-admin-user:permissions"), h.GetAccountPermissions) protected.GET("/admin/external-admin-users/target", middleware.RequirePermission("external-admin-user:create"), h.ResolveTarget) protected.POST("/admin/external-admin-users", middleware.RequirePermission("external-admin-user:create"), middleware.RequirePermission("external-admin-user:permissions"), h.CreateAccount) protected.PUT("/admin/external-admin-users/:id/permissions", middleware.RequirePermission("external-admin-user:permissions"), h.UpdateAccountPermissions) protected.PATCH("/admin/external-admin-users/:id/parent", middleware.RequirePermission("external-admin-user:permissions"), h.UpdateParent) protected.PATCH("/admin/external-admin-users/:id/status", middleware.RequirePermission("external-admin-user:status"), h.SetStatus) protected.POST("/admin/external-admin-users/:id/password", middleware.RequirePermission("external-admin-user:reset-password"), h.ResetPassword) } func RegisterExternalRoutes(api *gin.RouterGroup, h *Handler, handlers BusinessHandlers) { if api == nil || h == nil { return } external := api.Group("/external") auth := external.Group("/auth") // Credentials, session state and CSRF secrets must never be cached by browsers, // service workers or intermediary proxies. This middleware runs before session // auth so 4xx/5xx responses carry the same protection as successful responses. auth.Use(noStore()) auth.POST("/login", h.Login) protectedAuth := auth.Group("") // Audit wraps CSRF so forged writes are recorded as failed attempts instead of disappearing // before the audit middleware gets control. protectedAuth.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF()) protectedAuth.GET("/me", h.Me) protectedAuth.POST("/logout", h.Logout) protectedAuth.POST("/change-password", h.ChangePassword) // Password changes remain available as a self-service action, but they are not a // business-route gate. Legacy rows may still carry password_change_required=true; // authorization must therefore depend only on the active session and permissions. business := external.Group("") business.Use(h.AuthRequired(), h.Audit(), h.RequireCSRF(), h.RequireRegionScope()) registerBusinessWhitelist(business, h, handlers) } func noStore() gin.HandlerFunc { return func(c *gin.Context) { c.Header("Cache-Control", "no-store") c.Header("Pragma", "no-cache") c.Next() } } func registerBusinessWhitelist(group *gin.RouterGroup, h *Handler, handlers BusinessHandlers) { group.GET("/admin/external-super-admins", middleware.RequirePermission("external-super-admin:list"), h.ListExternalSuperAdmins) if handlers.AppUser != nil { teamClient := handlers.AppUser.ExternalTeamClient() group.GET("/admin/team/users", middleware.RequireAnyPermission("user:list", "bd:list", "agency:list", "host:list"), h.ListExternalTeamUsers(teamClient, "")) group.GET("/admin/team/invitation-candidate", middleware.RequireAnyPermission("bd:invite", "agency:invite", "host:invite"), h.SearchExternalInvitationCandidate(teamClient)) group.POST("/admin/team/bd-invitations", middleware.RequirePermission("bd:invite"), h.CreateExternalBDInvitation(teamClient)) group.POST("/admin/team/agency-invitations", middleware.RequirePermission("agency:invite"), h.CreateExternalAgencyInvitation(teamClient)) group.POST("/admin/team/host-invitations", middleware.RequirePermission("host:invite"), h.CreateExternalHostInvitation(teamClient)) group.GET("/app/users", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user-level:grant"), h.ListExternalTeamUsers(teamClient, "")) group.GET("/app/users/bans", middleware.RequireAnyPermission("user-ban:list", "user:unban"), func(c *gin.Context) { query := c.Request.URL.Query() query.Set("status", "banned") c.Request.URL.RawQuery = query.Encode() h.ListExternalTeamUsers(teamClient, "")(c) }) // Detail is a support read for each user-targeted action, not a second product // capability. Any independently assigned user action can resolve its exact target. targetGuard := h.RequireExternalTeamTarget(teamClient, "id") group.GET("/app/users/:id", middleware.RequireAnyPermission("user:list", "user:update", "user:ban", "user:unban", "user-level:grant"), targetGuard, handlers.AppUser.GetUser) group.PATCH("/app/users/:id", middleware.RequirePermission("user:update"), targetGuard, handlers.AppUser.UpdateUser) group.PUT("/app/users/:id/levels", middleware.RequirePermission("user-level:grant"), targetGuard, handlers.AppUser.AdjustTemporaryLevels) group.POST("/app/users/:id/ban", middleware.RequirePermission("user:ban"), targetGuard, handlers.AppUser.BanUser) group.POST("/app/users/:id/unban", middleware.RequirePermission("user:unban"), targetGuard, handlers.AppUser.UnbanUser) } if handlers.HostOrg != nil { var teamClient userclient.ExternalTeamClient if handlers.AppUser != nil { teamClient = handlers.AppUser.ExternalTeamClient() } group.GET("/admin/hosts", middleware.RequirePermission("host:list"), h.ListExternalTeamUsers(teamClient, "host")) group.GET("/admin/agencies", middleware.RequirePermission("agency:list"), h.ListExternalTeamUsers(teamClient, "agency")) group.GET("/admin/bds", middleware.RequirePermission("bd:list"), h.ListExternalTeamUsers(teamClient, "bd")) } if handlers.RoomAdmin != nil { var teamClient userclient.ExternalTeamClient if handlers.AppUser != nil { teamClient = handlers.AppUser.ExternalTeamClient() } roomScope := h.HydrateExternalRoomScope(teamClient) group.GET("/admin/rooms", middleware.RequireAnyPermission("room:list", "room:update", "room:ban"), roomScope, handlers.RoomAdmin.ListRooms) group.PATCH("/admin/rooms/:room_id", middleware.RequirePermission("room:update"), roomScope, handlers.RoomAdmin.UpdateRoom) group.POST("/admin/rooms/:room_id/ban", middleware.RequirePermission("room:ban"), roomScope, handlers.RoomAdmin.BanRoom) group.POST("/admin/rooms/:room_id/unban", middleware.RequirePermission("room:ban"), roomScope, handlers.RoomAdmin.UnbanRoom) } if handlers.Resource != nil { var teamClient userclient.ExternalTeamClient if handlers.AppUser != nil { teamClient = handlers.AppUser.ExternalTeamClient() } teamScope := h.HydrateExternalRoomScope(teamClient) // Resource owner data cannot yet classify generic entitlements into the three // product labels below. The assignment validator therefore keeps them atomic, // and the route repeats all three checks so corrupt DB JSON still fails closed. group.GET("/admin/resources", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), handlers.Resource.ListExternalGrantResources) group.GET("/admin/resource-grants", middleware.RequirePermission("privilege:list"), teamScope, handlers.Resource.ListResourceGrants) group.GET("/admin/resource-grants/target", middleware.RequireAnyPermission("privilege:grant", "pretty-id:grant", "user-title:grant", "room-background:grant"), teamScope, handlers.Resource.LookupResourceGrantTarget) group.POST("/admin/resource-grants/resource", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), teamScope, handlers.Resource.GrantExternalResource) group.POST("/admin/resource-grants/resources", middleware.RequirePermission("privilege:grant"), middleware.RequirePermission("user-title:grant"), middleware.RequirePermission("room-background:grant"), teamScope, handlers.Resource.GrantExternalResources) // External UI never uses group grants. Without an owner-level semantic category, // a mixed group cannot be authorized safely, so it is intentionally not exposed. } if handlers.PrettyID != nil { var teamClient userclient.ExternalTeamClient if handlers.AppUser != nil { teamClient = handlers.AppUser.ExternalTeamClient() } teamScope := h.HydrateExternalRoomScope(teamClient) group.GET("/admin/users/pretty-ids", middleware.RequirePermission("pretty-id:grant"), handlers.PrettyID.ListIDs) group.POST("/admin/users/pretty-ids/grant", middleware.RequirePermission("pretty-id:grant"), teamScope, handlers.PrettyID.Grant) } if handlers.AppConfig != nil { group.GET("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.ListBanners) group.POST("/admin/app-config/banners", middleware.RequirePermission("banner:create"), handlers.AppConfig.CreateBanner) group.PUT("/admin/app-config/banners/:banner_id", middleware.RequirePermission("banner:update"), handlers.AppConfig.UpdateBanner) group.DELETE("/admin/app-config/banners/:banner_id", middleware.RequirePermission("banner:update"), handlers.AppConfig.DeleteBanner) } if handlers.VIPConfig != nil { var teamClient userclient.ExternalTeamClient if handlers.AppUser != nil { teamClient = handlers.AppUser.ExternalTeamClient() } teamScope := h.HydrateExternalRoomScope(teamClient) // Grant mode and level IDs are owner-service facts. Read them with the same narrowly scoped // grant permission so the portal can select direct-VIP vs trial-card without config write access. group.GET("/admin/activity/vip-program", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.GetProgram) group.GET("/admin/activity/vip-levels", middleware.RequirePermission("user-level:grant"), handlers.VIPConfig.ListLevels) group.POST("/admin/activity/vip-trial-card-grants", middleware.RequirePermission("user-level:grant"), teamScope, handlers.VIPConfig.GrantExternalVIPTrialCard) } if handlers.Upload != nil { // Banner/avatar forms need a real upload endpoint; no other file-management route is exposed. group.POST("/admin/files/image/upload", middleware.RequireAnyPermission("banner:create", "banner:update", "user:update"), handlers.Upload.UploadImage) } }