mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
feat: add agent oauth consent management UI (#541)
* feat: add agent oauth consent management UI Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a * test: cover agent oauth consent edge paths Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a * fix: route agent oauth consent through rpc Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a * test: cover agent oauth consent rpc on workers Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a * test: cover agent oauth grant-use middleware Agent-Profile: https://agent-kanban.dev/agents/7b0ab18fa695f04a --------- Co-authored-by: Iris Tan <iris-tan@mails.agent-kanban.dev>
This commit is contained in:
committed by
GitHub
parent
00d298627c
commit
88916f4f03
@@ -2029,41 +2029,65 @@ func (e WebDavVerificationStatus) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for GetAgentOAuthConsentContext200JSONResponseBodyScopes.
|
||||
const (
|
||||
GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsCreate GetAgentOAuthConsentContext200JSONResponseBodyScopes = "objects:create"
|
||||
GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsDelete GetAgentOAuthConsentContext200JSONResponseBodyScopes = "objects:delete"
|
||||
GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsRead GetAgentOAuthConsentContext200JSONResponseBodyScopes = "objects:read"
|
||||
GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsUpdate GetAgentOAuthConsentContext200JSONResponseBodyScopes = "objects:update"
|
||||
GetAgentOAuthConsentContext200JSONResponseBodyScopesQuotaRead GetAgentOAuthConsentContext200JSONResponseBodyScopes = "quota:read"
|
||||
GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesCreate GetAgentOAuthConsentContext200JSONResponseBodyScopes = "shares:create"
|
||||
GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesDelete GetAgentOAuthConsentContext200JSONResponseBodyScopes = "shares:delete"
|
||||
GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesRead GetAgentOAuthConsentContext200JSONResponseBodyScopes = "shares:read"
|
||||
GetAgentOAuthConsentContext200JSONResponseBodyScopesStorageUsageRead GetAgentOAuthConsentContext200JSONResponseBodyScopes = "storage-usage:read"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the GetAgentOAuthConsentContext200JSONResponseBodyScopes enum.
|
||||
func (e GetAgentOAuthConsentContext200JSONResponseBodyScopes) Valid() bool {
|
||||
switch e {
|
||||
case GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsCreate:
|
||||
return true
|
||||
case GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsDelete:
|
||||
return true
|
||||
case GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsRead:
|
||||
return true
|
||||
case GetAgentOAuthConsentContext200JSONResponseBodyScopesObjectsUpdate:
|
||||
return true
|
||||
case GetAgentOAuthConsentContext200JSONResponseBodyScopesQuotaRead:
|
||||
return true
|
||||
case GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesCreate:
|
||||
return true
|
||||
case GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesDelete:
|
||||
return true
|
||||
case GetAgentOAuthConsentContext200JSONResponseBodyScopesSharesRead:
|
||||
return true
|
||||
case GetAgentOAuthConsentContext200JSONResponseBodyScopesStorageUsageRead:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for ListAgentOAuthGrants200JSONResponseBodyItemsScopes.
|
||||
const (
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksCancel ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "download-tasks:cancel"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksCreate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "download-tasks:create"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "download-tasks:read"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesImagesUpload ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "images:upload"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsCreate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:create"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsDelete ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:delete"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsPurge ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:purge"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:read"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsUpdate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:update"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesQuotaRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "quota:read"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesCreate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:create"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesDelete ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:delete"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:read"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesStorageUsageRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "storage-usage:read"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsCreate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:create"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsDelete ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:delete"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:read"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsUpdate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "objects:update"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesQuotaRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "quota:read"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesCreate ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:create"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesDelete ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:delete"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesSharesRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "shares:read"
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsScopesStorageUsageRead ListAgentOAuthGrants200JSONResponseBodyItemsScopes = "storage-usage:read"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the ListAgentOAuthGrants200JSONResponseBodyItemsScopes enum.
|
||||
func (e ListAgentOAuthGrants200JSONResponseBodyItemsScopes) Valid() bool {
|
||||
switch e {
|
||||
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksCancel:
|
||||
return true
|
||||
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksCreate:
|
||||
return true
|
||||
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesDownloadTasksRead:
|
||||
return true
|
||||
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesImagesUpload:
|
||||
return true
|
||||
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsCreate:
|
||||
return true
|
||||
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsDelete:
|
||||
return true
|
||||
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsPurge:
|
||||
return true
|
||||
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsRead:
|
||||
return true
|
||||
case ListAgentOAuthGrants200JSONResponseBodyItemsScopesObjectsUpdate:
|
||||
@@ -2083,6 +2107,21 @@ func (e ListAgentOAuthGrants200JSONResponseBodyItemsScopes) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for ListAgentOAuthGrants200JSONResponseBodyItemsStatus.
|
||||
const (
|
||||
ListAgentOAuthGrants200JSONResponseBodyItemsStatusActive ListAgentOAuthGrants200JSONResponseBodyItemsStatus = "active"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the ListAgentOAuthGrants200JSONResponseBodyItemsStatus enum.
|
||||
func (e ListAgentOAuthGrants200JSONResponseBodyItemsStatus) Valid() bool {
|
||||
switch e {
|
||||
case ListAgentOAuthGrants200JSONResponseBodyItemsStatusActive:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for ChangeEmail200JSONResponseBodyMessage.
|
||||
const (
|
||||
ChangeEmail200JSONResponseBodyMessageEmailUpdated ChangeEmail200JSONResponseBodyMessage = "Email updated"
|
||||
@@ -3660,37 +3699,37 @@ func (e RotateWorkspaceAgentApiKeyJSONBodyScopes) Valid() bool {
|
||||
|
||||
// Defines values for RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes.
|
||||
const (
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsCreate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:create"
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsDelete RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:delete"
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:read"
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsUpdate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:update"
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesQuotaRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "quota:read"
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesCreate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:create"
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesDelete RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:delete"
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:read"
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesStorageUsageRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "storage-usage:read"
|
||||
ObjectsCreate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:create"
|
||||
ObjectsDelete RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:delete"
|
||||
ObjectsRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:read"
|
||||
ObjectsUpdate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "objects:update"
|
||||
QuotaRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "quota:read"
|
||||
SharesCreate RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:create"
|
||||
SharesDelete RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:delete"
|
||||
SharesRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "shares:read"
|
||||
StorageUsageRead RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes = "storage-usage:read"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes enum.
|
||||
func (e RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes) Valid() bool {
|
||||
switch e {
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsCreate:
|
||||
case ObjectsCreate:
|
||||
return true
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsDelete:
|
||||
case ObjectsDelete:
|
||||
return true
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsRead:
|
||||
case ObjectsRead:
|
||||
return true
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesObjectsUpdate:
|
||||
case ObjectsUpdate:
|
||||
return true
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesQuotaRead:
|
||||
case QuotaRead:
|
||||
return true
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesCreate:
|
||||
case SharesCreate:
|
||||
return true
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesDelete:
|
||||
case SharesDelete:
|
||||
return true
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesSharesRead:
|
||||
case SharesRead:
|
||||
return true
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopesStorageUsageRead:
|
||||
case StorageUsageRead:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -3699,22 +3738,22 @@ func (e RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes) Valid() bool {
|
||||
|
||||
// Defines values for RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus.
|
||||
const (
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusActive RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "active"
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusExpired RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "expired"
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusInaccessible RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "inaccessible"
|
||||
RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusRevoked RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "revoked"
|
||||
Active RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "active"
|
||||
Expired RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "expired"
|
||||
Inaccessible RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "inaccessible"
|
||||
Revoked RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus = "revoked"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus enum.
|
||||
func (e RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus) Valid() bool {
|
||||
switch e {
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusActive:
|
||||
case Active:
|
||||
return true
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusExpired:
|
||||
case Expired:
|
||||
return true
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusInaccessible:
|
||||
case Inaccessible:
|
||||
return true
|
||||
case RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatusRevoked:
|
||||
case Revoked:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -5989,9 +6028,26 @@ type User struct {
|
||||
// WebDavVerificationStatus defines model for WebDavVerificationStatus.
|
||||
type WebDavVerificationStatus string
|
||||
|
||||
// GetAgentOAuthConsentContextParams defines parameters for GetAgentOAuthConsentContext.
|
||||
type GetAgentOAuthConsentContextParams struct {
|
||||
OauthQuery string `form:"oauthQuery" json:"oauthQuery"`
|
||||
}
|
||||
|
||||
// GetAgentOAuthConsentContext200JSONResponseBodyScopes defines parameters for GetAgentOAuthConsentContext.
|
||||
type GetAgentOAuthConsentContext200JSONResponseBodyScopes string
|
||||
|
||||
// SubmitAgentOAuthConsentJSONBody defines parameters for SubmitAgentOAuthConsent.
|
||||
type SubmitAgentOAuthConsentJSONBody struct {
|
||||
Accept bool `json:"accept"`
|
||||
OauthQuery string `json:"oauthQuery"`
|
||||
}
|
||||
|
||||
// ListAgentOAuthGrants200JSONResponseBodyItemsScopes defines parameters for ListAgentOAuthGrants.
|
||||
type ListAgentOAuthGrants200JSONResponseBodyItemsScopes string
|
||||
|
||||
// ListAgentOAuthGrants200JSONResponseBodyItemsStatus defines parameters for ListAgentOAuthGrants.
|
||||
type ListAgentOAuthGrants200JSONResponseBodyItemsStatus string
|
||||
|
||||
// BanUserJSONBody defines parameters for BanUser.
|
||||
type BanUserJSONBody struct {
|
||||
// BanExpiresIn The number of seconds until the ban expires
|
||||
@@ -7798,6 +7854,9 @@ type RotateWorkspaceAgentApiKey201JSONResponseBodyItemScopes string
|
||||
// RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus defines parameters for RotateWorkspaceAgentApiKey.
|
||||
type RotateWorkspaceAgentApiKey201JSONResponseBodyItemStatus string
|
||||
|
||||
// SubmitAgentOAuthConsentJSONRequestBody defines body for SubmitAgentOAuthConsent for application/json ContentType.
|
||||
type SubmitAgentOAuthConsentJSONRequestBody SubmitAgentOAuthConsentJSONBody
|
||||
|
||||
// BanUserJSONRequestBody defines body for BanUser for application/json ContentType.
|
||||
type BanUserJSONRequestBody BanUserJSONBody
|
||||
|
||||
@@ -8842,6 +8901,14 @@ func WithRequestEditorFn(fn RequestEditorFn) ClientOption {
|
||||
|
||||
// The interface specification for the client above.
|
||||
type ClientInterface interface {
|
||||
// GetAgentOAuthConsentContext request
|
||||
GetAgentOAuthConsentContext(ctx context.Context, params *GetAgentOAuthConsentContextParams, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// SubmitAgentOAuthConsentWithBody request with any body
|
||||
SubmitAgentOAuthConsentWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
SubmitAgentOAuthConsent(ctx context.Context, body SubmitAgentOAuthConsentJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
// ListAgentOAuthGrants request
|
||||
ListAgentOAuthGrants(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
|
||||
@@ -9873,6 +9940,42 @@ type ClientInterface interface {
|
||||
RotateWorkspaceAgentApiKey(ctx context.Context, orgId string, keyId string, body RotateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error)
|
||||
}
|
||||
|
||||
func (c *Client) GetAgentOAuthConsentContext(ctx context.Context, params *GetAgentOAuthConsentContextParams, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewGetAgentOAuthConsentContextRequest(c.Server, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) SubmitAgentOAuthConsentWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewSubmitAgentOAuthConsentRequestWithBody(c.Server, contentType, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) SubmitAgentOAuthConsent(ctx context.Context, body SubmitAgentOAuthConsentJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewSubmitAgentOAuthConsentRequest(c.Server, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req = req.WithContext(ctx)
|
||||
if err := c.applyEditors(ctx, req, reqEditors); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
func (c *Client) ListAgentOAuthGrants(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) {
|
||||
req, err := NewListAgentOAuthGrantsRequest(c.Server)
|
||||
if err != nil {
|
||||
@@ -14493,6 +14596,96 @@ func (c *Client) RotateWorkspaceAgentApiKey(ctx context.Context, orgId string, k
|
||||
return c.Client.Do(req)
|
||||
}
|
||||
|
||||
// NewGetAgentOAuthConsentContextRequest generates requests for GetAgentOAuthConsentContext
|
||||
func NewGetAgentOAuthConsentContextRequest(server string, params *GetAgentOAuthConsentContextParams) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/api/agent-oauth-consent")
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if params != nil {
|
||||
// queryValues collects non-styled parameters (passthrough, JSON)
|
||||
// that are safe to round-trip through url.Values.Encode().
|
||||
queryValues := queryURL.Query()
|
||||
// rawQueryFragments collects pre-encoded query fragments from
|
||||
// styled parameters, preserving literal commas as delimiters
|
||||
// per the OpenAPI spec (e.g. "color=blue,black,brown").
|
||||
var rawQueryFragments []string
|
||||
|
||||
if queryFrag, err := runtime.StyleParamWithOptions("form", true, "oauthQuery", params.OauthQuery, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil {
|
||||
return nil, err
|
||||
} else {
|
||||
for _, qp := range strings.Split(queryFrag, "&") {
|
||||
rawQueryFragments = append(rawQueryFragments, qp)
|
||||
}
|
||||
}
|
||||
|
||||
if encoded := queryValues.Encode(); encoded != "" {
|
||||
rawQueryFragments = append(rawQueryFragments, encoded)
|
||||
}
|
||||
queryURL.RawQuery = strings.Join(rawQueryFragments, "&")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewSubmitAgentOAuthConsentRequest calls the generic SubmitAgentOAuthConsent builder with application/json body
|
||||
func NewSubmitAgentOAuthConsentRequest(server string, body SubmitAgentOAuthConsentJSONRequestBody) (*http.Request, error) {
|
||||
var bodyReader io.Reader
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bodyReader = bytes.NewReader(buf)
|
||||
return NewSubmitAgentOAuthConsentRequestWithBody(server, "application/json", bodyReader)
|
||||
}
|
||||
|
||||
// NewSubmitAgentOAuthConsentRequestWithBody generates requests for SubmitAgentOAuthConsent with any type of body
|
||||
func NewSubmitAgentOAuthConsentRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) {
|
||||
var err error
|
||||
|
||||
serverURL, err := url.Parse(server)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
operationPath := fmt.Sprintf("/api/agent-oauth-consent")
|
||||
if operationPath[0] == '/' {
|
||||
operationPath = "." + operationPath
|
||||
}
|
||||
|
||||
queryURL, err := serverURL.Parse(operationPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, queryURL.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req.Header.Add("Content-Type", contentType)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// NewListAgentOAuthGrantsRequest generates requests for ListAgentOAuthGrants
|
||||
func NewListAgentOAuthGrantsRequest(server string) (*http.Request, error) {
|
||||
var err error
|
||||
@@ -25881,6 +26074,14 @@ func WithBaseURL(baseURL string) ClientOption {
|
||||
|
||||
// ClientWithResponsesInterface is the interface specification for the client with responses above.
|
||||
type ClientWithResponsesInterface interface {
|
||||
// GetAgentOAuthConsentContextWithResponse request
|
||||
GetAgentOAuthConsentContextWithResponse(ctx context.Context, params *GetAgentOAuthConsentContextParams, reqEditors ...RequestEditorFn) (*GetAgentOAuthConsentContextResponse, error)
|
||||
|
||||
// SubmitAgentOAuthConsentWithBodyWithResponse request with any body
|
||||
SubmitAgentOAuthConsentWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SubmitAgentOAuthConsentResponse, error)
|
||||
|
||||
SubmitAgentOAuthConsentWithResponse(ctx context.Context, body SubmitAgentOAuthConsentJSONRequestBody, reqEditors ...RequestEditorFn) (*SubmitAgentOAuthConsentResponse, error)
|
||||
|
||||
// ListAgentOAuthGrantsWithResponse request
|
||||
ListAgentOAuthGrantsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListAgentOAuthGrantsResponse, error)
|
||||
|
||||
@@ -26912,18 +27113,102 @@ type ClientWithResponsesInterface interface {
|
||||
RotateWorkspaceAgentApiKeyWithResponse(ctx context.Context, orgId string, keyId string, body RotateWorkspaceAgentApiKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*RotateWorkspaceAgentApiKeyResponse, error)
|
||||
}
|
||||
|
||||
type GetAgentOAuthConsentContextResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *struct {
|
||||
ClientId string `json:"clientId"`
|
||||
ClientName string `json:"clientName"`
|
||||
GrantLifetime struct {
|
||||
AccessTokenSeconds *int `json:"accessTokenSeconds,omitempty"`
|
||||
RefreshTokenSeconds *int `json:"refreshTokenSeconds,omitempty"`
|
||||
} `json:"grantLifetime"`
|
||||
InstanceOrigin string `json:"instanceOrigin"`
|
||||
RedirectUri string `json:"redirectUri"`
|
||||
Scopes []GetAgentOAuthConsentContext200JSONResponseBodyScopes `json:"scopes"`
|
||||
StandardScopes []string `json:"standardScopes"`
|
||||
Workspace struct {
|
||||
Id string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
} `json:"workspace"`
|
||||
}
|
||||
JSON400 *Error
|
||||
JSON403 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r GetAgentOAuthConsentContextResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r GetAgentOAuthConsentContextResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
|
||||
func (r GetAgentOAuthConsentContextResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type SubmitAgentOAuthConsentResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
JSON400 *Error
|
||||
JSON403 *Error
|
||||
}
|
||||
|
||||
// Status returns HTTPResponse.Status
|
||||
func (r SubmitAgentOAuthConsentResponse) Status() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Status
|
||||
}
|
||||
return http.StatusText(0)
|
||||
}
|
||||
|
||||
// StatusCode returns HTTPResponse.StatusCode
|
||||
func (r SubmitAgentOAuthConsentResponse) StatusCode() int {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.StatusCode
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers
|
||||
func (r SubmitAgentOAuthConsentResponse) ContentType() string {
|
||||
if r.HTTPResponse != nil {
|
||||
return r.HTTPResponse.Header.Get("Content-Type")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type ListAgentOAuthGrantsResponse struct {
|
||||
Body []byte
|
||||
HTTPResponse *http.Response
|
||||
JSON200 *struct {
|
||||
Items []struct {
|
||||
ClientId string `json:"clientId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Id string `json:"id"`
|
||||
OrgId string `json:"orgId"`
|
||||
Scopes []ListAgentOAuthGrants200JSONResponseBodyItemsScopes `json:"scopes"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
UserId string `json:"userId"`
|
||||
ClientId string `json:"clientId"`
|
||||
ClientName *string `json:"clientName,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Id string `json:"id"`
|
||||
LastUsedAt *string `json:"lastUsedAt"`
|
||||
OrgId string `json:"orgId"`
|
||||
Scopes []ListAgentOAuthGrants200JSONResponseBodyItemsScopes `json:"scopes"`
|
||||
Status ListAgentOAuthGrants200JSONResponseBodyItemsStatus `json:"status"`
|
||||
UserId string `json:"userId"`
|
||||
WorkspaceName *string `json:"workspaceName"`
|
||||
} `json:"items"`
|
||||
}
|
||||
}
|
||||
@@ -37771,6 +38056,32 @@ func (r RotateWorkspaceAgentApiKeyResponse) ContentType() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetAgentOAuthConsentContextWithResponse request returning *GetAgentOAuthConsentContextResponse
|
||||
func (c *ClientWithResponses) GetAgentOAuthConsentContextWithResponse(ctx context.Context, params *GetAgentOAuthConsentContextParams, reqEditors ...RequestEditorFn) (*GetAgentOAuthConsentContextResponse, error) {
|
||||
rsp, err := c.GetAgentOAuthConsentContext(ctx, params, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseGetAgentOAuthConsentContextResponse(rsp)
|
||||
}
|
||||
|
||||
// SubmitAgentOAuthConsentWithBodyWithResponse request with arbitrary body returning *SubmitAgentOAuthConsentResponse
|
||||
func (c *ClientWithResponses) SubmitAgentOAuthConsentWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SubmitAgentOAuthConsentResponse, error) {
|
||||
rsp, err := c.SubmitAgentOAuthConsentWithBody(ctx, contentType, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseSubmitAgentOAuthConsentResponse(rsp)
|
||||
}
|
||||
|
||||
func (c *ClientWithResponses) SubmitAgentOAuthConsentWithResponse(ctx context.Context, body SubmitAgentOAuthConsentJSONRequestBody, reqEditors ...RequestEditorFn) (*SubmitAgentOAuthConsentResponse, error) {
|
||||
rsp, err := c.SubmitAgentOAuthConsent(ctx, body, reqEditors...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ParseSubmitAgentOAuthConsentResponse(rsp)
|
||||
}
|
||||
|
||||
// ListAgentOAuthGrantsWithResponse request returning *ListAgentOAuthGrantsResponse
|
||||
func (c *ClientWithResponses) ListAgentOAuthGrantsWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListAgentOAuthGrantsResponse, error) {
|
||||
rsp, err := c.ListAgentOAuthGrants(ctx, reqEditors...)
|
||||
@@ -41111,6 +41422,103 @@ func (c *ClientWithResponses) RotateWorkspaceAgentApiKeyWithResponse(ctx context
|
||||
return ParseRotateWorkspaceAgentApiKeyResponse(rsp)
|
||||
}
|
||||
|
||||
// ParseGetAgentOAuthConsentContextResponse parses an HTTP response from a GetAgentOAuthConsentContextWithResponse call
|
||||
func ParseGetAgentOAuthConsentContextResponse(rsp *http.Response) (*GetAgentOAuthConsentContextResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &GetAgentOAuthConsentContextResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest struct {
|
||||
ClientId string `json:"clientId"`
|
||||
ClientName string `json:"clientName"`
|
||||
GrantLifetime struct {
|
||||
AccessTokenSeconds *int `json:"accessTokenSeconds,omitempty"`
|
||||
RefreshTokenSeconds *int `json:"refreshTokenSeconds,omitempty"`
|
||||
} `json:"grantLifetime"`
|
||||
InstanceOrigin string `json:"instanceOrigin"`
|
||||
RedirectUri string `json:"redirectUri"`
|
||||
Scopes []GetAgentOAuthConsentContext200JSONResponseBodyScopes `json:"scopes"`
|
||||
StandardScopes []string `json:"standardScopes"`
|
||||
Workspace struct {
|
||||
Id string `json:"id"`
|
||||
Name *string `json:"name"`
|
||||
} `json:"workspace"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON200 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON400 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON403 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseSubmitAgentOAuthConsentResponse parses an HTTP response from a SubmitAgentOAuthConsentWithResponse call
|
||||
func ParseSubmitAgentOAuthConsentResponse(rsp *http.Response) (*SubmitAgentOAuthConsentResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
defer func() { _ = rsp.Body.Close() }()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &SubmitAgentOAuthConsentResponse{
|
||||
Body: bodyBytes,
|
||||
HTTPResponse: rsp,
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON200 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON400 = &dest
|
||||
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403:
|
||||
var dest Error
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
response.JSON403 = &dest
|
||||
|
||||
}
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// ParseListAgentOAuthGrantsResponse parses an HTTP response from a ListAgentOAuthGrantsWithResponse call
|
||||
func ParseListAgentOAuthGrantsResponse(rsp *http.Response) (*ListAgentOAuthGrantsResponse, error) {
|
||||
bodyBytes, err := io.ReadAll(rsp.Body)
|
||||
@@ -41128,13 +41536,16 @@ func ParseListAgentOAuthGrantsResponse(rsp *http.Response) (*ListAgentOAuthGrant
|
||||
case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200:
|
||||
var dest struct {
|
||||
Items []struct {
|
||||
ClientId string `json:"clientId"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Id string `json:"id"`
|
||||
OrgId string `json:"orgId"`
|
||||
Scopes []ListAgentOAuthGrants200JSONResponseBodyItemsScopes `json:"scopes"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
UserId string `json:"userId"`
|
||||
ClientId string `json:"clientId"`
|
||||
ClientName *string `json:"clientName,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Id string `json:"id"`
|
||||
LastUsedAt *string `json:"lastUsedAt"`
|
||||
OrgId string `json:"orgId"`
|
||||
Scopes []ListAgentOAuthGrants200JSONResponseBodyItemsScopes `json:"scopes"`
|
||||
Status ListAgentOAuthGrants200JSONResponseBodyItemsStatus `json:"status"`
|
||||
UserId string `json:"userId"`
|
||||
WorkspaceName *string `json:"workspaceName"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(bodyBytes, &dest); err != nil {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { signUpAndGoToFiles } from './helpers'
|
||||
|
||||
const oauthQuery =
|
||||
'client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback&response_type=code&scope=openid%20offline_access%20objects%3Aread%20shares%3Acreate%20quota%3Aread'
|
||||
|
||||
test.describe('Agent Access OAuth UI', () => {
|
||||
test('renders consent details and submits full approval @desktop', async ({ page }) => {
|
||||
await signUpAndGoToFiles(page)
|
||||
|
||||
await page.route('**/api/agent-oauth-consent?*', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
clientId: 'zpan-agent',
|
||||
clientName: 'ZPan Agent',
|
||||
instanceOrigin: 'http://localhost:5185',
|
||||
workspace: { id: 'org-e2e', name: 'Personal' },
|
||||
scopes: ['objects:read', 'shares:create', 'quota:read'],
|
||||
standardScopes: ['openid', 'offline_access'],
|
||||
redirectUri: 'http://127.0.0.1:8484/callback',
|
||||
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/agent-oauth-consent', async (route) => {
|
||||
if (route.request().method() !== 'POST') return route.fallback()
|
||||
expect(route.request().method()).toBe('POST')
|
||||
const body = route.request().postDataJSON() as { accept: boolean; oauthQuery?: string; scope?: string }
|
||||
expect(body).toEqual({ accept: true, oauthQuery })
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ url: 'http://127.0.0.1:8484/callback?code=e2e-code' }),
|
||||
})
|
||||
})
|
||||
await page.route('http://127.0.0.1:8484/callback?code=e2e-code', async (route) => {
|
||||
await route.fulfill({ contentType: 'text/html', body: '<main>Returned to Restish</main>' })
|
||||
})
|
||||
|
||||
await page.goto(`/settings/agent-access?${oauthQuery}`)
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Authorize ZPan Agent' })).toBeVisible()
|
||||
await expect(page.getByText('http://localhost:5185')).toBeVisible()
|
||||
await expect(page.getByText('http://127.0.0.1:8484/callback')).toBeVisible()
|
||||
await expect(page.getByText('Files: read objects')).toBeVisible()
|
||||
await expect(page.getByText('Shares: create shares')).toBeVisible()
|
||||
await expect(page.getByText('Quota: read workspace quota')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Approve Access' }).click()
|
||||
await expect(page).toHaveURL(/127\.0\.0\.1:8484\/callback\?code=e2e-code/, { timeout: 10000 })
|
||||
await expect(page.getByText('Returned to Restish')).toBeVisible()
|
||||
})
|
||||
|
||||
test('lists and revokes delegated grants in settings @desktop', async ({ page }) => {
|
||||
await signUpAndGoToFiles(page)
|
||||
let revoked = false
|
||||
|
||||
await page.route('**/api/agent-oauth-grants', async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: revoked
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 'grant-e2e',
|
||||
clientId: 'zpan-agent',
|
||||
clientName: 'ZPan Agent',
|
||||
userId: 'user-e2e',
|
||||
orgId: 'org-e2e',
|
||||
workspaceName: 'Personal',
|
||||
scopes: ['objects:read', 'shares:create'],
|
||||
createdAt: '2026-07-29T12:00:00.000Z',
|
||||
lastUsedAt: null,
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/agent-oauth-grants/grant-e2e', async (route) => {
|
||||
expect(route.request().method()).toBe('DELETE')
|
||||
revoked = true
|
||||
await route.fulfill({ status: 204 })
|
||||
})
|
||||
|
||||
await page.goto('/settings/agent-access')
|
||||
|
||||
await expect(page.getByText('Delegated OAuth Grants')).toBeVisible()
|
||||
await expect(page.getByRole('cell', { name: 'ZPan Agent' })).toBeVisible()
|
||||
await expect(page.getByText('Shares: create shares')).toBeVisible()
|
||||
|
||||
const revokeButtons = page.getByRole('button', { name: 'Revoke' })
|
||||
await revokeButtons.last().click()
|
||||
await expect(page.getByRole('dialog', { name: 'Revoke OAuth Grant' })).toBeVisible()
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Revoke' }).click()
|
||||
await expect(page.getByText('No delegated OAuth grants yet')).toBeVisible()
|
||||
})
|
||||
|
||||
test('keeps consent and delegated grants usable on narrow screens @mobile', async ({ page }) => {
|
||||
await signUpAndGoToFiles(page)
|
||||
|
||||
await page.route('**/api/agent-oauth-consent?*', async (route) => {
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
clientId: 'zpan-agent',
|
||||
clientName: 'ZPan Agent',
|
||||
instanceOrigin: 'http://localhost:5185',
|
||||
workspace: { id: 'org-e2e', name: 'Personal' },
|
||||
scopes: ['objects:read', 'shares:create', 'quota:read'],
|
||||
standardScopes: ['openid', 'offline_access'],
|
||||
redirectUri: 'http://127.0.0.1:8484/callback',
|
||||
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
|
||||
}),
|
||||
})
|
||||
})
|
||||
await page.route('**/api/agent-oauth-grants', async (route) => {
|
||||
if (route.request().method() !== 'GET') return route.fallback()
|
||||
await route.fulfill({
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: 'grant-mobile',
|
||||
clientId: 'zpan-agent',
|
||||
clientName: 'ZPan Agent',
|
||||
userId: 'user-e2e',
|
||||
orgId: 'org-e2e',
|
||||
workspaceName: 'Personal',
|
||||
scopes: ['objects:read', 'shares:create', 'quota:read'],
|
||||
createdAt: '2026-07-29T12:00:00.000Z',
|
||||
lastUsedAt: '2026-07-29T12:30:00.000Z',
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
await page.goto(`/settings/agent-access?${oauthQuery}`)
|
||||
await expect(page.getByRole('heading', { name: 'Authorize ZPan Agent' })).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Approve Access' })).toBeVisible()
|
||||
await expect(page.getByText('Files: read objects')).toBeVisible()
|
||||
await expect(page.getByText('Shares: create shares')).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1))
|
||||
.toBe(true)
|
||||
|
||||
await page.goto('/settings/agent-access')
|
||||
await expect(page.getByText('Delegated OAuth Grants')).toBeVisible()
|
||||
await expect(page.getByRole('cell', { name: 'ZPan Agent' })).toBeVisible()
|
||||
const grantsTableContainer = page.locator('[data-slot="table-container"]').last()
|
||||
await expect(grantsTableContainer).toBeVisible()
|
||||
await expect
|
||||
.poll(async () =>
|
||||
grantsTableContainer.evaluate((node) => (node as HTMLElement).scrollWidth > (node as HTMLElement).clientWidth),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect
|
||||
.poll(async () => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1))
|
||||
.toBe(true)
|
||||
await grantsTableContainer.evaluate((node) => {
|
||||
node.scrollLeft = node.scrollWidth
|
||||
})
|
||||
await expect(page.getByRole('button', { name: 'Revoke' }).last()).toBeVisible()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `oauthConsent` ADD `last_used_at` integer;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -568,6 +568,13 @@
|
||||
"when": 1785337905649,
|
||||
"tag": "0081_spotty_boomerang",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 82,
|
||||
"version": "6",
|
||||
"when": 1785351402721,
|
||||
"tag": "0082_agent_oauth_consent_last_used_at",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -124,6 +124,7 @@ describe('Agent OAuth gateway', () => {
|
||||
referenceId: orgId,
|
||||
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
|
||||
createdAt: new Date('2026-07-29T12:00:00.000Z'),
|
||||
lastUsedAt: new Date('2026-07-29T12:20:00.000Z'),
|
||||
updatedAt: new Date('2026-07-29T12:01:00.000Z'),
|
||||
},
|
||||
{
|
||||
@@ -133,9 +134,32 @@ describe('Agent OAuth gateway', () => {
|
||||
referenceId: null,
|
||||
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
|
||||
createdAt: new Date('2026-07-29T12:02:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
updatedAt: new Date('2026-07-29T12:03:00.000Z'),
|
||||
},
|
||||
])
|
||||
await db.insert(authSchema.oauthAccessToken).values([
|
||||
{
|
||||
id: 'access-older',
|
||||
token: 'hashed-access-older',
|
||||
clientId: AGENT_OAUTH_CLIENT_ID,
|
||||
userId,
|
||||
referenceId: orgId,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date('2026-07-29T12:05:00.000Z'),
|
||||
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
|
||||
},
|
||||
{
|
||||
id: 'access-newer',
|
||||
token: 'hashed-access-newer',
|
||||
clientId: AGENT_OAUTH_CLIENT_ID,
|
||||
userId,
|
||||
referenceId: orgId,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date('2026-07-29T12:10:00.000Z'),
|
||||
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
|
||||
},
|
||||
])
|
||||
|
||||
await expect(createAgentOAuthGateway().listGrants(db, userId)).resolves.toEqual([
|
||||
{
|
||||
@@ -145,11 +169,51 @@ describe('Agent OAuth gateway', () => {
|
||||
orgId,
|
||||
scopes: [AuthorizationScope.OBJECTS_READ],
|
||||
createdAt: '2026-07-29T12:00:00.000Z',
|
||||
updatedAt: '2026-07-29T12:01:00.000Z',
|
||||
lastUsedAt: '2026-07-29T12:20:00.000Z',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('records actual delegated grant use without treating token issuance as use', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const gateway = createAgentOAuthGateway()
|
||||
const userId = 'oauth-user'
|
||||
const orgId = 'oauth-org'
|
||||
await insertUserAndOrg(db, userId, orgId)
|
||||
await db.insert(authSchema.oauthConsent).values({
|
||||
id: 'grant-1',
|
||||
clientId: AGENT_OAUTH_CLIENT_ID,
|
||||
userId,
|
||||
referenceId: orgId,
|
||||
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
|
||||
createdAt: new Date('2026-07-29T12:00:00.000Z'),
|
||||
updatedAt: new Date('2026-07-29T12:01:00.000Z'),
|
||||
})
|
||||
await db.insert(authSchema.oauthAccessToken).values({
|
||||
id: 'access-1',
|
||||
token: 'hashed-access',
|
||||
clientId: AGENT_OAUTH_CLIENT_ID,
|
||||
userId,
|
||||
referenceId: orgId,
|
||||
expiresAt: new Date(Date.now() + 60_000),
|
||||
createdAt: new Date('2026-07-29T12:10:00.000Z'),
|
||||
scopes: JSON.stringify([AuthorizationScope.OBJECTS_READ]),
|
||||
})
|
||||
|
||||
await expect(gateway.listGrants(db, userId)).resolves.toMatchObject([{ id: 'grant-1', lastUsedAt: null }])
|
||||
|
||||
await gateway.recordGrantUse(db, {
|
||||
grantId: 'grant-1',
|
||||
userId,
|
||||
orgId,
|
||||
now: new Date('2026-07-29T12:30:00.000Z'),
|
||||
})
|
||||
|
||||
await expect(gateway.listGrants(db, userId)).resolves.toMatchObject([
|
||||
{ id: 'grant-1', lastUsedAt: '2026-07-29T12:30:00.000Z' },
|
||||
])
|
||||
})
|
||||
|
||||
it('revokes only the managed client grant for the selected workspace', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = 'oauth-user'
|
||||
|
||||
@@ -125,7 +125,7 @@ export function createAgentOAuthGateway(): AgentOAuthGateway {
|
||||
orgId: oauthConsent.referenceId,
|
||||
scopes: oauthConsent.scopes,
|
||||
createdAt: oauthConsent.createdAt,
|
||||
updatedAt: oauthConsent.updatedAt,
|
||||
lastUsedAt: oauthConsent.lastUsedAt,
|
||||
})
|
||||
.from(oauthConsent)
|
||||
.where(and(eq(oauthConsent.userId, userId), eq(oauthConsent.clientId, AGENT_OAUTH_CLIENT_ID)))
|
||||
@@ -139,12 +139,26 @@ export function createAgentOAuthGateway(): AgentOAuthGateway {
|
||||
orgId: row.orgId,
|
||||
scopes: parseScopes(row.scopes).filter(isAuthorizationScope),
|
||||
createdAt: toIso(row.createdAt),
|
||||
updatedAt: toIso(row.updatedAt),
|
||||
lastUsedAt: row.lastUsedAt ? toIso(row.lastUsedAt) : null,
|
||||
},
|
||||
]
|
||||
})
|
||||
},
|
||||
|
||||
async recordGrantUse(db, input) {
|
||||
await db
|
||||
.update(oauthConsent)
|
||||
.set({ lastUsedAt: input.now })
|
||||
.where(
|
||||
and(
|
||||
eq(oauthConsent.id, input.grantId),
|
||||
eq(oauthConsent.userId, input.userId),
|
||||
eq(oauthConsent.referenceId, input.orgId),
|
||||
eq(oauthConsent.clientId, AGENT_OAUTH_CLIENT_ID),
|
||||
),
|
||||
)
|
||||
},
|
||||
|
||||
async revokeGrant(db, input) {
|
||||
const grants = await db
|
||||
.select({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { isPersonalOrgLike } from '@shared/org-slugs'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { and, eq, inArray } from 'drizzle-orm'
|
||||
import { member, organization } from '../../db/auth-schema'
|
||||
import type { Database } from '../../platform/interface'
|
||||
import type { OrgRepo } from '../../usecases/ports'
|
||||
@@ -29,6 +29,16 @@ export function createOrgRepo(db: Database): OrgRepo {
|
||||
return rows[0]?.role ?? null
|
||||
}
|
||||
|
||||
async function getOrgNames(orgIds: string[]): Promise<Map<string, string>> {
|
||||
const unique = [...new Set(orgIds)].filter(Boolean)
|
||||
if (unique.length === 0) return new Map()
|
||||
const rows = await db
|
||||
.select({ id: organization.id, name: organization.name })
|
||||
.from(organization)
|
||||
.where(inArray(organization.id, unique))
|
||||
return new Map(rows.map((row) => [row.id, row.name]))
|
||||
}
|
||||
|
||||
async function isPersonalOrg(orgId: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ slug: organization.slug, metadata: organization.metadata })
|
||||
@@ -61,5 +71,5 @@ export function createOrgRepo(db: Database): OrgRepo {
|
||||
return orgId === (await findPersonalOrg(userId))
|
||||
}
|
||||
|
||||
return { findPersonalOrg, getMemberRole, canReadOrg, canWriteToOrg, canManageAgentAccess, isPersonalOrg }
|
||||
return { findPersonalOrg, getMemberRole, getOrgNames, canReadOrg, canWriteToOrg, canManageAgentAccess, isPersonalOrg }
|
||||
}
|
||||
|
||||
@@ -537,3 +537,4 @@ export type AdminOverviewRoute = typeof adminOverview
|
||||
export type AdminStatsRoute = typeof adminStats
|
||||
export type StorageUsageRoute = typeof storageUsage
|
||||
export type AgentApiKeysRoute = typeof agentApiKeys
|
||||
export type AgentOAuthGrantsRoute = typeof agentOAuthGrants
|
||||
|
||||
@@ -12,6 +12,7 @@ function createGateway(): AgentOAuthGateway {
|
||||
assertLiveGrant: vi.fn(),
|
||||
verifyAccessToken: vi.fn(),
|
||||
listGrants: vi.fn(),
|
||||
recordGrantUse: vi.fn(),
|
||||
revokeGrant: vi.fn(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,8 @@ describe('Agent OAuth tables', () => {
|
||||
|
||||
expect(oauthConsent.referenceId.name).toBe('reference_id')
|
||||
expect(oauthConsent.scopes.notNull).toBe(true)
|
||||
expect(oauthConsent.lastUsedAt.name).toBe('last_used_at')
|
||||
expect(oauthConsent.lastUsedAt.notNull).toBe(false)
|
||||
expect(foreignKeys).toHaveLength(2)
|
||||
expect(oauthConsent.updatedAt.onUpdateFn?.()).toBeInstanceOf(Date)
|
||||
expect(foreignKeys.map((foreignKey) => foreignKey.reference().foreignColumns[0].name)).toEqual(['client_id', 'id'])
|
||||
|
||||
@@ -327,6 +327,7 @@ export const oauthConsent = sqliteTable(
|
||||
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.notNull(),
|
||||
lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
||||
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
|
||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { AGENT_OAUTH_CLIENT_ID } from '@shared/agent-oauth'
|
||||
import {
|
||||
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
|
||||
AGENT_OAUTH_CLIENT_ID,
|
||||
AGENT_OAUTH_CLIENT_NAME,
|
||||
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
|
||||
} from '@shared/agent-oauth'
|
||||
import { AuthorizationScope } from '@shared/authorization'
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import * as authSchema from '../db/auth-schema.js'
|
||||
import { authedHeaders, createTestApp } from '../test/setup.js'
|
||||
@@ -71,6 +76,88 @@ async function insertGrant(
|
||||
}
|
||||
|
||||
describe('Agent OAuth grants API integration', () => {
|
||||
it('returns server-owned Agent OAuth consent context for the active workspace', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app, 'agent-consent@example.com')
|
||||
const { orgId } = await getUserAndPersonalOrg(db, 'agent-consent@example.com')
|
||||
const oauthQuery = new URLSearchParams({
|
||||
client_id: AGENT_OAUTH_CLIENT_ID,
|
||||
redirect_uri: 'http://127.0.0.1:8484/callback',
|
||||
response_type: 'code',
|
||||
scope: `${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ} openid offline_access`,
|
||||
}).toString()
|
||||
|
||||
const res = await app.request(`/api/agent-oauth-consent?oauthQuery=${encodeURIComponent(oauthQuery)}`, { headers })
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
await expect(res.json()).resolves.toEqual({
|
||||
clientId: AGENT_OAUTH_CLIENT_ID,
|
||||
clientName: AGENT_OAUTH_CLIENT_NAME,
|
||||
instanceOrigin: 'http://localhost',
|
||||
workspace: { id: orgId, name: expect.any(String) },
|
||||
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
|
||||
standardScopes: ['openid', 'offline_access'],
|
||||
redirectUri: 'http://127.0.0.1:8484/callback',
|
||||
grantLifetime: {
|
||||
accessTokenSeconds: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
|
||||
refreshTokenSeconds: AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('revalidates OAuth consent submission through the Agent Access API', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await authedHeaders(app, 'agent-submit@example.com')
|
||||
|
||||
const res = await app.request('/api/agent-oauth-consent', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ accept: true, oauthQuery: 'client_id=zpan-agent&response_type=token' }),
|
||||
})
|
||||
|
||||
expect(res.status).toBe(400)
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
error: {
|
||||
message: 'Invalid Agent OAuth request',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('submits full OAuth consent through the Agent Access API', async () => {
|
||||
const { app } = await createTestApp()
|
||||
const headers = await authedHeaders(app, 'agent-submit-success@example.com')
|
||||
const oauthParams = new URLSearchParams({
|
||||
client_id: AGENT_OAUTH_CLIENT_ID,
|
||||
redirect_uri: 'http://127.0.0.1:8484/callback',
|
||||
response_type: 'code',
|
||||
scope: `${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ} openid offline_access`,
|
||||
state: 'agent-submit-success',
|
||||
code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
||||
code_challenge_method: 'S256',
|
||||
})
|
||||
const authorize = await app.request(`/api/auth/oauth2/authorize?${oauthParams}`, {
|
||||
headers: { ...headers, Origin: 'http://localhost' },
|
||||
})
|
||||
const consentLocation = authorize.headers.get('location')
|
||||
expect(authorize.status).toBe(302)
|
||||
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
|
||||
|
||||
const consent = await app.request('/api/agent-oauth-consent', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, Origin: 'http://localhost', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accept: true,
|
||||
oauthQuery: consentLocation?.slice(consentLocation.indexOf('?') + 1),
|
||||
}),
|
||||
})
|
||||
const consentBody = await consent.text()
|
||||
|
||||
expect(consent.status, consentBody).toBe(200)
|
||||
expect(JSON.parse(consentBody)).toMatchObject({
|
||||
url: expect.stringMatching(/^http:\/\/127\.0\.0\.1:8484\/callback\?code=/),
|
||||
})
|
||||
})
|
||||
|
||||
it('lists and revokes the current user grant family', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app, 'agent-grants@example.com')
|
||||
@@ -84,11 +171,14 @@ describe('Agent OAuth grants API integration', () => {
|
||||
{
|
||||
id: 'grant-1',
|
||||
clientId: AGENT_OAUTH_CLIENT_ID,
|
||||
clientName: 'ZPan Agent',
|
||||
userId,
|
||||
orgId,
|
||||
workspaceName: expect.any(String),
|
||||
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
|
||||
createdAt: '2026-07-29T12:00:00.000Z',
|
||||
updatedAt: '2026-07-29T12:00:00.000Z',
|
||||
lastUsedAt: null,
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -108,9 +198,18 @@ describe('Agent OAuth grants API integration', () => {
|
||||
await insertTeamOrg(db, 'other-workspace', userId)
|
||||
await insertGrant(db, { userId, orgId, scopes: [AuthorizationScope.OBJECTS_READ] })
|
||||
|
||||
const list = await app.request('/api/agent-oauth-grants', { headers })
|
||||
expect(list.status).toBe(200)
|
||||
await expect(list.json()).resolves.toMatchObject({ items: [{ id: 'grant-1', lastUsedAt: null }] })
|
||||
|
||||
const bearer = { Authorization: 'Bearer live-agent-token' }
|
||||
const allowed = await app.request('/api/objects', { headers: bearer })
|
||||
expect(allowed.status).toBe(200)
|
||||
const [usedGrant] = await db
|
||||
.select({ lastUsedAt: authSchema.oauthConsent.lastUsedAt })
|
||||
.from(authSchema.oauthConsent)
|
||||
.where(eq(authSchema.oauthConsent.id, 'grant-1'))
|
||||
expect(usedGrant.lastUsedAt).toBeInstanceOf(Date)
|
||||
|
||||
const wrongWorkspace = await app.request('/api/objects?orgId=other-workspace', { headers: bearer })
|
||||
expect(wrongWorkspace.status).toBe(403)
|
||||
|
||||
@@ -1,22 +1,54 @@
|
||||
import { OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { AuthorizationScope } from '@shared/authorization'
|
||||
import {
|
||||
agentOAuthConsentContextSchema,
|
||||
agentOAuthConsentResultSchema,
|
||||
agentOAuthConsentSubmitSchema,
|
||||
agentOAuthGrantListSchema,
|
||||
} from '@shared/schemas'
|
||||
import { requireAuth } from '../middleware/auth'
|
||||
import type { Env } from '../middleware/platform'
|
||||
import { getAgentOAuthConsentContext } from '../usecases/agent-oauth-consent'
|
||||
import { listAgentOAuthGrants, revokeAgentOAuthGrant } from '../usecases/agent-oauth-grants'
|
||||
import { authRoute, errorResponse, jsonContent } from './openapi'
|
||||
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
|
||||
|
||||
const agentOAuthGrantSchema = z.object({
|
||||
id: z.string(),
|
||||
clientId: z.string(),
|
||||
userId: z.string(),
|
||||
orgId: z.string(),
|
||||
scopes: z.array(z.enum(Object.values(AuthorizationScope) as [AuthorizationScope, ...AuthorizationScope[]])),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
|
||||
const listSchema = z.object({ items: z.array(agentOAuthGrantSchema) })
|
||||
const paramsSchema = z.object({ grantId: z.string().min(1) })
|
||||
const consentContextQuerySchema = z.object({ oauthQuery: z.string().min(1) })
|
||||
|
||||
const consentContextRoute = authRoute(
|
||||
{ access: 'session' },
|
||||
{
|
||||
operationId: 'getAgentOAuthConsentContext',
|
||||
summary: 'Get pending Agent OAuth consent context',
|
||||
tags: ['Agent Access'],
|
||||
method: 'get',
|
||||
path: '/agent-oauth-consent',
|
||||
middleware: [requireAuth] as const,
|
||||
request: { query: consentContextQuerySchema },
|
||||
responses: {
|
||||
200: jsonContent(agentOAuthConsentContextSchema, 'Agent OAuth consent context'),
|
||||
400: errorResponse('Invalid OAuth request'),
|
||||
403: errorResponse('Workspace access is required'),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const consentSubmitRoute = authRoute(
|
||||
{ access: 'session' },
|
||||
{
|
||||
operationId: 'submitAgentOAuthConsent',
|
||||
summary: 'Submit Agent OAuth consent decision',
|
||||
tags: ['Agent Access'],
|
||||
method: 'post',
|
||||
path: '/agent-oauth-consent',
|
||||
middleware: [requireAuth] as const,
|
||||
request: jsonBody(agentOAuthConsentSubmitSchema),
|
||||
responses: {
|
||||
200: jsonContent(agentOAuthConsentResultSchema, 'Agent OAuth consent result'),
|
||||
400: errorResponse('Invalid OAuth request'),
|
||||
403: errorResponse('Workspace access is required'),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
const listRoute = authRoute(
|
||||
{ access: 'session' },
|
||||
@@ -28,7 +60,7 @@ const listRoute = authRoute(
|
||||
path: '/agent-oauth-grants',
|
||||
middleware: [requireAuth] as const,
|
||||
responses: {
|
||||
200: jsonContent(listSchema, 'Agent OAuth grants'),
|
||||
200: jsonContent(agentOAuthGrantListSchema, 'Agent OAuth grants'),
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -51,6 +83,38 @@ const revokeRoute = authRoute(
|
||||
)
|
||||
|
||||
export const agentOAuthGrants = new OpenAPIHono<Env>()
|
||||
.openapi(consentContextRoute, async (c) => {
|
||||
const { oauthQuery } = c.req.valid('query')
|
||||
const context = await getAgentOAuthConsentContext(c.get('deps'), {
|
||||
userId: c.get('userId')!,
|
||||
orgId: c.get('orgId'),
|
||||
requestUrl: c.req.url,
|
||||
oauthQuery,
|
||||
})
|
||||
return c.json(context, 200)
|
||||
})
|
||||
.openapi(consentSubmitRoute, async (c) => {
|
||||
const { accept, oauthQuery } = c.req.valid('json')
|
||||
await getAgentOAuthConsentContext(c.get('deps'), {
|
||||
userId: c.get('userId')!,
|
||||
orgId: c.get('orgId'),
|
||||
requestUrl: c.req.url,
|
||||
oauthQuery,
|
||||
})
|
||||
const headers = new Headers(c.req.raw.headers)
|
||||
headers.set('content-type', 'application/json')
|
||||
headers.delete('content-length')
|
||||
const response = await c.get('auth').handler(
|
||||
new Request(new URL('/api/auth/oauth2/consent', c.req.url), {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ accept, oauth_query: oauthQuery }),
|
||||
}),
|
||||
)
|
||||
const body = await response.json().catch(() => null)
|
||||
if (!response.ok) return c.json(body ?? { error: response.statusText }, response.status as 400 | 403)
|
||||
return c.json(agentOAuthConsentResultSchema.parse(body), 200)
|
||||
})
|
||||
.openapi(listRoute, async (c) => {
|
||||
const result = await listAgentOAuthGrants(c.get('deps'), c.get('platform').db, { userId: c.get('userId')! })
|
||||
return c.json(result, 200)
|
||||
|
||||
@@ -71,12 +71,12 @@ describe('[CF] Auth API', () => {
|
||||
expect(authorize.status).toBe(302)
|
||||
expect(consentLocation).toMatch(/^\/settings\/agent-access\?/)
|
||||
|
||||
const consent = await app.request('/api/auth/oauth2/consent', {
|
||||
const consent = await app.request('/api/agent-oauth-consent', {
|
||||
method: 'POST',
|
||||
headers: { Cookie: cookie, Origin: 'http://localhost', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
accept: true,
|
||||
oauth_query: consentLocation?.slice(consentLocation.indexOf('?') + 1),
|
||||
oauthQuery: consentLocation?.slice(consentLocation.indexOf('?') + 1),
|
||||
}),
|
||||
})
|
||||
const consentBody = await consent.text()
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { AuthorizationScope } from '@shared/authorization'
|
||||
import { Hono } from 'hono'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { authorize, type RouteAuthorizationDeclaration } from './authz'
|
||||
import type { AuthzContext, Env } from './platform'
|
||||
|
||||
function probeApp(context: AuthzContext, declaration: RouteAuthorizationDeclaration) {
|
||||
const recordGrantUse = vi.fn(async () => {})
|
||||
const app = new Hono<Env>()
|
||||
app.use('/probe', async (c, next) => {
|
||||
c.set('authzContext', context)
|
||||
c.set('platform', { db: { kind: 'unit-db' } } as unknown as Env['Variables']['platform'])
|
||||
c.set('deps', {
|
||||
agentOAuth: { recordGrantUse },
|
||||
audit: { record: vi.fn() },
|
||||
org: {
|
||||
getMemberRole: vi.fn(async () => 'owner'),
|
||||
findPersonalOrg: vi.fn(async () => context.orgId),
|
||||
},
|
||||
} as unknown as Env['Variables']['deps'])
|
||||
await next()
|
||||
})
|
||||
app.get('/probe', authorize(declaration), (c) => c.json({ ok: true }))
|
||||
return { app, recordGrantUse }
|
||||
}
|
||||
|
||||
describe('authorize Agent OAuth grant-use tracking', () => {
|
||||
const context: AuthzContext = {
|
||||
credential: 'agent_oauth',
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
fixedOrgId: 'org-1',
|
||||
grantedScopes: new Set([AuthorizationScope.OBJECTS_READ]),
|
||||
actor: { type: 'agent_oauth', ref: 'grant-1' },
|
||||
state: { clientId: 'zpan-agent' },
|
||||
}
|
||||
|
||||
it('records actual Agent OAuth use for scoped protected routes', async () => {
|
||||
const { app, recordGrantUse } = probeApp(context, {
|
||||
access: 'protected',
|
||||
scopes: [AuthorizationScope.OBJECTS_READ],
|
||||
})
|
||||
|
||||
const res = await app.request('/probe')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(recordGrantUse).toHaveBeenCalledTimes(1)
|
||||
expect(recordGrantUse).toHaveBeenCalledWith(
|
||||
{ kind: 'unit-db' },
|
||||
expect.objectContaining({
|
||||
grantId: 'grant-1',
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
now: expect.any(Date),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not record display-only unscoped protected access as grant use', async () => {
|
||||
const { app, recordGrantUse } = probeApp(context, { access: 'protected' })
|
||||
|
||||
const res = await app.request('/probe')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(recordGrantUse).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not record non-Agent OAuth protected access as grant use', async () => {
|
||||
const { app, recordGrantUse } = probeApp(
|
||||
{
|
||||
credential: 'session',
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
fixedOrgId: null,
|
||||
grantedScopes: null,
|
||||
actor: { type: 'user', ref: 'user-1' },
|
||||
state: { firstParty: true },
|
||||
},
|
||||
{ access: 'protected', scopes: [AuthorizationScope.OBJECTS_READ] },
|
||||
)
|
||||
|
||||
const res = await app.request('/probe')
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(recordGrantUse).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -108,6 +108,7 @@ export function authorize(declaration: RouteAuthorizationDeclaration) {
|
||||
})
|
||||
if (decision.allowed) {
|
||||
if (decision.effectiveOrgId) c.set('orgId', decision.effectiveOrgId)
|
||||
await recordAgentOAuthGrantUse(c, declaration, decision.effectiveOrgId)
|
||||
await next()
|
||||
return
|
||||
}
|
||||
@@ -135,6 +136,23 @@ export function requirePermission(
|
||||
})
|
||||
}
|
||||
|
||||
async function recordAgentOAuthGrantUse(
|
||||
c: Context<Env>,
|
||||
declaration: RouteAuthorizationDeclaration,
|
||||
effectiveOrgId: string | null,
|
||||
) {
|
||||
const context = c.get('authzContext')
|
||||
if (declaration.access !== 'protected' || !declaration.scopes?.length) return
|
||||
if (context.credential !== 'agent_oauth') return
|
||||
if (!context.userId || !effectiveOrgId || context.actor?.type !== 'agent_oauth') return
|
||||
await c.get('deps').agentOAuth.recordGrantUse(c.get('platform').db, {
|
||||
grantId: context.actor.ref,
|
||||
userId: context.userId,
|
||||
orgId: effectiveOrgId,
|
||||
now: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
function deny(
|
||||
context: AuthzContext,
|
||||
status: 401 | 403,
|
||||
|
||||
@@ -189,6 +189,7 @@ const AUTH_SCHEMA_SQL = `
|
||||
reference_id TEXT,
|
||||
scopes TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)),
|
||||
last_used_at INTEGER,
|
||||
updated_at INTEGER NOT NULL DEFAULT (cast(unixepoch('subsecond') * 1000 as integer))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS oauthConsent_client_id_idx ON oauthConsent(client_id);
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import { AGENT_OAUTH_CLIENT_ID, AGENT_OAUTH_CLIENT_NAME } from '@shared/agent-oauth'
|
||||
import { AuthorizationScope } from '@shared/authorization'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getAgentOAuthConsentContext } from './agent-oauth-consent'
|
||||
import type { OrgRepo } from './ports'
|
||||
|
||||
function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
|
||||
return {
|
||||
findPersonalOrg: vi.fn(),
|
||||
getMemberRole: vi.fn(),
|
||||
getOrgNames: vi.fn(async () => new Map([['org-1', 'Personal']])),
|
||||
canReadOrg: vi.fn(async () => true),
|
||||
canWriteToOrg: vi.fn(),
|
||||
canManageAgentAccess: vi.fn(),
|
||||
isPersonalOrg: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function oauthQuery(overrides: Record<string, string> = {}) {
|
||||
return new URLSearchParams({
|
||||
client_id: AGENT_OAUTH_CLIENT_ID,
|
||||
redirect_uri: 'http://127.0.0.1:8484/callback',
|
||||
response_type: 'code',
|
||||
scope: `openid offline_access ${AuthorizationScope.OBJECTS_READ} ${AuthorizationScope.QUOTA_READ}`,
|
||||
...overrides,
|
||||
}).toString()
|
||||
}
|
||||
|
||||
describe('Agent OAuth consent usecase', () => {
|
||||
it('builds server-owned consent context for the active workspace', async () => {
|
||||
await expect(
|
||||
getAgentOAuthConsentContext(
|
||||
{ org: org() },
|
||||
{
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
|
||||
oauthQuery: oauthQuery(),
|
||||
},
|
||||
),
|
||||
).resolves.toEqual({
|
||||
clientId: AGENT_OAUTH_CLIENT_ID,
|
||||
clientName: AGENT_OAUTH_CLIENT_NAME,
|
||||
instanceOrigin: 'https://zpan.example.test',
|
||||
workspace: { id: 'org-1', name: 'Personal' },
|
||||
scopes: [AuthorizationScope.OBJECTS_READ, AuthorizationScope.QUOTA_READ],
|
||||
standardScopes: ['openid', 'offline_access'],
|
||||
redirectUri: 'http://127.0.0.1:8484/callback',
|
||||
grantLifetime: {
|
||||
accessTokenSeconds: 900,
|
||||
refreshTokenSeconds: 2_592_000,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the active workspace id when the workspace name is unavailable', async () => {
|
||||
await expect(
|
||||
getAgentOAuthConsentContext(
|
||||
{ org: org({ getOrgNames: vi.fn(async () => new Map()) }) },
|
||||
{
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
|
||||
oauthQuery: oauthQuery(),
|
||||
},
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
workspace: { id: 'org-1', name: null },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects requests that are not the managed authorization-code client flow', async () => {
|
||||
await expect(
|
||||
getAgentOAuthConsentContext(
|
||||
{ org: org() },
|
||||
{
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
|
||||
oauthQuery: oauthQuery({ response_type: 'token' }),
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ httpStatus: 400 })
|
||||
})
|
||||
|
||||
it('rejects untrusted redirect URIs and non-grantable scopes', async () => {
|
||||
await expect(
|
||||
getAgentOAuthConsentContext(
|
||||
{ org: org() },
|
||||
{
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
|
||||
oauthQuery: oauthQuery({ redirect_uri: 'https://evil.example/callback' }),
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ httpStatus: 400 })
|
||||
|
||||
await expect(
|
||||
getAgentOAuthConsentContext(
|
||||
{ org: org() },
|
||||
{
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
|
||||
oauthQuery: oauthQuery({ scope: 'objects:purge' }),
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ httpStatus: 400 })
|
||||
})
|
||||
|
||||
it('rejects missing or inaccessible workspaces', async () => {
|
||||
await expect(
|
||||
getAgentOAuthConsentContext(
|
||||
{ org: org({ canReadOrg: vi.fn(async () => false) }) },
|
||||
{
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
requestUrl: 'https://zpan.example.test/api/agent-oauth-consent',
|
||||
oauthQuery: oauthQuery(),
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ httpStatus: 403 })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
|
||||
AGENT_OAUTH_CLIENT_ID,
|
||||
AGENT_OAUTH_CLIENT_NAME,
|
||||
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
|
||||
AGENT_OAUTH_STANDARD_SCOPES,
|
||||
RESTISH_OAUTH_REDIRECT_URIS,
|
||||
} from '@shared/agent-oauth'
|
||||
import { isAuthorizationScope } from '@shared/authorization'
|
||||
import { type AgentGrantableScope, type AgentOAuthConsentContext, agentGrantableScopeSchema } from '@shared/schemas'
|
||||
import type { Deps } from './deps'
|
||||
import { badRequest, forbidden } from './ports'
|
||||
|
||||
export async function getAgentOAuthConsentContext(
|
||||
deps: Pick<Deps, 'org'>,
|
||||
input: { userId: string; orgId: string | null; requestUrl: string; oauthQuery: string },
|
||||
): Promise<AgentOAuthConsentContext> {
|
||||
const params = new URLSearchParams(input.oauthQuery)
|
||||
const clientId = params.get('client_id')
|
||||
const redirectUri = params.get('redirect_uri')
|
||||
const responseType = params.get('response_type')
|
||||
const scopeValue = params.get('scope') ?? ''
|
||||
|
||||
if (clientId !== AGENT_OAUTH_CLIENT_ID || responseType !== 'code' || !redirectUri) {
|
||||
throw badRequest('Invalid Agent OAuth request')
|
||||
}
|
||||
if (!RESTISH_OAUTH_REDIRECT_URIS.includes(redirectUri as (typeof RESTISH_OAUTH_REDIRECT_URIS)[number])) {
|
||||
throw badRequest('Invalid Agent OAuth redirect URI')
|
||||
}
|
||||
|
||||
const requestedScopes = scopeValue.split(/\s+/).filter(Boolean)
|
||||
const standardScopes = requestedScopes.filter((scope) =>
|
||||
(AGENT_OAUTH_STANDARD_SCOPES as readonly string[]).includes(scope),
|
||||
)
|
||||
const scopes = requestedScopes.filter(isAgentGrantableScope)
|
||||
if (scopes.length === 0 || requestedScopes.length !== standardScopes.length + scopes.length) {
|
||||
throw badRequest('Invalid Agent OAuth scope')
|
||||
}
|
||||
|
||||
const orgId = input.orgId
|
||||
if (!orgId || !(await deps.org.canReadOrg(input.userId, orgId))) {
|
||||
throw forbidden('Workspace access is required for Agent OAuth')
|
||||
}
|
||||
const names = await deps.org.getOrgNames([orgId])
|
||||
|
||||
return {
|
||||
clientId,
|
||||
clientName: AGENT_OAUTH_CLIENT_NAME,
|
||||
instanceOrigin: new URL(input.requestUrl).origin,
|
||||
workspace: { id: orgId, name: names.get(orgId) ?? null },
|
||||
scopes,
|
||||
standardScopes,
|
||||
redirectUri,
|
||||
grantLifetime: {
|
||||
accessTokenSeconds: AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
|
||||
refreshTokenSeconds: AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isAgentGrantableScope(scope: string): scope is AgentGrantableScope {
|
||||
return isAuthorizationScope(scope) && agentGrantableScopeSchema.safeParse(scope).success
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { listAgentOAuthGrants, revokeAgentOAuthGrant } from './agent-oauth-grants'
|
||||
import type { AgentOAuthGateway } from './ports'
|
||||
import type { AgentOAuthGateway, OrgRepo } from './ports'
|
||||
|
||||
const db = {} as never
|
||||
|
||||
@@ -10,11 +10,25 @@ function gateway(overrides: Partial<AgentOAuthGateway> = {}): AgentOAuthGateway
|
||||
assertLiveGrant: vi.fn(),
|
||||
verifyAccessToken: vi.fn(),
|
||||
listGrants: vi.fn(async () => []),
|
||||
recordGrantUse: vi.fn(),
|
||||
revokeGrant: vi.fn(async () => true),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function org(overrides: Partial<OrgRepo> = {}): OrgRepo {
|
||||
return {
|
||||
findPersonalOrg: vi.fn(),
|
||||
getMemberRole: vi.fn(),
|
||||
getOrgNames: vi.fn(async () => new Map([['org-1', 'Personal']])),
|
||||
canReadOrg: vi.fn(),
|
||||
canWriteToOrg: vi.fn(),
|
||||
canManageAgentAccess: vi.fn(),
|
||||
isPersonalOrg: vi.fn(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Agent OAuth grant usecases', () => {
|
||||
it('lists grants through the gateway', async () => {
|
||||
const agentOAuth = gateway({
|
||||
@@ -26,21 +40,24 @@ describe('Agent OAuth grant usecases', () => {
|
||||
orgId: 'org-1',
|
||||
scopes: [],
|
||||
createdAt: '2026-07-29T12:00:00.000Z',
|
||||
updatedAt: '2026-07-29T12:00:00.000Z',
|
||||
lastUsedAt: null,
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
await expect(listAgentOAuthGrants({ agentOAuth }, db, { userId: 'user-1' })).resolves.toEqual({
|
||||
await expect(listAgentOAuthGrants({ agentOAuth, org: org() }, db, { userId: 'user-1' })).resolves.toEqual({
|
||||
items: [
|
||||
{
|
||||
id: 'grant-1',
|
||||
clientId: 'zpan-agent',
|
||||
clientName: 'ZPan Agent',
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
workspaceName: 'Personal',
|
||||
scopes: [],
|
||||
createdAt: '2026-07-29T12:00:00.000Z',
|
||||
updatedAt: '2026-07-29T12:00:00.000Z',
|
||||
lastUsedAt: null,
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,14 +1,33 @@
|
||||
import {
|
||||
type AgentGrantableScope,
|
||||
type AgentOAuthGrant as AgentOAuthGrantDTO,
|
||||
agentGrantableScopeSchema,
|
||||
agentOAuthGrantDTO,
|
||||
} from '@shared/schemas'
|
||||
import type { Database } from '../platform/interface'
|
||||
import type { Deps } from './deps'
|
||||
import type { AgentOAuthGrant } from './ports'
|
||||
import { notFound } from './ports'
|
||||
|
||||
export async function listAgentOAuthGrants(
|
||||
deps: Pick<Deps, 'agentOAuth'>,
|
||||
deps: Pick<Deps, 'agentOAuth' | 'org'>,
|
||||
db: Database,
|
||||
input: { userId: string },
|
||||
): Promise<{ items: AgentOAuthGrant[] }> {
|
||||
return { items: await deps.agentOAuth.listGrants(db, input.userId) }
|
||||
): Promise<{ items: AgentOAuthGrantDTO[] }> {
|
||||
const items = await deps.agentOAuth.listGrants(db, input.userId)
|
||||
const orgNames = await deps.org.getOrgNames(items.map((item) => item.orgId))
|
||||
return {
|
||||
items: items.map((item) =>
|
||||
agentOAuthGrantDTO({
|
||||
...item,
|
||||
scopes: item.scopes.filter(isAgentGrantableScope),
|
||||
workspaceName: orgNames.get(item.orgId) ?? null,
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function isAgentGrantableScope(scope: string): scope is AgentGrantableScope {
|
||||
return agentGrantableScopeSchema.safeParse(scope).success
|
||||
}
|
||||
|
||||
export async function revokeAgentOAuthGrant(
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface AgentOAuthGrant {
|
||||
orgId: string
|
||||
scopes: AuthorizationScope[]
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
lastUsedAt: string | null
|
||||
}
|
||||
|
||||
export interface AgentOAuthGateway {
|
||||
@@ -27,5 +27,6 @@ export interface AgentOAuthGateway {
|
||||
): Promise<void>
|
||||
verifyAccessToken(db: Database, token: string): Promise<VerifiedAgentOAuthToken | null>
|
||||
listGrants(db: Database, userId: string): Promise<AgentOAuthGrant[]>
|
||||
recordGrantUse(db: Database, input: { grantId: string; userId: string; orgId: string; now: Date }): Promise<void>
|
||||
revokeGrant(db: Database, input: { userId: string; grantId: string; now: Date }): Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface OrgRepo {
|
||||
findPersonalOrg(userId: string): Promise<string | null>
|
||||
getMemberRole(orgId: string, userId: string): Promise<string | null>
|
||||
getOrgNames(orgIds: string[]): Promise<Map<string, string>>
|
||||
canReadOrg(userId: string, orgId: string): Promise<boolean>
|
||||
canWriteToOrg(userId: string, orgId: string): Promise<boolean>
|
||||
canManageAgentAccess(userId: string, orgId: string): Promise<boolean>
|
||||
|
||||
@@ -87,6 +87,7 @@ function makeDeps(
|
||||
org: {
|
||||
findPersonalOrg: async () => null,
|
||||
getMemberRole: async () => null,
|
||||
getOrgNames: async () => new Map(),
|
||||
canReadOrg: async () => false,
|
||||
canWriteToOrg: async () => false,
|
||||
canManageAgentAccess: async () => false,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
AGENT_OAUTH_ACCESS_TOKEN_SECONDS,
|
||||
AGENT_OAUTH_CLIENT_NAME,
|
||||
AGENT_OAUTH_REFRESH_TOKEN_SECONDS,
|
||||
} from '../agent-oauth'
|
||||
import { agentGrantableScopeSchema } from './agent-api-keys'
|
||||
|
||||
export const agentOAuthGrantStatusSchema = z.enum(['active'])
|
||||
export type AgentOAuthGrantStatus = z.infer<typeof agentOAuthGrantStatusSchema>
|
||||
|
||||
export const agentOAuthGrantSchema = z.object({
|
||||
id: z.string(),
|
||||
clientId: z.string(),
|
||||
clientName: z.string().default(AGENT_OAUTH_CLIENT_NAME),
|
||||
userId: z.string(),
|
||||
orgId: z.string(),
|
||||
workspaceName: z.string().nullable(),
|
||||
scopes: z.array(agentGrantableScopeSchema),
|
||||
createdAt: z.string(),
|
||||
lastUsedAt: z.string().nullable(),
|
||||
status: agentOAuthGrantStatusSchema,
|
||||
})
|
||||
export type AgentOAuthGrant = z.infer<typeof agentOAuthGrantSchema>
|
||||
|
||||
export const agentOAuthGrantListSchema = z.object({ items: z.array(agentOAuthGrantSchema) })
|
||||
export type AgentOAuthGrantList = z.infer<typeof agentOAuthGrantListSchema>
|
||||
|
||||
export const agentOAuthConsentContextSchema = z.object({
|
||||
clientId: z.string(),
|
||||
clientName: z.string(),
|
||||
instanceOrigin: z.string(),
|
||||
workspace: z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
}),
|
||||
scopes: z.array(agentGrantableScopeSchema),
|
||||
standardScopes: z.array(z.string()),
|
||||
redirectUri: z.string(),
|
||||
grantLifetime: z.object({
|
||||
accessTokenSeconds: z.number().int().default(AGENT_OAUTH_ACCESS_TOKEN_SECONDS),
|
||||
refreshTokenSeconds: z.number().int().default(AGENT_OAUTH_REFRESH_TOKEN_SECONDS),
|
||||
}),
|
||||
})
|
||||
export type AgentOAuthConsentContext = z.infer<typeof agentOAuthConsentContextSchema>
|
||||
|
||||
export const agentOAuthConsentContextRequestSchema = z.object({
|
||||
oauthQuery: z.string().min(1),
|
||||
})
|
||||
export type AgentOAuthConsentContextRequest = z.infer<typeof agentOAuthConsentContextRequestSchema>
|
||||
|
||||
export const agentOAuthConsentSubmitSchema = z.object({
|
||||
accept: z.boolean(),
|
||||
oauthQuery: z.string().min(1),
|
||||
})
|
||||
export type AgentOAuthConsentSubmit = z.infer<typeof agentOAuthConsentSubmitSchema>
|
||||
|
||||
export const agentOAuthConsentResultSchema = z.object({
|
||||
url: z.string(),
|
||||
})
|
||||
export type AgentOAuthConsentResult = z.infer<typeof agentOAuthConsentResultSchema>
|
||||
|
||||
export function agentOAuthGrantDTO(input: Omit<AgentOAuthGrant, 'clientName' | 'status'>): AgentOAuthGrant {
|
||||
return {
|
||||
...input,
|
||||
clientName: AGENT_OAUTH_CLIENT_NAME,
|
||||
status: 'active',
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,25 @@ export {
|
||||
agentGrantableScopeSchema,
|
||||
agentScopeLabels,
|
||||
} from './agent-api-keys'
|
||||
export type {
|
||||
AgentOAuthConsentContext,
|
||||
AgentOAuthConsentContextRequest,
|
||||
AgentOAuthConsentResult,
|
||||
AgentOAuthConsentSubmit,
|
||||
AgentOAuthGrant,
|
||||
AgentOAuthGrantList,
|
||||
AgentOAuthGrantStatus,
|
||||
} from './agent-oauth-grants'
|
||||
export {
|
||||
agentOAuthConsentContextRequestSchema,
|
||||
agentOAuthConsentContextSchema,
|
||||
agentOAuthConsentResultSchema,
|
||||
agentOAuthConsentSubmitSchema,
|
||||
agentOAuthGrantDTO,
|
||||
agentOAuthGrantListSchema,
|
||||
agentOAuthGrantSchema,
|
||||
agentOAuthGrantStatusSchema,
|
||||
} from './agent-oauth-grants'
|
||||
|
||||
export type {
|
||||
AnnouncementInput,
|
||||
|
||||
@@ -1250,6 +1250,29 @@
|
||||
"settings.agentAccess.revokeSuccess": "Agent API key revoked",
|
||||
"settings.agentAccess.revealedTitle": "Save Your Agent API Key",
|
||||
"settings.agentAccess.revealedWarning": "This is the only time this key will be shown. Store it securely.",
|
||||
"settings.agentAccess.oauthConsentEyebrow": "Delegated OAuth access",
|
||||
"settings.agentAccess.oauthConsentTitle": "Authorize ZPan Agent",
|
||||
"settings.agentAccess.oauthConsentDescription": "Review the exact workspace and scopes Restish will receive before continuing.",
|
||||
"settings.agentAccess.oauthClient": "Client",
|
||||
"settings.agentAccess.oauthOrigin": "ZPan instance",
|
||||
"settings.agentAccess.oauthReturn": "Return URL",
|
||||
"settings.agentAccess.oauthLifetime": "Grant lifetime",
|
||||
"settings.agentAccess.oauthLifetimeValue": "{{days}} days",
|
||||
"settings.agentAccess.oauthScopesTitle": "Requested scopes",
|
||||
"settings.agentAccess.oauthEffects": "This grant can read or change files and public shares only where the listed scopes allow it. Delete and share scopes can remove content or expose public links.",
|
||||
"settings.agentAccess.oauthApprove": "Approve Access",
|
||||
"settings.agentAccess.oauthDeny": "Deny",
|
||||
"settings.agentAccess.oauthExpiredTitle": "OAuth request expired",
|
||||
"settings.agentAccess.oauthExpiredDescription": "Start the Restish connection again to create a fresh authorization request.",
|
||||
"settings.agentAccess.oauthConsentFailed": "Could not finish OAuth consent.",
|
||||
"settings.agentAccess.oauthWorkspaceFailed": "Could not switch workspace.",
|
||||
"settings.agentAccess.oauthGrantsSection": "Delegated OAuth Grants",
|
||||
"settings.agentAccess.oauthGrantsDescription": "Manage Restish OAuth grants connected to your workspaces.",
|
||||
"settings.agentAccess.oauthNoGrants": "No delegated OAuth grants yet",
|
||||
"settings.agentAccess.oauthGrantsError": "Could not load delegated OAuth grants.",
|
||||
"settings.agentAccess.oauthGrantRevokeTitle": "Revoke OAuth Grant",
|
||||
"settings.agentAccess.oauthGrantRevokeConfirm": "Revoke {{client}} access to {{workspace}}? Active Restish sessions for this workspace will stop immediately.",
|
||||
"settings.agentAccess.oauthGrantRevokeSuccess": "OAuth grant revoked",
|
||||
"settings.appearance.theme.description": "Choose how ZPan looks. Follows your system setting by default.",
|
||||
"settings.appearance.language.description": "The display language for the app.",
|
||||
"settings.appearance.autoSaved": "Changes apply immediately.",
|
||||
|
||||
@@ -1250,6 +1250,29 @@
|
||||
"settings.agentAccess.revokeSuccess": "Agent API Key 已撤销",
|
||||
"settings.agentAccess.revealedTitle": "保存你的 Agent API Key",
|
||||
"settings.agentAccess.revealedWarning": "该 Key 只会显示一次,请妥善保存。",
|
||||
"settings.agentAccess.oauthConsentEyebrow": "委托 OAuth 访问",
|
||||
"settings.agentAccess.oauthConsentTitle": "授权 ZPan Agent",
|
||||
"settings.agentAccess.oauthConsentDescription": "继续前请确认 Restish 将获得的具体工作空间和权限。",
|
||||
"settings.agentAccess.oauthClient": "客户端",
|
||||
"settings.agentAccess.oauthOrigin": "ZPan 实例",
|
||||
"settings.agentAccess.oauthReturn": "返回 URL",
|
||||
"settings.agentAccess.oauthLifetime": "授权有效期",
|
||||
"settings.agentAccess.oauthLifetimeValue": "{{days}} 天",
|
||||
"settings.agentAccess.oauthScopesTitle": "请求权限",
|
||||
"settings.agentAccess.oauthEffects": "该授权只能按列出的权限读取或更改文件与公开分享。删除和分享权限可能移除内容或公开链接。",
|
||||
"settings.agentAccess.oauthApprove": "批准访问",
|
||||
"settings.agentAccess.oauthDeny": "拒绝",
|
||||
"settings.agentAccess.oauthExpiredTitle": "OAuth 请求已过期",
|
||||
"settings.agentAccess.oauthExpiredDescription": "请从 Restish 重新发起连接,生成新的授权请求。",
|
||||
"settings.agentAccess.oauthConsentFailed": "无法完成 OAuth 授权。",
|
||||
"settings.agentAccess.oauthWorkspaceFailed": "无法切换工作空间。",
|
||||
"settings.agentAccess.oauthGrantsSection": "委托 OAuth 授权",
|
||||
"settings.agentAccess.oauthGrantsDescription": "管理连接到你工作空间的 Restish OAuth 授权。",
|
||||
"settings.agentAccess.oauthNoGrants": "暂无委托 OAuth 授权",
|
||||
"settings.agentAccess.oauthGrantsError": "无法加载委托 OAuth 授权。",
|
||||
"settings.agentAccess.oauthGrantRevokeTitle": "撤销 OAuth 授权",
|
||||
"settings.agentAccess.oauthGrantRevokeConfirm": "撤销 {{client}} 对 {{workspace}} 的访问?该工作空间的 Restish 会话将立即停止。",
|
||||
"settings.agentAccess.oauthGrantRevokeSuccess": "OAuth 授权已撤销",
|
||||
"settings.appearance.theme.description": "选择 ZPan 的外观,默认跟随系统。",
|
||||
"settings.appearance.language.description": "界面显示语言。",
|
||||
"settings.appearance.autoSaved": "修改即时生效。",
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
getAdminDashboardStorageStats,
|
||||
getAdminDashboardTrafficStats,
|
||||
getAdminOverview,
|
||||
getAgentOAuthConsentContext,
|
||||
getAnnouncement,
|
||||
getBackgroundJob,
|
||||
getChangelog,
|
||||
@@ -81,6 +82,7 @@ import {
|
||||
listAdminAnnouncements,
|
||||
listAdminAuditLogs,
|
||||
listAgentApiKeys,
|
||||
listAgentOAuthGrants,
|
||||
listAnnouncements,
|
||||
listApiKeys,
|
||||
listAuthProviders,
|
||||
@@ -123,6 +125,7 @@ import {
|
||||
restoreObject,
|
||||
retryBackgroundJob,
|
||||
revokeAgentApiKey,
|
||||
revokeAgentOAuthGrant,
|
||||
revokeIhostApiKey,
|
||||
revokeOrgEntitlement,
|
||||
revokeRemoteDownloadApiKey,
|
||||
@@ -139,6 +142,7 @@ import {
|
||||
sendDownloaderHeartbeat,
|
||||
serverEventsUrl,
|
||||
setSharePrivacy,
|
||||
submitAgentOAuthConsent,
|
||||
testEmail,
|
||||
testImageDomainProvider,
|
||||
transferObject,
|
||||
@@ -3208,6 +3212,99 @@ describe('api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Agent OAuth consent and grants', () => {
|
||||
const sampleGrantList = {
|
||||
items: [
|
||||
{
|
||||
id: 'grant-1',
|
||||
clientId: 'zpan-agent',
|
||||
clientName: 'ZPan Agent',
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
workspaceName: 'Personal',
|
||||
scopes: ['objects:read'],
|
||||
createdAt: '2026-07-29T00:00:00.000Z',
|
||||
lastUsedAt: null,
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
it('loads server-owned OAuth consent context with the raw OAuth query', async () => {
|
||||
const payload = {
|
||||
clientId: 'zpan-agent',
|
||||
clientName: 'ZPan Agent',
|
||||
instanceOrigin: 'https://zpan.example.test',
|
||||
workspace: { id: 'org-1', name: 'Personal' },
|
||||
scopes: ['objects:read'],
|
||||
standardScopes: ['openid', 'offline_access'],
|
||||
redirectUri: 'http://127.0.0.1:8484/callback',
|
||||
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await getAgentOAuthConsentContext('client_id=zpan-agent&scope=objects%3Aread')
|
||||
|
||||
expect(result).toEqual(payload)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/agent-oauth-consent')
|
||||
expect(url).toContain('oauthQuery=client_id%3Dzpan-agent%26scope%3Dobjects%253Aread')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('submits full OAuth consent through the Hono RPC wrapper without sending scope overrides', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ url: 'http://127.0.0.1:8484/callback?code=abc' }))
|
||||
|
||||
const result = await submitAgentOAuthConsent({ accept: true, oauthQuery: 'client_id=zpan-agent' })
|
||||
|
||||
expect(result).toEqual({ url: 'http://127.0.0.1:8484/callback?code=abc' })
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/agent-oauth-consent')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.credentials).toBe('include')
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
accept: true,
|
||||
oauthQuery: 'client_id=zpan-agent',
|
||||
})
|
||||
})
|
||||
|
||||
it('throws an API error when OAuth consent submission is rejected with a non-JSON response', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
statusText: 'Bad Request',
|
||||
json: async () => {
|
||||
throw new Error('not json')
|
||||
},
|
||||
} as unknown as Response)
|
||||
|
||||
await expect(submitAgentOAuthConsent({ accept: false, oauthQuery: 'client_id=zpan-agent' })).rejects.toThrow(
|
||||
ApiError,
|
||||
)
|
||||
})
|
||||
|
||||
it('lists delegated Agent OAuth grants', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(sampleGrantList))
|
||||
|
||||
const result = await listAgentOAuthGrants()
|
||||
|
||||
expect(result).toEqual(sampleGrantList)
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/agent-oauth-grants')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('revokes delegated Agent OAuth grants with DELETE', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
|
||||
|
||||
await revokeAgentOAuthGrant('grant-1')
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/agent-oauth-grants/grant-1')
|
||||
expect(init.method).toBe('DELETE')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listApiKeys', () => {
|
||||
const sampleKey = {
|
||||
id: 'key-1',
|
||||
|
||||
+33
-1
@@ -6,6 +6,11 @@ import type {
|
||||
AgentApiKeyCreateInput,
|
||||
AgentApiKeyList,
|
||||
AgentApiKeyRotateInput,
|
||||
AgentOAuthConsentContext,
|
||||
AgentOAuthConsentResult,
|
||||
AgentOAuthConsentSubmit,
|
||||
AgentOAuthGrant,
|
||||
AgentOAuthGrantList,
|
||||
AllowedImageMime,
|
||||
AnnouncementInput,
|
||||
CloudCreditBalanceResponse,
|
||||
@@ -103,6 +108,7 @@ import {
|
||||
adminSiteInvitations,
|
||||
adminTeams,
|
||||
agentApiKeysApi,
|
||||
agentOAuthGrantsApi,
|
||||
announcementsApi,
|
||||
authedSharesApi,
|
||||
authProviders,
|
||||
@@ -1092,7 +1098,17 @@ export function deleteIhostConfig() {
|
||||
|
||||
// Agent Access API keys
|
||||
|
||||
export type { AgentApiKey, AgentApiKeyCreated, AgentApiKeyCreateInput, AgentApiKeyList, AgentApiKeyRotateInput }
|
||||
export type {
|
||||
AgentApiKey,
|
||||
AgentApiKeyCreated,
|
||||
AgentApiKeyCreateInput,
|
||||
AgentApiKeyList,
|
||||
AgentApiKeyRotateInput,
|
||||
AgentOAuthConsentContext,
|
||||
AgentOAuthConsentResult,
|
||||
AgentOAuthGrant,
|
||||
AgentOAuthGrantList,
|
||||
}
|
||||
|
||||
export function listAgentApiKeys(orgId: string, page = 1, pageSize = 50) {
|
||||
return unwrap<AgentApiKeyList>(
|
||||
@@ -1124,6 +1140,22 @@ export function revokeAgentApiKey(orgId: string, keyId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getAgentOAuthConsentContext(oauthQuery: string) {
|
||||
return unwrap<AgentOAuthConsentContext>(agentOAuthGrantsApi['agent-oauth-consent'].$get({ query: { oauthQuery } }))
|
||||
}
|
||||
|
||||
export function submitAgentOAuthConsent(input: AgentOAuthConsentSubmit) {
|
||||
return unwrap<AgentOAuthConsentResult>(agentOAuthGrantsApi['agent-oauth-consent'].$post({ json: input }))
|
||||
}
|
||||
|
||||
export function listAgentOAuthGrants() {
|
||||
return unwrap<AgentOAuthGrantList>(agentOAuthGrantsApi['agent-oauth-grants'].$get())
|
||||
}
|
||||
|
||||
export function revokeAgentOAuthGrant(grantId: string) {
|
||||
return discard(agentOAuthGrantsApi['agent-oauth-grants'][':grantId'].$delete({ param: { grantId } }))
|
||||
}
|
||||
|
||||
// Image Host API Keys (via better-auth apiKey plugin)
|
||||
|
||||
export interface IhostApiKey {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
AdminStatsRoute,
|
||||
AdminTeamsRoute,
|
||||
AgentApiKeysRoute,
|
||||
AgentOAuthGrantsRoute,
|
||||
AnnouncementsRoute,
|
||||
AuthedSharesRoute,
|
||||
AuthProvidersRoute,
|
||||
@@ -51,6 +52,7 @@ export const downloadTasksApi = hc<DownloadTasksRoute>('/api/downloads/tasks', o
|
||||
export const downloaderSelfApi = hc<DownloaderSelfRoute>('/api/downloads/downloaders', opts)
|
||||
export const trash = hc<TrashRoute>('/api/trash', opts)
|
||||
export const agentApiKeysApi = hc<AgentApiKeysRoute>('/api/workspaces', opts)
|
||||
export const agentOAuthGrantsApi = hc<AgentOAuthGrantsRoute>('/api', opts)
|
||||
export const storages = hc<StoragesRoute>('/api/site/storages', opts)
|
||||
export const storageUsageApi = hc<StorageUsageRoute>('/api/storage', opts)
|
||||
export const adminDownloadersApi = hc<DownloadersRoute>('/api/downloads/downloaders', opts)
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
import type { AgentApiKey } from '@shared/schemas'
|
||||
import type { AgentApiKey, AgentOAuthGrant } from '@shared/schemas'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { toast } from 'sonner'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '@/lib/api'
|
||||
import { useListOrganizations } from '@/lib/auth-client'
|
||||
import {
|
||||
createAgentApiKey,
|
||||
getAgentOAuthConsentContext,
|
||||
listAgentApiKeys,
|
||||
listAgentOAuthGrants,
|
||||
revokeAgentApiKey,
|
||||
revokeAgentOAuthGrant,
|
||||
rotateAgentApiKey,
|
||||
submitAgentOAuthConsent,
|
||||
} from '@/lib/api'
|
||||
import { setActive, useListOrganizations } from '@/lib/auth-client'
|
||||
import { redirectExternal } from '@/lib/browser-navigation'
|
||||
import { AgentAccessSettingsPage } from './agent-access'
|
||||
import { SettingsLayout } from './route'
|
||||
|
||||
@@ -14,6 +24,7 @@ const state = vi.hoisted(() => ({
|
||||
{ id: 'org-2', name: 'Team Alpha' },
|
||||
],
|
||||
keys: [] as AgentApiKey[],
|
||||
grants: [] as AgentOAuthGrant[],
|
||||
webdavEnabled: true,
|
||||
}))
|
||||
|
||||
@@ -28,6 +39,20 @@ const translations: Record<string, string> = {
|
||||
'settings.agentAccess.scope.quotaRead': 'Quota: read workspace quota',
|
||||
'settings.agentAccess.scope.storageUsageRead': 'Storage usage: read workspace usage',
|
||||
'settings.agentAccess.managementRequired': 'Owner or admin access is required',
|
||||
'settings.agentAccess.oauthConsentTitle': 'Authorize ZPan Agent',
|
||||
'settings.agentAccess.oauthClient': 'Client',
|
||||
'settings.agentAccess.oauthOrigin': 'ZPan instance',
|
||||
'settings.agentAccess.oauthReturn': 'Return URL',
|
||||
'settings.agentAccess.oauthLifetime': 'Grant lifetime',
|
||||
'settings.agentAccess.oauthLifetimeValue': '30 days',
|
||||
'settings.agentAccess.oauthScopesTitle': 'Requested scopes',
|
||||
'settings.agentAccess.oauthApprove': 'Approve Access',
|
||||
'settings.agentAccess.oauthDeny': 'Deny',
|
||||
'settings.agentAccess.oauthExpiredTitle': 'OAuth request expired',
|
||||
'settings.agentAccess.oauthGrantsSection': 'Delegated OAuth Grants',
|
||||
'settings.agentAccess.oauthNoGrants': 'No delegated OAuth grants yet',
|
||||
'settings.agentAccess.oauthGrantRevokeTitle': 'Revoke OAuth Grant',
|
||||
'settings.agentAccess.oauthGrantRevokeSuccess': 'OAuth grant revoked',
|
||||
}
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
@@ -59,13 +84,22 @@ vi.mock('@/hooks/use-site-config', () => ({
|
||||
|
||||
vi.mock('@/lib/auth-client', () => ({
|
||||
useListOrganizations: vi.fn(),
|
||||
setActive: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/browser-navigation', () => ({
|
||||
redirectExternal: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({
|
||||
createAgentApiKey: vi.fn(),
|
||||
getAgentOAuthConsentContext: vi.fn(),
|
||||
listAgentApiKeys: vi.fn(),
|
||||
listAgentOAuthGrants: vi.fn(),
|
||||
revokeAgentApiKey: vi.fn(),
|
||||
revokeAgentOAuthGrant: vi.fn(),
|
||||
rotateAgentApiKey: vi.fn(),
|
||||
submitAgentOAuthConsent: vi.fn(),
|
||||
}))
|
||||
|
||||
const queryClients: QueryClient[] = []
|
||||
@@ -94,6 +128,7 @@ beforeEach(() => {
|
||||
disconnect() {}
|
||||
},
|
||||
)
|
||||
Element.prototype.scrollIntoView = vi.fn()
|
||||
vi.mocked(useListOrganizations).mockReturnValue({ data: state.orgs } as never)
|
||||
vi.mocked(listAgentApiKeys).mockImplementation(async (orgId: string) => ({
|
||||
items: state.keys.filter((item) => item.orgId === orgId),
|
||||
@@ -101,6 +136,9 @@ beforeEach(() => {
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
}))
|
||||
vi.mocked(listAgentOAuthGrants).mockImplementation(async () => ({ items: state.grants }))
|
||||
vi.mocked(setActive).mockResolvedValue({ data: null, error: null } as never)
|
||||
window.history.replaceState(null, '', '/settings/agent-access')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -109,6 +147,7 @@ afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
state.keys = []
|
||||
state.grants = []
|
||||
state.webdavEnabled = true
|
||||
})
|
||||
|
||||
@@ -118,6 +157,7 @@ describe('Agent Access settings page', () => {
|
||||
|
||||
await waitFor(() => expect(listAgentApiKeys).toHaveBeenCalledWith('org-1'))
|
||||
expect(await screen.findByText('settings.agentAccess.noKeys')).toBeTruthy()
|
||||
expect(await screen.findByText('No delegated OAuth grants yet')).toBeTruthy()
|
||||
expect(screen.queryByLabelText('settings.agentAccess.nameLabel')).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.create' }))
|
||||
@@ -296,6 +336,124 @@ describe('Agent Access settings page', () => {
|
||||
expect(await screen.findByText('Owner or admin access is required')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'settings.agentAccess.create' }).hasAttribute('disabled')).toBe(true)
|
||||
})
|
||||
|
||||
it('lists delegated OAuth grants and revokes them server-side', async () => {
|
||||
state.grants = [
|
||||
{
|
||||
id: 'grant-1',
|
||||
clientId: 'zpan-agent',
|
||||
clientName: 'ZPan Agent',
|
||||
userId: 'user-1',
|
||||
orgId: 'org-1',
|
||||
workspaceName: 'Personal',
|
||||
scopes: ['objects:read', 'shares:create'],
|
||||
createdAt: '2026-07-29T12:00:00.000Z',
|
||||
lastUsedAt: '2026-07-29T12:10:00.000Z',
|
||||
status: 'active',
|
||||
},
|
||||
]
|
||||
vi.mocked(revokeAgentOAuthGrant).mockResolvedValue(undefined)
|
||||
|
||||
renderWithQuery(<AgentAccessSettingsPage />)
|
||||
|
||||
expect(await screen.findByText('Delegated OAuth Grants')).toBeTruthy()
|
||||
expect(await screen.findByText('ZPan Agent')).toBeTruthy()
|
||||
expect(screen.getByText('Files: read objects')).toBeTruthy()
|
||||
expect(screen.getByText('Shares: create shares')).toBeTruthy()
|
||||
|
||||
const revokeButtons = screen.getAllByRole('button', { name: 'settings.agentAccess.revoke' })
|
||||
fireEvent.click(revokeButtons[revokeButtons.length - 1]!)
|
||||
const dialog = await screen.findByRole('dialog', { name: 'Revoke OAuth Grant' })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'settings.agentAccess.revoke' }))
|
||||
|
||||
await waitFor(() => expect(revokeAgentOAuthGrant).toHaveBeenCalledWith('grant-1'))
|
||||
expect(toast.success).toHaveBeenCalledWith('OAuth grant revoked')
|
||||
})
|
||||
|
||||
it('renders OAuth consent from server context and submits full approval', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/settings/agent-access?client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback&response_type=code&scope=openid%20offline_access%20objects%3Aread%20quota%3Aread',
|
||||
)
|
||||
vi.mocked(getAgentOAuthConsentContext).mockResolvedValue({
|
||||
clientId: 'zpan-agent',
|
||||
clientName: 'ZPan Agent',
|
||||
instanceOrigin: 'https://zpan.example.test',
|
||||
workspace: { id: 'org-1', name: 'Personal' },
|
||||
scopes: ['objects:read', 'quota:read'],
|
||||
standardScopes: ['openid', 'offline_access'],
|
||||
redirectUri: 'http://127.0.0.1:8484/callback',
|
||||
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
|
||||
})
|
||||
vi.mocked(submitAgentOAuthConsent).mockResolvedValue({ url: 'http://127.0.0.1:8484/callback?code=abc' })
|
||||
|
||||
renderWithQuery(<AgentAccessSettingsPage />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Authorize ZPan Agent' })).toBeTruthy()
|
||||
expect(screen.getByText('https://zpan.example.test')).toBeTruthy()
|
||||
expect(screen.getByText('http://127.0.0.1:8484/callback')).toBeTruthy()
|
||||
expect(screen.getByText('Files: read objects')).toBeTruthy()
|
||||
expect(screen.getByText('Quota: read workspace quota')).toBeTruthy()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Approve Access' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(submitAgentOAuthConsent).toHaveBeenCalledWith({
|
||||
accept: true,
|
||||
oauthQuery: window.location.search.slice(1),
|
||||
}),
|
||||
)
|
||||
expect(redirectExternal).toHaveBeenCalledWith('http://127.0.0.1:8484/callback?code=abc')
|
||||
})
|
||||
|
||||
it('switches active workspace before OAuth consent and supports denial', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/settings/agent-access?client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback&response_type=code&scope=objects%3Aread',
|
||||
)
|
||||
vi.mocked(getAgentOAuthConsentContext).mockResolvedValue({
|
||||
clientId: 'zpan-agent',
|
||||
clientName: 'ZPan Agent',
|
||||
instanceOrigin: 'https://zpan.example.test',
|
||||
workspace: { id: 'org-1', name: 'Personal' },
|
||||
scopes: ['objects:read'],
|
||||
standardScopes: [],
|
||||
redirectUri: 'http://127.0.0.1:8484/callback',
|
||||
grantLifetime: { accessTokenSeconds: 900, refreshTokenSeconds: 2_592_000 },
|
||||
})
|
||||
vi.mocked(submitAgentOAuthConsent).mockResolvedValue({ url: 'http://127.0.0.1:8484/callback?error=access_denied' })
|
||||
|
||||
renderWithQuery(<AgentAccessSettingsPage />)
|
||||
await screen.findByRole('heading', { name: 'Authorize ZPan Agent' })
|
||||
|
||||
fireEvent.click(screen.getByRole('combobox'))
|
||||
fireEvent.click(await screen.findByRole('option', { name: 'Team Alpha' }))
|
||||
await waitFor(() => expect(setActive).toHaveBeenCalledWith({ organizationId: 'org-2' }))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Deny' }))
|
||||
await waitFor(() =>
|
||||
expect(submitAgentOAuthConsent).toHaveBeenCalledWith({
|
||||
accept: false,
|
||||
oauthQuery: window.location.search.slice(1),
|
||||
}),
|
||||
)
|
||||
expect(redirectExternal).toHaveBeenCalledWith('http://127.0.0.1:8484/callback?error=access_denied')
|
||||
})
|
||||
|
||||
it('shows an expired OAuth request state when the consent context fails', async () => {
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
'/settings/agent-access?client_id=zpan-agent&redirect_uri=http%3A%2F%2F127.0.0.1%3A8484%2Fcallback',
|
||||
)
|
||||
vi.mocked(getAgentOAuthConsentContext).mockRejectedValue(new Error('expired'))
|
||||
|
||||
renderWithQuery(<AgentAccessSettingsPage />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'OAuth request expired' })).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Settings layout tabs', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type AgentGrantableScope, agentApiKeyShortcutOptions, agentScopeLabels } from '@shared/schemas'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { Copy, KeyRound, Plus, RotateCw, Trash2 } from 'lucide-react'
|
||||
import { Check, Copy, KeyRound, Plug, Plus, RotateCw, ShieldAlert, Trash2, X } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
@@ -21,8 +21,20 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { type AgentApiKey, createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '@/lib/api'
|
||||
import { useListOrganizations } from '@/lib/auth-client'
|
||||
import {
|
||||
type AgentApiKey,
|
||||
type AgentOAuthGrant,
|
||||
createAgentApiKey,
|
||||
getAgentOAuthConsentContext,
|
||||
listAgentApiKeys,
|
||||
listAgentOAuthGrants,
|
||||
revokeAgentApiKey,
|
||||
revokeAgentOAuthGrant,
|
||||
rotateAgentApiKey,
|
||||
submitAgentOAuthConsent,
|
||||
} from '@/lib/api'
|
||||
import { setActive, useListOrganizations } from '@/lib/auth-client'
|
||||
import { redirectExternal } from '@/lib/browser-navigation'
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/settings/agent-access')({
|
||||
component: AgentAccessSettingsPage,
|
||||
@@ -54,6 +66,13 @@ function formatDate(value: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : null
|
||||
}
|
||||
|
||||
function oauthQueryFromLocation(): string {
|
||||
if (typeof window === 'undefined') return ''
|
||||
const query = window.location.search.slice(1)
|
||||
const params = new URLSearchParams(query)
|
||||
return params.has('client_id') && params.has('redirect_uri') ? query : ''
|
||||
}
|
||||
|
||||
function CopyButton({ value }: { value: string }) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
@@ -267,11 +286,281 @@ function RevokeAgentKeyDialog({ apiKey, onClose }: { apiKey: AgentApiKey | null;
|
||||
)
|
||||
}
|
||||
|
||||
function RevokeAgentOAuthGrantDialog({ grant, onClose }: { grant: AgentOAuthGrant | null; onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!grant) return
|
||||
await revokeAgentOAuthGrant(grant.id)
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['agent-oauth-grants'] })
|
||||
toast.success(t('settings.agentAccess.oauthGrantRevokeSuccess'))
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => toast.error(err.message),
|
||||
})
|
||||
if (!grant) return null
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('settings.agentAccess.oauthGrantRevokeTitle')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('settings.agentAccess.oauthGrantRevokeConfirm', {
|
||||
client: grant.clientName,
|
||||
workspace: grant.workspaceName ?? grant.orgId,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={revokeMutation.isPending}
|
||||
onClick={() => revokeMutation.mutate()}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
{revokeMutation.isPending ? t('common.loading') : t('settings.agentAccess.revoke')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentOAuthConsentPanel({ oauthQuery, organizations }: { oauthQuery: string; organizations: Organization[] }) {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const [switchingOrgId, setSwitchingOrgId] = useState<string | null>(null)
|
||||
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||
const consentQuery = useQuery({
|
||||
queryKey: ['agent-oauth-consent', oauthQuery],
|
||||
queryFn: () => getAgentOAuthConsentContext(oauthQuery),
|
||||
enabled: !!oauthQuery,
|
||||
retry: false,
|
||||
})
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: (accept: boolean) => submitAgentOAuthConsent({ accept, oauthQuery }),
|
||||
onSuccess: (result) => redirectExternal(result.url),
|
||||
onError: (err) => setSubmitError(err instanceof Error ? err.message : t('settings.agentAccess.oauthConsentFailed')),
|
||||
})
|
||||
|
||||
async function changeWorkspace(nextOrgId: string) {
|
||||
setSwitchingOrgId(nextOrgId)
|
||||
setSubmitError(null)
|
||||
try {
|
||||
const { error } = await setActive({ organizationId: nextOrgId })
|
||||
if (error) throw error
|
||||
await queryClient.invalidateQueries({ queryKey: ['agent-oauth-consent', oauthQuery] })
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : t('settings.agentAccess.oauthWorkspaceFailed'))
|
||||
} finally {
|
||||
setSwitchingOrgId(null)
|
||||
}
|
||||
}
|
||||
|
||||
if (consentQuery.isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl rounded-md border bg-background p-6">
|
||||
<p className="text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (consentQuery.isError || !consentQuery.data) {
|
||||
return (
|
||||
<div className="max-w-3xl rounded-md border bg-background p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<ShieldAlert className="mt-0.5 size-5 text-destructive" />
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold">{t('settings.agentAccess.oauthExpiredTitle')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.agentAccess.oauthExpiredDescription')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const context = consentQuery.data
|
||||
const lifetimeDays = Math.round(context.grantLifetime.refreshTokenSeconds / 86400)
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl rounded-md border bg-background p-6 shadow-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||
<Plug className="size-4" />
|
||||
{t('settings.agentAccess.oauthConsentEyebrow')}
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">{t('settings.agentAccess.oauthConsentTitle')}</h1>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.agentAccess.oauthConsentDescription')}</p>
|
||||
</div>
|
||||
|
||||
<dl className="mt-6 grid gap-4 rounded-md border bg-muted/30 p-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthClient')}</dt>
|
||||
<dd className="mt-1 font-medium">{context.clientName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthOrigin')}</dt>
|
||||
<dd className="mt-1 break-all font-medium">{context.instanceOrigin}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthReturn')}</dt>
|
||||
<dd className="mt-1 break-all font-medium">{context.redirectUri}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-muted-foreground">{t('settings.agentAccess.oauthLifetime')}</dt>
|
||||
<dd className="mt-1 font-medium">{t('settings.agentAccess.oauthLifetimeValue', { days: lifetimeDays })}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="mt-6 space-y-2">
|
||||
<Label htmlFor="agent-oauth-workspace">{t('settings.agentAccess.workspaceLabel')}</Label>
|
||||
<Select value={context.workspace.id} onValueChange={changeWorkspace} disabled={!!switchingOrgId}>
|
||||
<SelectTrigger id="agent-oauth-workspace">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{organizations.map((org) => (
|
||||
<SelectItem key={org.id} value={org.id}>
|
||||
{org.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<h2 className="text-base font-semibold">{t('settings.agentAccess.oauthScopesTitle')}</h2>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{context.scopes.map((scope) => (
|
||||
<div key={scope} className="rounded-md border px-3 py-2 text-sm">
|
||||
{t(agentScopeLabels[scope])}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('settings.agentAccess.oauthEffects')}</p>
|
||||
</div>
|
||||
|
||||
{submitError ? (
|
||||
<p role="alert" className="mt-4 text-sm text-destructive">
|
||||
{submitError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={submitMutation.isPending || !!switchingOrgId}
|
||||
onClick={() => submitMutation.mutate(false)}
|
||||
>
|
||||
<X className="size-4" />
|
||||
{t('settings.agentAccess.oauthDeny')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={submitMutation.isPending || !!switchingOrgId}
|
||||
onClick={() => submitMutation.mutate(true)}
|
||||
>
|
||||
<Check className="size-4" />
|
||||
{submitMutation.isPending ? t('common.loading') : t('settings.agentAccess.oauthApprove')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AgentOAuthGrantsSection() {
|
||||
const { t } = useTranslation()
|
||||
const [revokingGrant, setRevokingGrant] = useState<AgentOAuthGrant | null>(null)
|
||||
const grantsQuery = useQuery({
|
||||
queryKey: ['agent-oauth-grants'],
|
||||
queryFn: listAgentOAuthGrants,
|
||||
})
|
||||
const grants = grantsQuery.data?.items ?? []
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.agentAccess.oauthGrantsSection')}</CardTitle>
|
||||
<CardDescription>{t('settings.agentAccess.oauthGrantsDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{grantsQuery.isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
|
||||
) : grantsQuery.isError ? (
|
||||
<p className="py-6 text-center text-sm text-destructive">{t('settings.agentAccess.oauthGrantsError')}</p>
|
||||
) : grants.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-muted-foreground">{t('settings.agentAccess.oauthNoGrants')}</p>
|
||||
) : (
|
||||
<Table className="min-w-[760px]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t('settings.agentAccess.oauthClient')}</TableHead>
|
||||
<TableHead>{t('settings.agentAccess.colWorkspace')}</TableHead>
|
||||
<TableHead>{t('settings.agentAccess.colScopes')}</TableHead>
|
||||
<TableHead>{t('settings.agentAccess.colCreated')}</TableHead>
|
||||
<TableHead>{t('settings.agentAccess.colLastUsed')}</TableHead>
|
||||
<TableHead>{t('settings.agentAccess.colStatus')}</TableHead>
|
||||
<TableHead className="w-20 text-right">{t('settings.agentAccess.colActions')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{grants.map((grant) => (
|
||||
<TableRow key={grant.id}>
|
||||
<TableCell className="font-medium">{grant.clientName}</TableCell>
|
||||
<TableCell>{grant.workspaceName ?? grant.orgId}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex max-w-md flex-wrap gap-1">
|
||||
{grant.scopes.map((scope) => (
|
||||
<Badge key={scope} variant="secondary">
|
||||
{t(agentScopeLabels[scope])}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">{formatDate(grant.createdAt)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
{formatDate(grant.lastUsedAt) ?? t('settings.agentAccess.never')}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge>{t('settings.agentAccess.status.active')}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right whitespace-nowrap">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={t('settings.agentAccess.revoke')}
|
||||
onClick={() => setRevokingGrant(grant)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
<span className="sr-only">{t('settings.agentAccess.revoke')}</span>
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
<RevokeAgentOAuthGrantDialog grant={revokingGrant} onClose={() => setRevokingGrant(null)} />
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function AgentAccessSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const queryClient = useQueryClient()
|
||||
const { data: organizationData } = useListOrganizations()
|
||||
const organizations = (organizationData ?? []) as Organization[]
|
||||
const oauthQuery = oauthQueryFromLocation()
|
||||
const [orgId, setOrgId] = useState('')
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [revealedKey, setRevealedKey] = useState<RevealedKey | null>(null)
|
||||
@@ -300,8 +589,10 @@ export function AgentAccessSettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (oauthQuery) return <AgentOAuthConsentPanel oauthQuery={oauthQuery} organizations={organizations} />
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl">
|
||||
<div className="max-w-6xl space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('settings.agentAccess.section')}</CardTitle>
|
||||
@@ -414,6 +705,7 @@ export function AgentAccessSettingsPage() {
|
||||
<CreateAgentKeyDialog open={createOpen} orgId={orgId} onOpenChange={setCreateOpen} onCreated={setRevealedKey} />
|
||||
<RevealedKeyDialog revealedKey={revealedKey} onClose={() => setRevealedKey(null)} />
|
||||
<RevokeAgentKeyDialog apiKey={revoking} onClose={() => setRevoking(null)} />
|
||||
<AgentOAuthGrantsSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user