diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index f37e523f..6f52fdbf 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -17,6 +17,7 @@ type handlerSet struct { device *handlers.DeviceHandler token *handlers.TokenHandler client *handlers.ClientHandler + userClient *handlers.UserClientHandler session *handlers.SessionHandler oauth *handlers.OAuthHandler audit *handlers.AuditHandler @@ -58,8 +59,9 @@ func initializeHandlers(deps handlerDeps) handlerSet { deps.services.authorization, deps.cfg, ), - client: handlers.NewClientHandler(deps.services.client, deps.services.authorization), - session: handlers.NewSessionHandler(deps.services.token, deps.services.user), + client: handlers.NewClientHandler(deps.services.client, deps.services.authorization), + userClient: handlers.NewUserClientHandler(deps.services.client), + session: handlers.NewSessionHandler(deps.services.token, deps.services.user), oauth: handlers.NewOAuthHandler( deps.oauthProviders, deps.services.user, diff --git a/internal/bootstrap/router.go b/internal/bootstrap/router.go index b472bbe2..50c19c06 100644 --- a/internal/bootstrap/router.go +++ b/internal/bootstrap/router.go @@ -172,9 +172,13 @@ func setupAllRoutes( oauthProtected.POST("/authorize", h.authorization.HandleAuthorize) } + // injectPendingCount adds the pending client count to context for admin users. + // Applied to all authenticated route groups so the navbar badge is visible site-wide. + injectPending := h.client.InjectPendingCount() + // Protected routes (require login) protected := r.Group("") - protected.Use(middleware.RequireAuth(h.userService), middleware.CSRFMiddleware()) + protected.Use(middleware.RequireAuth(h.userService), middleware.CSRFMiddleware(), injectPending) { protected.GET("/device", h.device.DevicePage) protected.POST("/device/verify", rateLimiters.deviceVerify, h.device.DeviceVerify) @@ -182,7 +186,7 @@ func setupAllRoutes( // Account routes (require login) account := r.Group("/account") - account.Use(middleware.RequireAuth(h.userService), middleware.CSRFMiddleware()) + account.Use(middleware.RequireAuth(h.userService), middleware.CSRFMiddleware(), injectPending) { account.GET("/sessions", h.session.ListSessions) account.POST("/sessions/:id/revoke", h.session.RevokeSession) @@ -194,12 +198,27 @@ func setupAllRoutes( account.POST("/authorizations/:uuid/revoke", h.authorization.RevokeAuthorization) } + // User apps area (all authenticated users, not admin-only) + apps := r.Group("/apps") + apps.Use(middleware.RequireAuth(h.userService), middleware.CSRFMiddleware(), injectPending) + { + apps.GET("", h.userClient.ShowMyAppsPage) + apps.GET("/new", h.userClient.ShowCreateAppPage) + apps.POST("", h.userClient.CreateApp) + apps.GET("/:id", h.userClient.ShowAppPage) + apps.GET("/:id/edit", h.userClient.ShowEditAppPage) + apps.POST("/:id", h.userClient.UpdateApp) + apps.POST("/:id/delete", h.userClient.DeleteApp) + apps.GET("/:id/regenerate-secret", h.userClient.RegenerateAppSecret) + } + // Admin routes (require admin role) admin := r.Group("/admin") admin.Use( middleware.RequireAuth(h.userService), middleware.RequireAdmin(h.userService), middleware.CSRFMiddleware(), + injectPending, ) { admin.GET("/clients", h.client.ShowClientsPage) @@ -212,6 +231,8 @@ func setupAllRoutes( admin.GET("/clients/:id/regenerate-secret", h.client.RegenerateSecret) admin.POST("/clients/:id/revoke-all", h.client.RevokeAllTokens) admin.GET("/clients/:id/authorizations", h.client.ListClientAuthorizations) + admin.POST("/clients/:id/approve", h.client.ApproveClient) + admin.POST("/clients/:id/reject", h.client.RejectClient) // Audit log routes (HTML pages) admin.GET("/audit", h.audit.ShowAuditLogsPage) diff --git a/internal/handlers/audit.go b/internal/handlers/audit.go index 8ae6aa3c..e6ca4662 100644 --- a/internal/handlers/audit.go +++ b/internal/handlers/audit.go @@ -95,7 +95,7 @@ func (h *AuditHandler) ShowAuditLogsPage(c *gin.Context) { templates.RenderTempl(c, http.StatusOK, templates.AdminAuditLogs(templates.AuditLogsPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(userModel, "audit"), + NavbarProps: buildNavbarProps(c, userModel, "audit"), User: userModel, Logs: toPointerSlice(logs), Page: pagination.CurrentPage, diff --git a/internal/handlers/authorization.go b/internal/handlers/authorization.go index 47966bbd..6b14b017 100644 --- a/internal/handlers/authorization.go +++ b/internal/handlers/authorization.go @@ -113,7 +113,7 @@ func (h *AuthorizationHandler) ShowAuthorizePage(c *gin.Context) { // Render the consent page templates.RenderTempl(c, http.StatusOK, templates.AuthorizePage(templates.AuthorizePageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(user, ""), + NavbarProps: buildNavbarProps(c, user, ""), Username: user.Username, ClientID: req.Client.ClientID, ClientName: req.Client.ClientName, diff --git a/internal/handlers/client.go b/internal/handlers/client.go index f2f654cd..bb604eb3 100644 --- a/internal/handlers/client.go +++ b/internal/handlers/client.go @@ -42,15 +42,33 @@ func parseRedirectURIs(input string) []string { return redirectURIs } +// InjectPendingCount is a middleware that queries the pending client count for +// admin users and stores it in the gin context so buildNavbarProps can show the +// badge on every page. Non-admin users are skipped to avoid unnecessary queries. +func (h *ClientHandler) InjectPendingCount() gin.HandlerFunc { + return func(c *gin.Context) { + if u, exists := c.Get("user"); exists { + if user, ok := u.(*models.User); ok && user.IsAdmin() { + if count, err := h.clientService.CountPendingClients(); err == nil { + c.Set(ctxKeyPendingClientsCount, int(count)) + } + } + } + c.Next() + } +} + // ShowClientsPage displays the list of all OAuth clients func (h *ClientHandler) ShowClientsPage(c *gin.Context) { // Parse pagination parameters page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) search := c.Query("search") + statusFilter := c.Query("status") - // Create pagination params + // Create pagination params (with optional status filter) params := store.NewPaginationParams(page, pageSize, search) + params.StatusFilter = statusFilter // Get paginated clients with creator information clients, pagination, err := h.clientService.ListClientsPaginatedWithCreator(params) @@ -77,15 +95,18 @@ func (h *ClientHandler) ShowClientsPage(c *gin.Context) { user, _ := c.Get("user") userModel := user.(*models.User) + navbar := buildNavbarProps(c, userModel, "clients") + templates.RenderTempl(c, http.StatusOK, templates.AdminClients(templates.ClientsPageProps{ - BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(userModel, "clients"), - User: userModel, - Clients: clients, - Pagination: pagination, - Search: search, - PageSize: pageSize, - Success: successMsg, + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: navbar, + User: userModel, + Clients: clients, + Pagination: pagination, + Search: search, + PageSize: pageSize, + Success: successMsg, + StatusFilter: statusFilter, })) } @@ -96,7 +117,7 @@ func (h *ClientHandler) ShowCreateClientPage(c *gin.Context) { templates.RenderTempl(c, http.StatusOK, templates.AdminClientForm(templates.ClientFormPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(userModel, "clients"), + NavbarProps: buildNavbarProps(c, userModel, "clients"), Title: "Create OAuth Client", Method: http.MethodPost, Action: "/admin/clients", @@ -119,6 +140,7 @@ func (h *ClientHandler) CreateClient(c *gin.Context) { EnableDeviceFlow: c.PostForm("enable_device_flow") == queryValueTrue, EnableAuthCodeFlow: c.PostForm("enable_auth_code_flow") == queryValueTrue, EnableClientCredentialsFlow: c.PostForm("enable_client_credentials_flow") == queryValueTrue, + IsAdminCreated: true, // admin-created clients are immediately active } resp, err := h.clientService.CreateClient(c.Request.Context(), req) @@ -143,7 +165,7 @@ func (h *ClientHandler) CreateClient(c *gin.Context) { http.StatusBadRequest, templates.AdminClientForm(templates.ClientFormPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(userModel, "clients"), + NavbarProps: buildNavbarProps(c, userModel, "clients"), Client: clientData, Error: err.Error(), Title: "Create OAuth Client", @@ -173,6 +195,7 @@ func (h *ClientHandler) CreateClient(c *gin.Context) { EnableAuthCodeFlow: resp.EnableAuthCodeFlow, EnableClientCredentialsFlow: resp.EnableClientCredentialsFlow, IsActive: resp.IsActive, + Status: resp.Status, } templates.RenderTempl( @@ -180,7 +203,7 @@ func (h *ClientHandler) CreateClient(c *gin.Context) { http.StatusOK, templates.AdminClientCreated(templates.ClientCreatedPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(userModel, "clients"), + NavbarProps: buildNavbarProps(c, userModel, "clients"), Client: clientDisplay, ClientSecret: resp.ClientSecretPlain, }), @@ -220,7 +243,7 @@ func (h *ClientHandler) ShowEditClientPage(c *gin.Context) { templates.RenderTempl(c, http.StatusOK, templates.AdminClientForm(templates.ClientFormPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(userModel, "clients"), + NavbarProps: buildNavbarProps(c, userModel, "clients"), Client: clientDisplay, Title: "Edit OAuth Client", Method: http.MethodPost, @@ -275,7 +298,7 @@ func (h *ClientHandler) UpdateClient(c *gin.Context) { http.StatusBadRequest, templates.AdminClientForm(templates.ClientFormPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(userModel, "clients"), + NavbarProps: buildNavbarProps(c, userModel, "clients"), Client: clientDisplay, Error: err.Error(), Title: "Edit OAuth Client", @@ -340,7 +363,7 @@ func (h *ClientHandler) RegenerateSecret(c *gin.Context) { http.StatusOK, templates.AdminClientSecret(templates.ClientSecretPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(userModel, "clients"), + NavbarProps: buildNavbarProps(c, userModel, "clients"), Client: client, ClientSecret: newSecret, }), @@ -369,6 +392,10 @@ func (h *ClientHandler) ViewClient(c *gin.Context) { successMsg = "All active tokens have been revoked. Users will need to re-authenticate." case "updated": successMsg = "Client updated successfully." + case "approved": + successMsg = "Client approved. It is now active and can be used for OAuth flows." + case "rejected": + successMsg = "Client rejected. It has been set to inactive." } templates.RenderTempl( @@ -376,7 +403,7 @@ func (h *ClientHandler) ViewClient(c *gin.Context) { http.StatusOK, templates.AdminClientDetail(templates.ClientDetailPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(userModel, "clients"), + NavbarProps: buildNavbarProps(c, userModel, "clients"), Client: client, ActiveTokenCount: activeTokenCount, Success: successMsg, @@ -384,6 +411,40 @@ func (h *ClientHandler) ViewClient(c *gin.Context) { ) } +// ApproveClient sets a pending client's status to active. +func (h *ClientHandler) ApproveClient(c *gin.Context) { + clientID := c.Param("id") + userID, _ := c.Get("user_id") + + if err := h.clientService.ApproveClient( + c.Request.Context(), + clientID, + userID.(string), + ); err != nil { + renderErrorPage(c, http.StatusInternalServerError, "Failed to approve client: "+err.Error()) + return + } + + c.Redirect(http.StatusFound, "/admin/clients/"+clientID+"?success=approved") +} + +// RejectClient sets a pending client's status to inactive. +func (h *ClientHandler) RejectClient(c *gin.Context) { + clientID := c.Param("id") + userID, _ := c.Get("user_id") + + if err := h.clientService.RejectClient( + c.Request.Context(), + clientID, + userID.(string), + ); err != nil { + renderErrorPage(c, http.StatusInternalServerError, "Failed to reject client: "+err.Error()) + return + } + + c.Redirect(http.StatusFound, "/admin/clients/"+clientID+"?success=rejected") +} + // ListClientAuthorizations shows all users who have granted access to this client (admin overview). func (h *ClientHandler) ListClientAuthorizations(c *gin.Context) { clientID := c.Param("id") @@ -424,7 +485,7 @@ func (h *ClientHandler) ListClientAuthorizations(c *gin.Context) { http.StatusOK, templates.AdminClientAuthorizations(templates.ClientAuthorizationsPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(userModel, "clients"), + NavbarProps: buildNavbarProps(c, userModel, "clients"), Client: client, Authorizations: displayAuths, }), @@ -452,7 +513,7 @@ func (h *ClientHandler) RevokeAllTokens(c *gin.Context) { http.StatusInternalServerError, templates.AdminClientDetail(templates.ClientDetailPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(userModel, "clients"), + NavbarProps: buildNavbarProps(c, userModel, "clients"), Client: client, ActiveTokenCount: activeTokenCount, Error: "Failed to revoke tokens: " + err.Error(), diff --git a/internal/handlers/session.go b/internal/handlers/session.go index 1d7788f9..aef814a4 100644 --- a/internal/handlers/session.go +++ b/internal/handlers/session.go @@ -59,7 +59,7 @@ func (h *SessionHandler) ListSessions(c *gin.Context) { templates.RenderTempl(c, http.StatusOK, templates.AccountSessions(templates.SessionsPageProps{ BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, - NavbarProps: buildNavbarProps(user, "sessions"), + NavbarProps: buildNavbarProps(c, user, "sessions"), Sessions: tokens, Pagination: pagination, Search: search, diff --git a/internal/handlers/user_client.go b/internal/handlers/user_client.go new file mode 100644 index 00000000..3f8362f0 --- /dev/null +++ b/internal/handlers/user_client.go @@ -0,0 +1,372 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + "strings" + + "github.com/go-authgate/authgate/internal/middleware" + "github.com/go-authgate/authgate/internal/models" + "github.com/go-authgate/authgate/internal/services" + "github.com/go-authgate/authgate/internal/store" + "github.com/go-authgate/authgate/internal/templates" + + "github.com/gin-gonic/gin" +) + +// UserClientHandler handles the /apps area for authenticated (non-admin) users +// to register and manage their own OAuth applications. +type UserClientHandler struct { + clientService *services.ClientService +} + +func NewUserClientHandler(cs *services.ClientService) *UserClientHandler { + return &UserClientHandler{clientService: cs} +} + +// ShowMyAppsPage lists all OAuth applications owned by the logged-in user. +func (h *UserClientHandler) ShowMyAppsPage(c *gin.Context) { + userID, _ := c.Get("user_id") + user, _ := c.Get("user") + userModel := user.(*models.User) + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10")) + search := c.Query("search") + + params := store.NewPaginationParams(page, pageSize, search) + apps, pagination, err := h.clientService.ListClientsByUser(userID.(string), params) + if err != nil { + renderErrorPage(c, http.StatusInternalServerError, "Failed to load apps: "+err.Error()) + return + } + + templates.RenderTempl(c, http.StatusOK, templates.MyApps(templates.MyAppsPageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, userModel, "my-apps"), + Apps: apps, + Pagination: pagination, + PageSize: pageSize, + Search: search, + })) +} + +// ShowCreateAppPage displays the form to register a new application. +func (h *UserClientHandler) ShowCreateAppPage(c *gin.Context) { + user, _ := c.Get("user") + userModel := user.(*models.User) + + templates.RenderTempl(c, http.StatusOK, templates.UserAppForm(templates.UserClientFormPageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, userModel, "my-apps"), + Title: "Register New App", + Method: http.MethodPost, + Action: "/apps", + IsEdit: false, + })) +} + +// CreateApp handles POST /apps to register a new OAuth client. +func (h *UserClientHandler) CreateApp(c *gin.Context) { + userID, _ := c.Get("user_id") + user, _ := c.Get("user") + userModel := user.(*models.User) + + req := services.CreateClientRequest{ + ClientName: c.PostForm("client_name"), + Description: c.PostForm("description"), + UserID: userID.(string), + Scopes: c.PostForm("scopes"), + RedirectURIs: parseRedirectURIs(c.PostForm("redirect_uris")), + CreatedBy: userID.(string), + ClientType: c.PostForm("client_type"), + EnableDeviceFlow: c.PostForm("enable_device_flow") == queryValueTrue, + EnableAuthCodeFlow: c.PostForm("enable_auth_code_flow") == queryValueTrue, + IsAdminCreated: false, // user-created: starts as pending + } + + // Validate scopes before calling service to give a user-friendly error + if req.Scopes != "" { + for scope := range strings.FieldsSeq(req.Scopes) { + switch scope { + case "email", "profile", "openid", "offline_access": + // ok + default: + renderUserAppForm( + c, + userModel, + nil, + "/apps", + false, + "Invalid scope: "+scope+". Allowed scopes are: email, profile, openid, offline_access", + ) + return + } + } + } + + resp, err := h.clientService.CreateClient(c.Request.Context(), req) + if err != nil { + clientData := &templates.ClientDisplay{ + ClientName: req.ClientName, + Description: req.Description, + Scopes: req.Scopes, + RedirectURIs: strings.Join(req.RedirectURIs, ", "), + ClientType: req.ClientType, + EnableDeviceFlow: req.EnableDeviceFlow, + EnableAuthCodeFlow: req.EnableAuthCodeFlow, + } + renderUserAppForm(c, userModel, clientData, "/apps", false, err.Error()) + return + } + + clientDisplay := appToDisplay(resp.OAuthApplication) + + templates.RenderTempl( + c, + http.StatusOK, + templates.UserAppCreated(templates.UserClientCreatedPageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, userModel, "my-apps"), + Client: clientDisplay, + PlainSecret: resp.ClientSecretPlain, + }), + ) +} + +// ShowAppPage displays details for a user-owned app. +func (h *UserClientHandler) ShowAppPage(c *gin.Context) { + clientID := c.Param("id") + userID, _ := c.Get("user_id") + user, _ := c.Get("user") + userModel := user.(*models.User) + + client, err := h.clientService.GetClient(clientID) + if err != nil { + renderErrorPage(c, http.StatusNotFound, "App not found") + return + } + + if client.UserID != userID.(string) { + renderErrorPage(c, http.StatusForbidden, "You do not have access to this app") + return + } + + activeTokens, _ := h.clientService.CountActiveTokens(clientID) + + successMsg := "" + if c.Query("success") == "updated" { + successMsg = "App updated successfully." + } + + templates.RenderTempl( + c, + http.StatusOK, + templates.UserAppDetail(templates.UserClientDetailPageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, userModel, "my-apps"), + Client: appToDisplay(client), + ActiveTokens: activeTokens, + Success: successMsg, + }), + ) +} + +// ShowEditAppPage displays the edit form for a user-owned app. +func (h *UserClientHandler) ShowEditAppPage(c *gin.Context) { + clientID := c.Param("id") + userID, _ := c.Get("user_id") + user, _ := c.Get("user") + userModel := user.(*models.User) + + client, err := h.clientService.GetClient(clientID) + if err != nil { + renderErrorPage(c, http.StatusNotFound, "App not found") + return + } + + if client.UserID != userID.(string) { + renderErrorPage(c, http.StatusForbidden, "You do not have access to this app") + return + } + + action := "/apps/" + clientID + templates.RenderTempl(c, http.StatusOK, templates.UserAppForm(templates.UserClientFormPageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, userModel, "my-apps"), + Title: "Edit App", + Method: http.MethodPost, + Action: action, + IsEdit: true, + Client: appToDisplay(client), + })) +} + +// UpdateApp handles POST /apps/:id to update a user-owned app. +func (h *UserClientHandler) UpdateApp(c *gin.Context) { + clientID := c.Param("id") + userID, _ := c.Get("user_id") + user, _ := c.Get("user") + userModel := user.(*models.User) + + req := services.UserUpdateClientRequest{ + ClientName: c.PostForm("client_name"), + Description: c.PostForm("description"), + Scopes: c.PostForm("scopes"), + RedirectURIs: parseRedirectURIs(c.PostForm("redirect_uris")), + ClientType: c.PostForm("client_type"), + EnableDeviceFlow: c.PostForm("enable_device_flow") == queryValueTrue, + EnableAuthCodeFlow: c.PostForm("enable_auth_code_flow") == queryValueTrue, + } + + err := h.clientService.UserUpdateClient(c.Request.Context(), clientID, userID.(string), req) + if err != nil { + if errors.Is(err, services.ErrClientOwnershipRequired) { + renderErrorPage(c, http.StatusForbidden, err.Error()) + return + } + + client, _ := h.clientService.GetClient(clientID) + clientData := &templates.ClientDisplay{ + ClientName: req.ClientName, + Description: req.Description, + Scopes: req.Scopes, + RedirectURIs: strings.Join(req.RedirectURIs, ", "), + ClientType: req.ClientType, + EnableDeviceFlow: req.EnableDeviceFlow, + EnableAuthCodeFlow: req.EnableAuthCodeFlow, + } + if client != nil { + clientData.ID = client.ID + clientData.ClientID = client.ClientID + clientData.Status = client.Status + } + renderUserAppForm(c, userModel, clientData, "/apps/"+clientID, true, err.Error()) + return + } + + c.Redirect(http.StatusFound, "/apps/"+clientID+"?success=updated") +} + +// DeleteApp handles POST /apps/:id/delete to remove a pending or inactive user-owned app. +func (h *UserClientHandler) DeleteApp(c *gin.Context) { + clientID := c.Param("id") + userID, _ := c.Get("user_id") + + err := h.clientService.UserDeleteClient(c.Request.Context(), clientID, userID.(string)) + if err != nil { + if errors.Is(err, services.ErrClientOwnershipRequired) { + renderErrorPage(c, http.StatusForbidden, err.Error()) + return + } + if errors.Is(err, services.ErrCannotDeleteActiveClient) { + renderErrorPage(c, http.StatusBadRequest, err.Error()) + return + } + renderErrorPage(c, http.StatusInternalServerError, "Failed to delete app: "+err.Error()) + return + } + + c.Redirect(http.StatusFound, "/apps") +} + +// RegenerateAppSecret handles GET /apps/:id/regenerate-secret. +func (h *UserClientHandler) RegenerateAppSecret(c *gin.Context) { + clientID := c.Param("id") + userID, _ := c.Get("user_id") + user, _ := c.Get("user") + userModel := user.(*models.User) + + // Ownership check + client, err := h.clientService.GetClient(clientID) + if err != nil { + renderErrorPage(c, http.StatusNotFound, "App not found") + return + } + if client.UserID != userID.(string) { + renderErrorPage(c, http.StatusForbidden, "You do not have access to this app") + return + } + + newSecret, err := h.clientService.RegenerateSecret( + c.Request.Context(), + clientID, + userID.(string), + ) + if err != nil { + renderErrorPage( + c, + http.StatusInternalServerError, + "Failed to regenerate secret: "+err.Error(), + ) + return + } + + refreshed, _ := h.clientService.GetClient(clientID) + display := appToDisplay(refreshed) + + templates.RenderTempl( + c, + http.StatusOK, + templates.UserAppCreated(templates.UserClientCreatedPageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, userModel, "my-apps"), + Client: display, + PlainSecret: newSecret, + }), + ) +} + +// appToDisplay converts an OAuthApplication to a ClientDisplay for user templates. +func appToDisplay(app *models.OAuthApplication) *templates.ClientDisplay { + if app == nil { + return nil + } + return &templates.ClientDisplay{ + ID: app.ID, + ClientID: app.ClientID, + ClientName: app.ClientName, + Description: app.Description, + UserID: app.UserID, + Scopes: app.Scopes, + GrantTypes: app.GrantTypes, + RedirectURIs: app.RedirectURIs.Join(", "), + ClientType: app.ClientType, + EnableDeviceFlow: app.EnableDeviceFlow, + EnableAuthCodeFlow: app.EnableAuthCodeFlow, + EnableClientCredentialsFlow: app.EnableClientCredentialsFlow, + IsActive: app.IsActive, + Status: app.Status, + CreatedAt: app.CreatedAt, + UpdatedAt: app.UpdatedAt, + } +} + +func renderUserAppForm( + c *gin.Context, + user *models.User, + client *templates.ClientDisplay, + action string, + isEdit bool, + errMsg string, +) { + title := "Register New App" + if isEdit { + title = "Edit App" + } + templates.RenderTempl( + c, + http.StatusBadRequest, + templates.UserAppForm(templates.UserClientFormPageProps{ + BaseProps: templates.BaseProps{CSRFToken: middleware.GetCSRFToken(c)}, + NavbarProps: buildNavbarProps(c, user, "my-apps"), + Title: title, + Method: http.MethodPost, + Action: action, + IsEdit: isEdit, + Client: client, + Error: errMsg, + }), + ) +} diff --git a/internal/handlers/utils.go b/internal/handlers/utils.go index ac771abf..30ef8b78 100644 --- a/internal/handlers/utils.go +++ b/internal/handlers/utils.go @@ -7,13 +7,24 @@ import ( "github.com/gin-gonic/gin" ) +const ctxKeyPendingClientsCount = "pending_clients_count" + // buildNavbarProps creates NavbarProps from a user model and active link identifier. -func buildNavbarProps(user *models.User, activeLink string) templates.NavbarProps { +// If the gin context contains a pending_clients_count value (set by InjectPendingCount +// middleware), it is included in the navbar badge for admin users. +func buildNavbarProps(c *gin.Context, user *models.User, activeLink string) templates.NavbarProps { + pendingCount := 0 + if v, exists := c.Get(ctxKeyPendingClientsCount); exists { + if count, ok := v.(int); ok { + pendingCount = count + } + } return templates.NavbarProps{ - Username: user.Username, - FullName: user.FullName, - IsAdmin: user.IsAdmin(), - ActiveLink: activeLink, + Username: user.Username, + FullName: user.FullName, + IsAdmin: user.IsAdmin(), + ActiveLink: activeLink, + PendingClientsCount: pendingCount, } } diff --git a/internal/models/audit_log.go b/internal/models/audit_log.go index 64e5e1b3..9abca8e2 100644 --- a/internal/models/audit_log.go +++ b/internal/models/audit_log.go @@ -48,6 +48,10 @@ const ( EventUserAuthorizationRevoked EventType = "USER_AUTHORIZATION_REVOKED" EventClientTokensRevokedAll EventType = "CLIENT_TOKENS_REVOKED_ALL" //nolint:gosec // G101: false positive, this is a const string describing an event type, not a credential + // Client approval events (user self-service workflow) + EventClientApproved EventType = "CLIENT_APPROVED" + EventClientRejected EventType = "CLIENT_REJECTED" + // Client Credentials Flow events (RFC 6749 §4.4) EventClientCredentialsTokenIssued EventType = "CLIENT_CREDENTIALS_TOKEN_ISSUED" //nolint:gosec // G101: false positive diff --git a/internal/models/oauth_application.go b/internal/models/oauth_application.go index 07e17e38..978f65a1 100644 --- a/internal/models/oauth_application.go +++ b/internal/models/oauth_application.go @@ -14,6 +14,13 @@ import ( "golang.org/x/crypto/bcrypt" ) +// ClientStatus constants define the approval lifecycle of an OAuth client. +const ( + ClientStatusPending = "pending" // Awaiting admin approval + ClientStatusActive = "active" // Admin approved / admin-created + ClientStatusInactive = "inactive" // Admin rejected or disabled +) + // Base32 characters, but lowercased. const lowerBase32Chars = "abcdefghijklmnopqrstuvwxyz234567" @@ -35,6 +42,7 @@ type OAuthApplication struct { EnableAuthCodeFlow bool `gorm:"not null;default:false"` EnableClientCredentialsFlow bool `gorm:"not null;default:false"` // Client Credentials Grant (RFC 6749 §4.4); confidential clients only IsActive bool `gorm:"not null;default:true"` + Status string `gorm:"not null;default:'active'"` // ClientStatusPending / ClientStatusActive / ClientStatusInactive CreatedBy string CreatedAt time.Time UpdatedAt time.Time diff --git a/internal/services/client.go b/internal/services/client.go index 34ecfb50..b5840cb1 100644 --- a/internal/services/client.go +++ b/internal/services/client.go @@ -19,6 +19,29 @@ const ( ClientTypePublic = "public" ) +// buildGrantTypes derives the GrantTypes string from per-flow enable flags. +func buildGrantTypes(enableDevice, enableAuthCode, enableClientCredentials bool) string { + var grants []string + if enableDevice { + grants = append(grants, "device_code") + } + if enableAuthCode { + grants = append(grants, "authorization_code") + } + if enableClientCredentials { + grants = append(grants, "client_credentials") + } + return strings.Join(grants, " ") +} + +// allowedUserScopes are the scopes that non-admin users may request. +var allowedUserScopes = map[string]bool{ + "email": true, + "profile": true, + "openid": true, + "offline_access": true, +} + var ( ErrClientNotFound = errors.New("client not found") ErrInvalidClientData = errors.New("invalid client data") @@ -26,7 +49,10 @@ var ( ErrRedirectURIRequired = errors.New( "at least one redirect URI is required when Authorization Code Flow is enabled", ) - ErrAtLeastOneGrantRequired = errors.New("at least one grant type must be enabled") + ErrAtLeastOneGrantRequired = errors.New("at least one grant type must be enabled") + ErrClientOwnershipRequired = errors.New("you do not own this client") + ErrCannotDeleteActiveClient = errors.New("cannot delete an active client") + ErrInvalidScopeForUser = errors.New("scope not allowed for user-created clients") ) // validateRedirectURIs checks that every URI in the slice is an absolute http/https @@ -76,6 +102,18 @@ type CreateClientRequest struct { EnableDeviceFlow bool // Enable Device Authorization Grant (RFC 8628) EnableAuthCodeFlow bool // Enable Authorization Code Flow (RFC 6749) EnableClientCredentialsFlow bool // Enable Client Credentials Grant (RFC 6749 §4.4); confidential clients only + IsAdminCreated bool // When true: Status=active, IsActive=true; when false: Status=pending, IsActive=false +} + +// UserUpdateClientRequest contains the restricted set of fields a non-admin user may update on their own client. +type UserUpdateClientRequest struct { + ClientName string + Description string + Scopes string // validated against allowedUserScopes + RedirectURIs []string + ClientType string + EnableDeviceFlow bool + EnableAuthCodeFlow bool } type UpdateClientRequest struct { @@ -144,17 +182,16 @@ func (s *ClientService) CreateClient( } // Derive GrantTypes string from the enabled flows - var grants []string - if enableDevice { - grants = append(grants, "device_code") - } - if enableAuthCode { - grants = append(grants, "authorization_code") - } - if enableClientCredentials { - grants = append(grants, "client_credentials") + grantTypes := buildGrantTypes(enableDevice, enableAuthCode, enableClientCredentials) + + // Determine approval status based on creator role. + // Admin-created clients are immediately active; user-created clients require approval. + clientStatus := models.ClientStatusPending + isActive := false + if req.IsAdminCreated { + clientStatus = models.ClientStatusActive + isActive = true } - grantTypes := strings.Join(grants, " ") client := &models.OAuthApplication{ ClientID: clientID, @@ -168,7 +205,8 @@ func (s *ClientService) CreateClient( EnableDeviceFlow: enableDevice, EnableAuthCodeFlow: enableAuthCode, EnableClientCredentialsFlow: enableClientCredentials, - IsActive: true, + IsActive: isActive, + Status: clientStatus, CreatedBy: req.CreatedBy, } @@ -182,6 +220,16 @@ func (s *ClientService) CreateClient( return nil, err } + // GORM treats false as the zero value for bool and ignores it during CREATE when + // the column has a `default:true` tag. For pending (user-created) clients we must + // perform an explicit UPDATE to ensure IsActive is persisted as false. + if !isActive { + client.IsActive = false + if err := s.store.UpdateClient(client); err != nil { + return nil, err + } + } + // Log client creation if s.auditService != nil { s.auditService.Log(ctx, AuditLogEntry{ @@ -253,17 +301,11 @@ func (s *ClientService) UpdateClient( client.EnableDeviceFlow = req.EnableDeviceFlow client.EnableAuthCodeFlow = req.EnableAuthCodeFlow client.EnableClientCredentialsFlow = enableClientCredentials - var grants []string - if req.EnableDeviceFlow { - grants = append(grants, "device_code") - } - if req.EnableAuthCodeFlow { - grants = append(grants, "authorization_code") - } - if enableClientCredentials { - grants = append(grants, "client_credentials") - } - client.GrantTypes = strings.Join(grants, " ") + client.GrantTypes = buildGrantTypes( + req.EnableDeviceFlow, + req.EnableAuthCodeFlow, + enableClientCredentials, + ) err = s.store.UpdateClient(client) if err != nil { @@ -454,3 +496,218 @@ func (s *ClientService) VerifyClientSecret(clientID, clientSecret string) error func (s *ClientService) CountActiveTokens(clientID string) (int64, error) { return s.store.CountActiveTokensByClientID(clientID) } + +// CountPendingClients returns the number of clients awaiting admin approval. +func (s *ClientService) CountPendingClients() (int64, error) { + return s.store.CountClientsByStatus(models.ClientStatusPending) +} + +// ListClientsByUser returns paginated OAuth clients owned by the given user. +func (s *ClientService) ListClientsByUser( + userID string, + params store.PaginationParams, +) ([]models.OAuthApplication, store.PaginationResult, error) { + return s.store.ListClientsByUserID(userID, params) +} + +// validateUserScopes checks that all requested scopes are in the allowed set for user-created clients. +func validateUserScopes(scopes string) error { + for scope := range strings.FieldsSeq(scopes) { + if !allowedUserScopes[scope] { + return fmt.Errorf("%w: %q", ErrInvalidScopeForUser, scope) + } + } + return nil +} + +// UserUpdateClient updates a client owned by actorUserID with the restricted field set. +// Ownership is enforced; the approval Status is never changed by this method. +func (s *ClientService) UserUpdateClient( + ctx context.Context, + clientID, actorUserID string, + req UserUpdateClientRequest, +) error { + if strings.TrimSpace(req.ClientName) == "" { + return ErrClientNameRequired + } + + if !req.EnableDeviceFlow && !req.EnableAuthCodeFlow { + return ErrAtLeastOneGrantRequired + } + + if req.EnableAuthCodeFlow && len(req.RedirectURIs) == 0 { + return ErrRedirectURIRequired + } + + if err := validateRedirectURIs(req.RedirectURIs); err != nil { + return err + } + + if err := validateUserScopes(req.Scopes); err != nil { + return err + } + + client, err := s.store.GetClient(clientID) + if err != nil { + return ErrClientNotFound + } + + if client.UserID != actorUserID { + return ErrClientOwnershipRequired + } + + client.ClientName = strings.TrimSpace(req.ClientName) + client.Description = strings.TrimSpace(req.Description) + client.Scopes = strings.TrimSpace(req.Scopes) + client.RedirectURIs = models.StringArray(req.RedirectURIs) + + if req.ClientType == ClientTypePublic { + client.ClientType = ClientTypePublic + } else { + client.ClientType = ClientTypeConfidential + } + + client.EnableDeviceFlow = req.EnableDeviceFlow + client.EnableAuthCodeFlow = req.EnableAuthCodeFlow + // User-created clients cannot enable client credentials flow. + client.EnableClientCredentialsFlow = false + client.GrantTypes = buildGrantTypes(req.EnableDeviceFlow, req.EnableAuthCodeFlow, false) + + if err := s.store.UpdateClient(client); err != nil { + return err + } + + if s.auditService != nil { + s.auditService.Log(ctx, AuditLogEntry{ + EventType: models.EventClientUpdated, + Severity: models.SeverityInfo, + ActorUserID: actorUserID, + ResourceType: models.ResourceClient, + ResourceID: clientID, + ResourceName: client.ClientName, + Action: "OAuth client updated by owner", + Details: models.AuditDetails{ + "client_name": client.ClientName, + "grant_types": client.GrantTypes, + "scopes": client.Scopes, + }, + Success: true, + }) + } + + return nil +} + +// UserDeleteClient deletes a client owned by actorUserID. +// Deletion is blocked for clients with Status=active (must be rejected first). +func (s *ClientService) UserDeleteClient( + ctx context.Context, + clientID, actorUserID string, +) error { + client, err := s.store.GetClient(clientID) + if err != nil { + return ErrClientNotFound + } + + if client.UserID != actorUserID { + return ErrClientOwnershipRequired + } + + if client.Status == models.ClientStatusActive { + return ErrCannotDeleteActiveClient + } + + if err := s.store.DeleteClient(clientID); err != nil { + return err + } + + if s.auditService != nil { + s.auditService.Log(ctx, AuditLogEntry{ + EventType: models.EventClientDeleted, + Severity: models.SeverityWarning, + ActorUserID: actorUserID, + ResourceType: models.ResourceClient, + ResourceID: clientID, + ResourceName: client.ClientName, + Action: "OAuth client deleted by owner", + Details: models.AuditDetails{ + "client_name": client.ClientName, + }, + Success: true, + }) + } + + return nil +} + +// ApproveClient sets a client's status to active and enables it for OAuth flows. +func (s *ClientService) ApproveClient( + ctx context.Context, + clientID, adminUserID string, +) error { + client, err := s.store.GetClient(clientID) + if err != nil { + return ErrClientNotFound + } + + client.Status = models.ClientStatusActive + client.IsActive = true + + if err := s.store.UpdateClient(client); err != nil { + return err + } + + if s.auditService != nil { + s.auditService.Log(ctx, AuditLogEntry{ + EventType: models.EventClientApproved, + Severity: models.SeverityInfo, + ActorUserID: adminUserID, + ResourceType: models.ResourceClient, + ResourceID: clientID, + ResourceName: client.ClientName, + Action: "OAuth client approved", + Details: models.AuditDetails{ + "client_name": client.ClientName, + }, + Success: true, + }) + } + + return nil +} + +// RejectClient sets a client's status to inactive and disables it for OAuth flows. +func (s *ClientService) RejectClient( + ctx context.Context, + clientID, adminUserID string, +) error { + client, err := s.store.GetClient(clientID) + if err != nil { + return ErrClientNotFound + } + + client.Status = models.ClientStatusInactive + client.IsActive = false + + if err := s.store.UpdateClient(client); err != nil { + return err + } + + if s.auditService != nil { + s.auditService.Log(ctx, AuditLogEntry{ + EventType: models.EventClientRejected, + Severity: models.SeverityInfo, + ActorUserID: adminUserID, + ResourceType: models.ResourceClient, + ResourceID: clientID, + ResourceName: client.ClientName, + Action: "OAuth client rejected", + Details: models.AuditDetails{ + "client_name": client.ClientName, + }, + Success: true, + }) + } + + return nil +} diff --git a/internal/services/client_user_test.go b/internal/services/client_user_test.go new file mode 100644 index 00000000..8ec25796 --- /dev/null +++ b/internal/services/client_user_test.go @@ -0,0 +1,347 @@ +package services + +import ( + "context" + "testing" + + "github.com/go-authgate/authgate/internal/models" + "github.com/go-authgate/authgate/internal/store" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ============================================================ +// CreateClient – IsAdminCreated flag +// ============================================================ + +func TestCreateClient_AdminCreated_IsActive(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + userID := uuid.New().String() + + resp, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "Admin Client", + UserID: userID, + CreatedBy: userID, + IsAdminCreated: true, + }) + require.NoError(t, err) + assert.True(t, resp.IsActive) + assert.Equal(t, models.ClientStatusActive, resp.Status) +} + +func TestCreateClient_UserCreated_IsPendingAndInactive(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + userID := uuid.New().String() + + resp, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "User Client", + UserID: userID, + CreatedBy: userID, + IsAdminCreated: false, + }) + require.NoError(t, err) + assert.False(t, resp.IsActive) + assert.Equal(t, models.ClientStatusPending, resp.Status) +} + +// ============================================================ +// UserUpdateClient – ownership enforcement +// ============================================================ + +func TestUserUpdateClient_OwnershipEnforced(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + ownerID := uuid.New().String() + otherID := uuid.New().String() + + resp, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "Owned Client", + UserID: ownerID, + CreatedBy: ownerID, + IsAdminCreated: false, + }) + require.NoError(t, err) + + err = svc.UserUpdateClient( + context.Background(), + resp.ClientID, + otherID, + UserUpdateClientRequest{ + ClientName: "Renamed", + EnableDeviceFlow: true, + }, + ) + assert.ErrorIs(t, err, ErrClientOwnershipRequired) +} + +func TestUserUpdateClient_OwnerCanUpdate(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + ownerID := uuid.New().String() + + resp, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "My App", + UserID: ownerID, + CreatedBy: ownerID, + IsAdminCreated: false, + }) + require.NoError(t, err) + + err = svc.UserUpdateClient( + context.Background(), + resp.ClientID, + ownerID, + UserUpdateClientRequest{ + ClientName: "My App Updated", + EnableDeviceFlow: true, + Scopes: "email profile", + }, + ) + require.NoError(t, err) + + updated, err := svc.GetClient(resp.ClientID) + require.NoError(t, err) + assert.Equal(t, "My App Updated", updated.ClientName) +} + +// ============================================================ +// UserUpdateClient – scope validation +// ============================================================ + +func TestUserUpdateClient_InvalidScopeRejected(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + ownerID := uuid.New().String() + + resp, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "Scope App", + UserID: ownerID, + CreatedBy: ownerID, + IsAdminCreated: false, + }) + require.NoError(t, err) + + err = svc.UserUpdateClient( + context.Background(), + resp.ClientID, + ownerID, + UserUpdateClientRequest{ + ClientName: "Scope App", + EnableDeviceFlow: true, + Scopes: "admin superuser", // not allowed + }, + ) + assert.ErrorIs(t, err, ErrInvalidScopeForUser) +} + +func TestUserUpdateClient_AllowedScopesAccepted(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + ownerID := uuid.New().String() + + resp, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "Scope App OK", + UserID: ownerID, + CreatedBy: ownerID, + IsAdminCreated: false, + }) + require.NoError(t, err) + + err = svc.UserUpdateClient( + context.Background(), + resp.ClientID, + ownerID, + UserUpdateClientRequest{ + ClientName: "Scope App OK", + EnableDeviceFlow: true, + Scopes: "email profile openid offline_access", + }, + ) + require.NoError(t, err) +} + +// ============================================================ +// UserDeleteClient – ownership + active block +// ============================================================ + +func TestUserDeleteClient_OwnershipEnforced(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + ownerID := uuid.New().String() + otherID := uuid.New().String() + + resp, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "Delete Target", + UserID: ownerID, + CreatedBy: ownerID, + IsAdminCreated: false, + }) + require.NoError(t, err) + + err = svc.UserDeleteClient(context.Background(), resp.ClientID, otherID) + assert.ErrorIs(t, err, ErrClientOwnershipRequired) +} + +func TestUserDeleteClient_ActiveClientBlocked(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + ownerID := uuid.New().String() + adminID := uuid.New().String() + + resp, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "Active Client", + UserID: ownerID, + CreatedBy: ownerID, + IsAdminCreated: false, + }) + require.NoError(t, err) + + // Approve it first to make it active + require.NoError(t, svc.ApproveClient(context.Background(), resp.ClientID, adminID)) + + err = svc.UserDeleteClient(context.Background(), resp.ClientID, ownerID) + assert.ErrorIs(t, err, ErrCannotDeleteActiveClient) +} + +func TestUserDeleteClient_PendingClientAllowed(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + ownerID := uuid.New().String() + + resp, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "Pending Client", + UserID: ownerID, + CreatedBy: ownerID, + IsAdminCreated: false, + }) + require.NoError(t, err) + assert.Equal(t, models.ClientStatusPending, resp.Status) + + err = svc.UserDeleteClient(context.Background(), resp.ClientID, ownerID) + require.NoError(t, err) +} + +// ============================================================ +// ApproveClient / RejectClient +// ============================================================ + +func TestApproveClient_SetsActiveStatus(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + ownerID := uuid.New().String() + adminID := uuid.New().String() + + resp, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "Pending", + UserID: ownerID, + CreatedBy: ownerID, + IsAdminCreated: false, + }) + require.NoError(t, err) + assert.Equal(t, models.ClientStatusPending, resp.Status) + assert.False(t, resp.IsActive) + + require.NoError(t, svc.ApproveClient(context.Background(), resp.ClientID, adminID)) + + approved, err := svc.GetClient(resp.ClientID) + require.NoError(t, err) + assert.Equal(t, models.ClientStatusActive, approved.Status) + assert.True(t, approved.IsActive) +} + +func TestRejectClient_SetsInactiveStatus(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + ownerID := uuid.New().String() + adminID := uuid.New().String() + + resp, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "To Reject", + UserID: ownerID, + CreatedBy: ownerID, + IsAdminCreated: false, + }) + require.NoError(t, err) + + require.NoError(t, svc.RejectClient(context.Background(), resp.ClientID, adminID)) + + rejected, err := svc.GetClient(resp.ClientID) + require.NoError(t, err) + assert.Equal(t, models.ClientStatusInactive, rejected.Status) + assert.False(t, rejected.IsActive) +} + +// ============================================================ +// CountPendingClients +// ============================================================ + +func TestCountPendingClients(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + ownerID := uuid.New().String() + adminID := uuid.New().String() + + // Initially zero pending (seeded default is active) + initial, err := svc.CountPendingClients() + require.NoError(t, err) + + // Add two pending clients + resp1, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "Pending 1", + UserID: ownerID, + CreatedBy: ownerID, + IsAdminCreated: false, + }) + require.NoError(t, err) + _, err = svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "Pending 2", + UserID: ownerID, + CreatedBy: ownerID, + IsAdminCreated: false, + }) + require.NoError(t, err) + + count, err := svc.CountPendingClients() + require.NoError(t, err) + assert.Equal(t, initial+2, count) + + // Approve one → count goes back down + require.NoError(t, svc.ApproveClient(context.Background(), resp1.ClientID, adminID)) + count, err = svc.CountPendingClients() + require.NoError(t, err) + assert.Equal(t, initial+1, count) +} + +// ============================================================ +// ListClientsByUser +// ============================================================ + +func TestListClientsByUser(t *testing.T) { + s := setupTestStore(t) + svc := NewClientService(s, nil) + user1ID := uuid.New().String() + user2ID := uuid.New().String() + + _, err := svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "App A", UserID: user1ID, CreatedBy: user1ID, + }) + require.NoError(t, err) + _, err = svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "App B", UserID: user1ID, CreatedBy: user1ID, + }) + require.NoError(t, err) + _, err = svc.CreateClient(context.Background(), CreateClientRequest{ + ClientName: "Other", UserID: user2ID, CreatedBy: user2ID, + }) + require.NoError(t, err) + + params := store.NewPaginationParams(1, 10, "") + apps, pagination, err := svc.ListClientsByUser(user1ID, params) + require.NoError(t, err) + assert.Len(t, apps, 2) + assert.Equal(t, int64(2), pagination.Total) +} diff --git a/internal/store/pagination.go b/internal/store/pagination.go index f1aa888d..195aa824 100644 --- a/internal/store/pagination.go +++ b/internal/store/pagination.go @@ -4,9 +4,10 @@ import "math" // PaginationParams contains parameters for paginated queries type PaginationParams struct { - Page int // Current page number (1-indexed) - PageSize int // Number of items per page - Search string // Search keyword + Page int // Current page number (1-indexed) + PageSize int // Number of items per page + Search string // Search keyword + StatusFilter string // Optional status filter (e.g. "pending", "active", "inactive") } // PaginationResult contains pagination metadata diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go index 33885c5f..a3a2fa25 100644 --- a/internal/store/sqlite.go +++ b/internal/store/sqlite.go @@ -194,6 +194,7 @@ func (s *Store) seedData(ctx context.Context, cfg *config.Config) error { EnableAuthCodeFlow: true, EnableDeviceFlow: true, IsActive: true, + Status: models.ClientStatusActive, } clientSecret, err := client.GenerateClientSecret(ctx) if err != nil { @@ -332,7 +333,7 @@ func (s *Store) ListClients() ([]models.OAuthApplication, error) { return clients, nil } -// ListClientsPaginated returns paginated OAuth clients with search support +// ListClientsPaginated returns paginated OAuth clients with search and optional status filter support func (s *Store) ListClientsPaginated( params PaginationParams, ) ([]models.OAuthApplication, PaginationResult, error) { @@ -351,6 +352,11 @@ func (s *Store) ListClientsPaginated( ) } + // Apply status filter if provided + if params.StatusFilter != "" { + query = query.Where("status = ?", params.StatusFilter) + } + // Count total records if err := query.Count(&total).Error; err != nil { return nil, PaginationResult{}, err @@ -371,6 +377,50 @@ func (s *Store) ListClientsPaginated( return clients, pagination, nil } +// ListClientsByUserID returns paginated OAuth clients owned by the given user +func (s *Store) ListClientsByUserID( + userID string, + params PaginationParams, +) ([]models.OAuthApplication, PaginationResult, error) { + var clients []models.OAuthApplication + var total int64 + + query := s.db.Model(&models.OAuthApplication{}).Where("user_id = ?", userID) + + if params.Search != "" { + searchPattern := "%" + params.Search + "%" + query = query.Where( + "client_name LIKE ? OR client_id LIKE ? OR description LIKE ?", + searchPattern, searchPattern, searchPattern, + ) + } + + if err := query.Count(&total).Error; err != nil { + return nil, PaginationResult{}, err + } + + pagination := CalculatePagination(total, params.Page, params.PageSize) + offset := (params.Page - 1) * params.PageSize + + if err := query.Order("created_at DESC"). + Limit(params.PageSize). + Offset(offset). + Find(&clients).Error; err != nil { + return nil, PaginationResult{}, err + } + + return clients, pagination, nil +} + +// CountClientsByStatus returns the number of clients with the given status +func (s *Store) CountClientsByStatus(status string) (int64, error) { + var count int64 + err := s.db.Model(&models.OAuthApplication{}). + Where("status = ?", status). + Count(&count).Error + return count, err +} + func (s *Store) GetClientsByIDs(clientIDs []string) (map[string]*models.OAuthApplication, error) { if len(clientIDs) == 0 { return make(map[string]*models.OAuthApplication), nil diff --git a/internal/templates/admin_client_detail.templ b/internal/templates/admin_client_detail.templ index 758558c3..bf2a294c 100644 --- a/internal/templates/admin_client_detail.templ +++ b/internal/templates/admin_client_detail.templ @@ -75,12 +75,18 @@ templ AdminClientDetail(props ClientDetailPageProps) {
⏳ This client is pending approval.
+