Let agents drive the flow API over MCP
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
Playwright Tests / test-playwright (1, 2) (push) Canceled after 0s
Playwright Tests / test-playwright (2, 2) (push) Canceled after 0s
pre-commit / pre-commit (push) Canceled after 0s
Test Backend / test-backend (push) Canceled after 0s
Compose Smoke Test / test-compose (push) Canceled after 0s
Playwright Tests / merge-reports (push) Canceled after 0s
The engine now speaks MCP at /mcp, with a built-in OAuth 2.1 authorization server in front of it: an agent registers itself, sends a human to the browser to approve it, and exchanges the resulting code for a token. PKCE is required, codes are single-use and stored only as hashes, the browser is redirected to the URI that was registered rather than the one asked for, and refresh tokens rotate so that replaying a spent one revokes the whole line. Twenty tools cover reading, building, publishing and running flows, and each one calls the same REST endpoint the dashboard calls, in-process, carrying the caller's own token. That keeps one description of what a flow is and what may be done to it — validation, the draft/publish split, the version check — and means an agent can do nothing a person could not do in the browser. Agent tokens are RS256 with a keypair of their own rather than the secret that signs browser sessions, so deleting the key withdraws every agent without logging anyone out, and deps.decode_token grew the branch that trusting a second issuer will need when the hosted login arrives. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3724b68f23
commit
8d82d6c4ec
@@ -57,6 +57,84 @@ export const Body_login_login_access_tokenSchema = {
|
||||
title: 'Body_login-login_access_token'
|
||||
} as const;
|
||||
|
||||
export const Body_oauth_tokenSchema = {
|
||||
properties: {
|
||||
grant_type: {
|
||||
type: 'string',
|
||||
title: 'Grant Type'
|
||||
},
|
||||
code: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Code'
|
||||
},
|
||||
redirect_uri: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Redirect Uri'
|
||||
},
|
||||
client_id: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Client Id'
|
||||
},
|
||||
code_verifier: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Code Verifier'
|
||||
},
|
||||
refresh_token: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Refresh Token'
|
||||
},
|
||||
resource: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Resource'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['grant_type'],
|
||||
title: 'Body_oauth-token'
|
||||
} as const;
|
||||
|
||||
export const DTypeSchema = {
|
||||
type: 'string',
|
||||
enum: ['float', 'int', 'str', 'bool', 'json'],
|
||||
@@ -702,6 +780,158 @@ export const NodeTypeInfoSchema = {
|
||||
description: 'A node type the editor can offer, with its parameter schema.'
|
||||
} as const;
|
||||
|
||||
export const OAuthAuthorizeInfoSchema = {
|
||||
properties: {
|
||||
client_name: {
|
||||
type: 'string',
|
||||
title: 'Client Name'
|
||||
},
|
||||
redirect_uri: {
|
||||
type: 'string',
|
||||
title: 'Redirect Uri'
|
||||
},
|
||||
scope: {
|
||||
type: 'string',
|
||||
title: 'Scope'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['client_name', 'redirect_uri', 'scope'],
|
||||
title: 'OAuthAuthorizeInfo',
|
||||
description: 'What the consent page shows, all of it validated server-side.'
|
||||
} as const;
|
||||
|
||||
export const OAuthAuthorizeRequestSchema = {
|
||||
properties: {
|
||||
client_id: {
|
||||
type: 'string',
|
||||
title: 'Client Id'
|
||||
},
|
||||
redirect_uri: {
|
||||
type: 'string',
|
||||
title: 'Redirect Uri'
|
||||
},
|
||||
code_challenge: {
|
||||
type: 'string',
|
||||
title: 'Code Challenge'
|
||||
},
|
||||
code_challenge_method: {
|
||||
type: 'string',
|
||||
title: 'Code Challenge Method',
|
||||
default: 'S256'
|
||||
},
|
||||
state: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'State'
|
||||
},
|
||||
resource: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Resource'
|
||||
},
|
||||
scope: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Scope'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['client_id', 'redirect_uri', 'code_challenge'],
|
||||
title: 'OAuthAuthorizeRequest'
|
||||
} as const;
|
||||
|
||||
export const OAuthAuthorizeResponseSchema = {
|
||||
properties: {
|
||||
redirect_url: {
|
||||
type: 'string',
|
||||
title: 'Redirect Url'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['redirect_url'],
|
||||
title: 'OAuthAuthorizeResponse'
|
||||
} as const;
|
||||
|
||||
export const OAuthClientRegisterSchema = {
|
||||
properties: {
|
||||
client_name: {
|
||||
type: 'string',
|
||||
maxLength: 128,
|
||||
title: 'Client Name',
|
||||
default: 'MCP client'
|
||||
},
|
||||
redirect_uris: {
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Redirect Uris'
|
||||
},
|
||||
grant_types: {
|
||||
anyOf: [
|
||||
{
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Grant Types'
|
||||
},
|
||||
response_types: {
|
||||
anyOf: [
|
||||
{
|
||||
items: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'array'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Response Types'
|
||||
},
|
||||
token_endpoint_auth_method: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Token Endpoint Auth Method'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['redirect_uris'],
|
||||
title: 'OAuthClientRegister',
|
||||
description: 'RFC 7591 dynamic client registration request.'
|
||||
} as const;
|
||||
|
||||
export const PositionSchema = {
|
||||
properties: {
|
||||
x: {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import type { CancelablePromise } from './core/CancelablePromise';
|
||||
import { OpenAPI } from './core/OpenAPI';
|
||||
import { request as __request } from './core/request';
|
||||
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||
import type { FlowsReadFlowsResponse, FlowsReadNodeTypesResponse, FlowsReadLibraryResponse, FlowsDeleteSharedNodeData, FlowsDeleteSharedNodeResponse, FlowsReadFlowData, FlowsReadFlowResponse, FlowsSaveFlowData, FlowsSaveFlowResponse, FlowsDeleteFlowData, FlowsDeleteFlowResponse, FlowsPublishFlowData, FlowsPublishFlowResponse, FlowsDiscardDraftData, FlowsDiscardDraftResponse, FlowsRenameFlowData, FlowsRenameFlowResponse, FlowsReadNodeSourceData, FlowsReadNodeSourceResponse, FlowsSaveNodeSourceData, FlowsSaveNodeSourceResponse, FlowsShareNodeData, FlowsShareNodeResponse, FlowsUnshareNodeData, FlowsUnshareNodeResponse, FlowsStartFlowData, FlowsStartFlowResponse, FlowsStopFlowData, FlowsStopFlowResponse, FlowsPauseFlowData, FlowsPauseFlowResponse, FlowsResumeFlowData, FlowsResumeFlowResponse, FlowsValidateFlowData, FlowsValidateFlowResponse, FlowsRunFlowData, FlowsRunFlowResponse, FlowsTriggerNodeData, FlowsTriggerNodeResponse, FlowsReadFlowStateData, FlowsReadFlowStateResponse, FlowsReadMessageHistoryData, FlowsReadMessageHistoryResponse, LoginLoginAccessTokenData, LoginLoginAccessTokenResponse, LoginTestTokenResponse, LoginRecoverPasswordData, LoginRecoverPasswordResponse, LoginResetPasswordData, LoginResetPasswordResponse, LoginRecoverPasswordHtmlContentData, LoginRecoverPasswordHtmlContentResponse, OauthRegisterClientData, OauthRegisterClientResponse, OauthAuthorizeValidateData, OauthAuthorizeValidateResponse, OauthAuthorizeData, OauthAuthorizeResponse, OauthTokenData, OauthTokenResponse, PrivateCreateUserData, PrivateCreateUserResponse, SecretsReadSecretsResponse, SecretsSaveSecretData, SecretsSaveSecretResponse, SecretsDeleteSecretData, SecretsDeleteSecretResponse, UsersReadUsersData, UsersReadUsersResponse, UsersCreateUserData, UsersCreateUserResponse, UsersReadUserMeResponse, UsersDeleteUserMeResponse, UsersUpdateUserMeData, UsersUpdateUserMeResponse, UsersUpdatePasswordMeData, UsersUpdatePasswordMeResponse, UsersRegisterUserData, UsersRegisterUserResponse, UsersReadUserByIdData, UsersReadUserByIdResponse, UsersUpdateUserData, UsersUpdateUserResponse, UsersDeleteUserData, UsersDeleteUserResponse, UtilsTestEmailData, UtilsTestEmailResponse, UtilsHealthCheckResponse } from './types.gen';
|
||||
|
||||
export class FlowsService {
|
||||
/**
|
||||
@@ -615,6 +615,94 @@ export class LoginService {
|
||||
}
|
||||
}
|
||||
|
||||
export class OauthService {
|
||||
/**
|
||||
* Register Client
|
||||
* RFC 7591 dynamic client registration.
|
||||
*
|
||||
* Open on purpose, and harmless on its own: a registered client can do
|
||||
* nothing until a signed-in human approves it on the consent page.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns unknown Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static registerClient(data: OauthRegisterClientData): CancelablePromise<OauthRegisterClientResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/oauth/register',
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize Validate
|
||||
* What the consent page should say, checked before it says it.
|
||||
* @param data The data for the request.
|
||||
* @param data.clientId
|
||||
* @param data.redirectUri
|
||||
* @returns OAuthAuthorizeInfo Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static authorizeValidate(data: OauthAuthorizeValidateData): CancelablePromise<OauthAuthorizeValidateResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'GET',
|
||||
url: '/api/v1/oauth/authorize/validate',
|
||||
query: {
|
||||
client_id: data.clientId,
|
||||
redirect_uri: data.redirectUri
|
||||
},
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Authorize
|
||||
* Approve a client, on behalf of the signed-in user.
|
||||
* @param data The data for the request.
|
||||
* @param data.requestBody
|
||||
* @returns OAuthAuthorizeResponse Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static authorize(data: OauthAuthorizeData): CancelablePromise<OauthAuthorizeResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/oauth/authorize',
|
||||
body: data.requestBody,
|
||||
mediaType: 'application/json',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Token
|
||||
* Exchange a code, or a refresh token, for an access token.
|
||||
* @param data The data for the request.
|
||||
* @param data.formData
|
||||
* @returns unknown Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
public static token(data: OauthTokenData): CancelablePromise<OauthTokenResponse> {
|
||||
return __request(OpenAPI, {
|
||||
method: 'POST',
|
||||
url: '/api/v1/oauth/token',
|
||||
formData: data.formData,
|
||||
mediaType: 'application/x-www-form-urlencoded',
|
||||
errors: {
|
||||
422: 'Validation Error'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class PrivateService {
|
||||
/**
|
||||
* Create User
|
||||
|
||||
@@ -9,6 +9,16 @@ export type Body_login_login_access_token = {
|
||||
client_secret?: (string | null);
|
||||
};
|
||||
|
||||
export type Body_oauth_token = {
|
||||
grant_type: string;
|
||||
code?: (string | null);
|
||||
redirect_uri?: (string | null);
|
||||
client_id?: (string | null);
|
||||
code_verifier?: (string | null);
|
||||
refresh_token?: (string | null);
|
||||
resource?: (string | null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Serializable payload types.
|
||||
*
|
||||
@@ -228,6 +238,40 @@ export type NodeTypeInfo = {
|
||||
plugin?: (string | null);
|
||||
};
|
||||
|
||||
/**
|
||||
* What the consent page shows, all of it validated server-side.
|
||||
*/
|
||||
export type OAuthAuthorizeInfo = {
|
||||
client_name: string;
|
||||
redirect_uri: string;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
export type OAuthAuthorizeRequest = {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
code_challenge: string;
|
||||
code_challenge_method?: string;
|
||||
state?: (string | null);
|
||||
resource?: (string | null);
|
||||
scope?: (string | null);
|
||||
};
|
||||
|
||||
export type OAuthAuthorizeResponse = {
|
||||
redirect_url: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* RFC 7591 dynamic client registration request.
|
||||
*/
|
||||
export type OAuthClientRegister = {
|
||||
client_name?: string;
|
||||
redirect_uris: Array<(string)>;
|
||||
grant_types?: (Array<(string)> | null);
|
||||
response_types?: (Array<(string)> | null);
|
||||
token_endpoint_auth_method?: (string | null);
|
||||
};
|
||||
|
||||
/**
|
||||
* Where a node sits on the canvas.
|
||||
*/
|
||||
@@ -517,6 +561,31 @@ export type LoginRecoverPasswordHtmlContentData = {
|
||||
|
||||
export type LoginRecoverPasswordHtmlContentResponse = (string);
|
||||
|
||||
export type OauthRegisterClientData = {
|
||||
requestBody: OAuthClientRegister;
|
||||
};
|
||||
|
||||
export type OauthRegisterClientResponse = (unknown);
|
||||
|
||||
export type OauthAuthorizeValidateData = {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
};
|
||||
|
||||
export type OauthAuthorizeValidateResponse = (OAuthAuthorizeInfo);
|
||||
|
||||
export type OauthAuthorizeData = {
|
||||
requestBody: OAuthAuthorizeRequest;
|
||||
};
|
||||
|
||||
export type OauthAuthorizeResponse = (OAuthAuthorizeResponse);
|
||||
|
||||
export type OauthTokenData = {
|
||||
formData: Body_oauth_token;
|
||||
};
|
||||
|
||||
export type OauthTokenResponse = (unknown);
|
||||
|
||||
export type PrivateCreateUserData = {
|
||||
requestBody: PrivateUserCreate;
|
||||
};
|
||||
|
||||
@@ -15,6 +15,17 @@ const isLoggedIn = () => {
|
||||
return localStorage.getItem("access_token") !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where to go after signing in.
|
||||
*
|
||||
* Only a path on this origin: an open redirect here would let a link take
|
||||
* someone through a real login and land them somewhere else entirely.
|
||||
*/
|
||||
export function safeRedirect(target: string | undefined): string {
|
||||
if (!target || !target.startsWith("/") || target.startsWith("//")) return "/"
|
||||
return target
|
||||
}
|
||||
|
||||
const useAuth = () => {
|
||||
const navigate = useNavigate()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -48,7 +59,9 @@ const useAuth = () => {
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: login,
|
||||
onSuccess: () => {
|
||||
navigate({ to: "/" })
|
||||
// The consent page sends people here with where to come back to.
|
||||
const target = new URLSearchParams(window.location.search).get("redirect")
|
||||
navigate({ to: safeRedirect(target ?? undefined) })
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
})
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Route as LoginRouteImport } from './routes/login'
|
||||
import { Route as LayoutRouteImport } from './routes/_layout'
|
||||
import { Route as CanvasRouteImport } from './routes/_canvas'
|
||||
import { Route as LayoutIndexRouteImport } from './routes/_layout/index'
|
||||
import { Route as OauthAuthorizeRouteImport } from './routes/oauth.authorize'
|
||||
import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings'
|
||||
import { Route as LayoutAdminRouteImport } from './routes/_layout/admin'
|
||||
import { Route as CanvasFlowsIndexRouteImport } from './routes/_canvas/flows/index'
|
||||
@@ -54,6 +55,11 @@ const LayoutIndexRoute = LayoutIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => LayoutRoute,
|
||||
} as any)
|
||||
const OauthAuthorizeRoute = OauthAuthorizeRouteImport.update({
|
||||
id: '/oauth/authorize',
|
||||
path: '/oauth/authorize',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const LayoutSettingsRoute = LayoutSettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
@@ -83,6 +89,7 @@ export interface FileRoutesByFullPath {
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||
'/flows/': typeof CanvasFlowsIndexRoute
|
||||
}
|
||||
@@ -94,6 +101,7 @@ export interface FileRoutesByTo {
|
||||
'/signup': typeof SignupRoute
|
||||
'/admin': typeof LayoutAdminRoute
|
||||
'/settings': typeof LayoutSettingsRoute
|
||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||
'/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||
'/flows': typeof CanvasFlowsIndexRoute
|
||||
}
|
||||
@@ -107,6 +115,7 @@ export interface FileRoutesById {
|
||||
'/signup': typeof SignupRoute
|
||||
'/_layout/admin': typeof LayoutAdminRoute
|
||||
'/_layout/settings': typeof LayoutSettingsRoute
|
||||
'/oauth/authorize': typeof OauthAuthorizeRoute
|
||||
'/_layout/': typeof LayoutIndexRoute
|
||||
'/_canvas/flows/$flowName': typeof CanvasFlowsFlowNameRoute
|
||||
'/_canvas/flows/': typeof CanvasFlowsIndexRoute
|
||||
@@ -121,6 +130,7 @@ export interface FileRouteTypes {
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/settings'
|
||||
| '/oauth/authorize'
|
||||
| '/flows/$flowName'
|
||||
| '/flows/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
@@ -132,6 +142,7 @@ export interface FileRouteTypes {
|
||||
| '/signup'
|
||||
| '/admin'
|
||||
| '/settings'
|
||||
| '/oauth/authorize'
|
||||
| '/flows/$flowName'
|
||||
| '/flows'
|
||||
id:
|
||||
@@ -144,6 +155,7 @@ export interface FileRouteTypes {
|
||||
| '/signup'
|
||||
| '/_layout/admin'
|
||||
| '/_layout/settings'
|
||||
| '/oauth/authorize'
|
||||
| '/_layout/'
|
||||
| '/_canvas/flows/$flowName'
|
||||
| '/_canvas/flows/'
|
||||
@@ -156,6 +168,7 @@ export interface RootRouteChildren {
|
||||
RecoverPasswordRoute: typeof RecoverPasswordRoute
|
||||
ResetPasswordRoute: typeof ResetPasswordRoute
|
||||
SignupRoute: typeof SignupRoute
|
||||
OauthAuthorizeRoute: typeof OauthAuthorizeRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -209,6 +222,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof LayoutIndexRouteImport
|
||||
parentRoute: typeof LayoutRoute
|
||||
}
|
||||
'/oauth/authorize': {
|
||||
id: '/oauth/authorize'
|
||||
path: '/oauth/authorize'
|
||||
fullPath: '/oauth/authorize'
|
||||
preLoaderRoute: typeof OauthAuthorizeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_layout/settings': {
|
||||
id: '/_layout/settings'
|
||||
path: '/settings'
|
||||
@@ -275,6 +295,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
RecoverPasswordRoute: RecoverPasswordRoute,
|
||||
ResetPasswordRoute: ResetPasswordRoute,
|
||||
SignupRoute: SignupRoute,
|
||||
OauthAuthorizeRoute: OauthAuthorizeRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { LoadingButton } from "@/components/ui/loading-button"
|
||||
import { PasswordInput } from "@/components/ui/password-input"
|
||||
import useAuth, { isLoggedIn } from "@/hooks/useAuth"
|
||||
import useAuth, { isLoggedIn, safeRedirect } from "@/hooks/useAuth"
|
||||
|
||||
const formSchema = z.object({
|
||||
username: z.email(),
|
||||
@@ -34,10 +34,13 @@ type FormData = z.infer<typeof formSchema>
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: Login,
|
||||
beforeLoad: async () => {
|
||||
// Optional, so every other `navigate({ to: "/login" })` stays as it was.
|
||||
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
||||
typeof search.redirect === "string" ? { redirect: search.redirect } : {},
|
||||
beforeLoad: async ({ search }) => {
|
||||
if (isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/",
|
||||
to: safeRedirect(search.redirect),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router"
|
||||
import { AlertTriangle } from "lucide-react"
|
||||
|
||||
import { OauthService } from "@/client"
|
||||
import { AuthLayout } from "@/components/Common/AuthLayout"
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LoadingButton } from "@/components/ui/loading-button"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { isLoggedIn } from "@/hooks/useAuth"
|
||||
|
||||
/**
|
||||
* Where an agent sends someone to be let in.
|
||||
*
|
||||
* Everything shown here is checked by the server first, and the URL the browser
|
||||
* is finally sent to is the one the server hands back — never the one in the
|
||||
* address bar — so a doctored link cannot redirect an approval somewhere else.
|
||||
*/
|
||||
export const Route = createFileRoute("/oauth/authorize")({
|
||||
component: Authorize,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
client_id: String(search.client_id ?? ""),
|
||||
redirect_uri: String(search.redirect_uri ?? ""),
|
||||
code_challenge: String(search.code_challenge ?? ""),
|
||||
code_challenge_method: String(search.code_challenge_method ?? "S256"),
|
||||
state: search.state ? String(search.state) : undefined,
|
||||
resource: search.resource ? String(search.resource) : undefined,
|
||||
}),
|
||||
beforeLoad: ({ location }) => {
|
||||
if (!isLoggedIn()) {
|
||||
throw redirect({
|
||||
to: "/login",
|
||||
search: { redirect: location.href },
|
||||
})
|
||||
}
|
||||
},
|
||||
head: () => ({
|
||||
meta: [{ title: "Authorize - Fluksio" }],
|
||||
}),
|
||||
})
|
||||
|
||||
function Authorize() {
|
||||
const search = Route.useSearch()
|
||||
|
||||
const {
|
||||
data: info,
|
||||
isPending,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ["oauth", "authorize", search.client_id, search.redirect_uri],
|
||||
queryFn: () =>
|
||||
OauthService.authorizeValidate({
|
||||
clientId: search.client_id,
|
||||
redirectUri: search.redirect_uri,
|
||||
}),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const approve = useMutation({
|
||||
mutationFn: () =>
|
||||
OauthService.authorize({
|
||||
requestBody: {
|
||||
client_id: search.client_id,
|
||||
redirect_uri: search.redirect_uri,
|
||||
code_challenge: search.code_challenge,
|
||||
code_challenge_method: search.code_challenge_method,
|
||||
state: search.state,
|
||||
resource: search.resource,
|
||||
},
|
||||
}),
|
||||
onSuccess: (response) => {
|
||||
// The server's URL, built from the registered redirect.
|
||||
window.location.assign(response.redirect_url)
|
||||
},
|
||||
})
|
||||
|
||||
const deny = () => {
|
||||
if (!info) return
|
||||
const url = new URL(info.redirect_uri)
|
||||
url.searchParams.set("error", "access_denied")
|
||||
if (search.state) url.searchParams.set("state", search.state)
|
||||
window.location.assign(url.toString())
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
<h1 className="text-2xl font-bold">Authorize access</h1>
|
||||
</div>
|
||||
|
||||
{isPending ? (
|
||||
<div className="grid gap-3">
|
||||
<Skeleton className="h-5 w-56" />
|
||||
<Skeleton className="h-5 w-40" />
|
||||
</div>
|
||||
) : error || !info ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertDescription>
|
||||
This authorization request is not valid. Start it again from the
|
||||
application that sent you here.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{info.client_name}
|
||||
</span>{" "}
|
||||
wants to read and change your flows, and to run them. It will act
|
||||
as you. Only allow this if you started it.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<LoadingButton
|
||||
loading={approve.isPending}
|
||||
onClick={() => approve.mutate()}
|
||||
data-testid="approve-client"
|
||||
>
|
||||
Allow
|
||||
</LoadingButton>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={deny}
|
||||
disabled={approve.isPending}
|
||||
data-testid="deny-client"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{approve.isError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle />
|
||||
<AlertDescription>
|
||||
That did not work. Start again from the application.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</AuthLayout>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user