mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Implement OAuth flow in SkillSetupWizard and enhance skill manager
- Added support for OAuth in the SkillSetupWizard, allowing skills to handle OAuth login and state management. - Introduced new OAuthLoginView component for user interaction during OAuth processes. - Updated SkillManager to notify skills of successful OAuth completion and manage OAuth credentials. - Enhanced deep link handling to support OAuth success and error states. - Modified SkillManifest to include OAuth configuration details. - Updated runtime and backend to accommodate OAuth functionality, ensuring skills can utilize OAuth for authentication.
This commit is contained in:
+1
-1
Submodule skills updated: 74d3cb1a2e...9dc1855541
@@ -65,10 +65,16 @@ mod desktop {
|
||||
"autoStart": m.auto_start,
|
||||
"version": m.version,
|
||||
"description": m.description,
|
||||
"setup": m.setup.as_ref().map(|s| serde_json::json!({
|
||||
"required": s.required,
|
||||
"label": s.label,
|
||||
})),
|
||||
"setup": m.setup.as_ref().map(|s| {
|
||||
let mut obj = serde_json::json!({
|
||||
"required": s.required,
|
||||
"label": s.label,
|
||||
});
|
||||
if let Some(oauth) = &s.oauth {
|
||||
obj["oauth"] = oauth.clone();
|
||||
}
|
||||
obj
|
||||
}),
|
||||
"platforms": m.platforms,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ pub struct SkillSetup {
|
||||
#[serde(default)]
|
||||
pub required: bool,
|
||||
pub label: Option<String>,
|
||||
/// OAuth configuration (provider, scopes, apiBaseUrl).
|
||||
pub oauth: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Raw manifest as it appears on disk.
|
||||
|
||||
@@ -410,8 +410,34 @@ async fn handle_message(
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
SkillMessage::Rpc { method, params, reply } => {
|
||||
let args = serde_json::json!({ "method": method, "params": params });
|
||||
let result = handle_js_call(ctx, "onRpc", &args.to_string()).await;
|
||||
let result = match method.as_str() {
|
||||
"oauth/complete" => {
|
||||
// Set credential on the oauth bridge, then call onOAuthComplete
|
||||
let set_cred_code = format!(
|
||||
"if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {{ globalThis.oauth.__setCredential({}); }}",
|
||||
serde_json::to_string(¶ms).unwrap_or_else(|_| "null".to_string())
|
||||
);
|
||||
ctx.with(|js_ctx| {
|
||||
let _ = js_ctx.eval::<rquickjs::Value, _>(set_cred_code.as_bytes());
|
||||
}).await;
|
||||
let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
handle_js_call(ctx, "onOAuthComplete", ¶ms_str).await
|
||||
}
|
||||
"oauth/revoked" => {
|
||||
// Clear credential on the oauth bridge, then call onOAuthRevoked
|
||||
let clear_code = "if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) { globalThis.oauth.__setCredential(null); }";
|
||||
ctx.with(|js_ctx| {
|
||||
let _ = js_ctx.eval::<rquickjs::Value, _>(clear_code.as_bytes());
|
||||
}).await;
|
||||
let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
handle_js_void_call(ctx, "onOAuthRevoked", ¶ms_str).await
|
||||
.map(|()| serde_json::json!({ "ok": true }))
|
||||
}
|
||||
_ => {
|
||||
let args = serde_json::json!({ "method": method, "params": params });
|
||||
handle_js_call(ctx, "onRpc", &args.to_string()).await
|
||||
}
|
||||
};
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
}
|
||||
|
||||
+80
@@ -823,6 +823,86 @@ globalThis.data = {
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// OAuth Bridge API (credential management and authenticated proxy)
|
||||
// ============================================================================
|
||||
(function () {
|
||||
var __oauthCredential = null;
|
||||
|
||||
globalThis.oauth = {
|
||||
/** Get the current OAuth credential, or null if not connected. */
|
||||
getCredential: function () {
|
||||
return __oauthCredential;
|
||||
},
|
||||
|
||||
/**
|
||||
* Make an authenticated API request proxied through the backend.
|
||||
* Path is relative to manifest's apiBaseUrl.
|
||||
*/
|
||||
fetch: function (path, options) {
|
||||
if (!__oauthCredential) {
|
||||
return {
|
||||
status: 401,
|
||||
headers: {},
|
||||
body: JSON.stringify({ error: 'No OAuth credential. Complete OAuth setup first.' }),
|
||||
};
|
||||
}
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
|
||||
var jwtToken = __platform.env('JWT_TOKEN') || '';
|
||||
var cleanPath = path.charAt(0) === '/' ? path.slice(1) : path;
|
||||
var proxyUrl = backendUrl + '/proxy/by-id/' + __oauthCredential.credentialId + '/' + cleanPath;
|
||||
var method = (options && options.method) || 'GET';
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
if (jwtToken) {
|
||||
headers['Authorization'] = 'Bearer ' + jwtToken;
|
||||
}
|
||||
if (options && options.headers) {
|
||||
for (var k in options.headers) {
|
||||
headers[k] = options.headers[k];
|
||||
}
|
||||
}
|
||||
var fetchOpts = JSON.stringify({
|
||||
method: method,
|
||||
headers: headers,
|
||||
body: options ? options.body : undefined,
|
||||
timeout: options ? options.timeout : undefined,
|
||||
});
|
||||
var result = __net.fetch(proxyUrl, fetchOpts);
|
||||
return JSON.parse(result);
|
||||
},
|
||||
|
||||
/** Revoke the current OAuth credential server-side. */
|
||||
revoke: function () {
|
||||
if (__oauthCredential) {
|
||||
try {
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
|
||||
var jwtToken = __platform.env('JWT_TOKEN') || '';
|
||||
var revokeOpts = JSON.stringify({
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer ' + jwtToken,
|
||||
},
|
||||
});
|
||||
__net.fetch(
|
||||
backendUrl + '/auth/integrations/' + __oauthCredential.credentialId,
|
||||
revokeOpts
|
||||
);
|
||||
} catch (e) {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
__oauthCredential = null;
|
||||
return true;
|
||||
},
|
||||
|
||||
/** Internal: set credential (called by runtime on oauth/complete). */
|
||||
__setCredential: function (cred) {
|
||||
__oauthCredential = cred;
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
// Cron Bridge API (placeholder - requires integration with CronScheduler)
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
/**
|
||||
* Multi-step setup wizard for a single skill.
|
||||
* Manages the state machine: start → render form → submit → next/error/complete.
|
||||
* Manages the state machine: start -> render form -> submit -> next/error/complete.
|
||||
* For OAuth skills, shows a login button instead of form steps.
|
||||
* Ensures the skill is running (starts it if needed) before starting the setup flow.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { store } from "../../store";
|
||||
import { useAppSelector } from "../../store/hooks";
|
||||
import { skillManager } from "../../lib/skills/manager";
|
||||
import { setSkillSetupComplete } from "../../store/skillsSlice";
|
||||
import { BACKEND_URL } from "../../utils/config";
|
||||
import { openUrl } from "../../utils/openUrl";
|
||||
import type { SetupStep, SetupFieldError } from "../../lib/skills/types";
|
||||
import SetupFormRenderer from "./SetupFormRenderer";
|
||||
|
||||
@@ -16,8 +21,15 @@ interface SkillSetupWizardProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface OAuthConfig {
|
||||
provider: string;
|
||||
scopes: string[];
|
||||
}
|
||||
|
||||
type WizardState =
|
||||
| { phase: "loading" }
|
||||
| { phase: "oauth"; oauth: OAuthConfig }
|
||||
| { phase: "oauth_waiting"; oauth: OAuthConfig }
|
||||
| { phase: "step"; step: SetupStep; errors?: SetupFieldError[] | null }
|
||||
| { phase: "submitting"; step: SetupStep }
|
||||
| { phase: "complete"; message?: string }
|
||||
@@ -30,6 +42,22 @@ export default function SkillSetupWizard({
|
||||
}: SkillSetupWizardProps) {
|
||||
const [state, setState] = useState<WizardState>({ phase: "loading" });
|
||||
|
||||
// Watch skill state for OAuth completion (skill pushes connected: true)
|
||||
const skillState = useAppSelector(
|
||||
(s) => s.skills.skillStates[skillId],
|
||||
);
|
||||
|
||||
// When skill state changes to connected during OAuth waiting, mark complete
|
||||
useEffect(() => {
|
||||
if (
|
||||
(state.phase === "oauth" || state.phase === "oauth_waiting") &&
|
||||
skillState?.connected === true
|
||||
) {
|
||||
store.dispatch(setSkillSetupComplete({ skillId, complete: true }));
|
||||
setState({ phase: "complete", message: "Successfully connected!" });
|
||||
}
|
||||
}, [skillState?.connected, state.phase, skillId]);
|
||||
|
||||
// Start the skill (if not running) then start the setup flow on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -68,6 +96,20 @@ export default function SkillSetupWizard({
|
||||
throw new Error(errMsg);
|
||||
}
|
||||
|
||||
// If the skill has OAuth config, show OAuth login instead of form steps
|
||||
if (manifest.setup?.oauth) {
|
||||
if (!cancelled) {
|
||||
setState({
|
||||
phase: "oauth",
|
||||
oauth: {
|
||||
provider: manifest.setup.oauth.provider,
|
||||
scopes: manifest.setup.oauth.scopes,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[SkillSetupWizard] starting setup", skillId);
|
||||
const firstStep = await skillManager.startSetup(skillId);
|
||||
console.log("[SkillSetupWizard] setup started", skillId);
|
||||
@@ -89,6 +131,28 @@ export default function SkillSetupWizard({
|
||||
};
|
||||
}, [skillId]);
|
||||
|
||||
const handleOAuthLogin = useCallback(async () => {
|
||||
if (state.phase !== "oauth") return;
|
||||
|
||||
const { oauth } = state;
|
||||
const token = store.getState().auth.token;
|
||||
const params = new URLSearchParams({
|
||||
provider: oauth.provider,
|
||||
skill_id: skillId,
|
||||
});
|
||||
if (token) {
|
||||
params.set("token", token);
|
||||
}
|
||||
if (oauth.scopes.length > 0) {
|
||||
params.set("scopes", oauth.scopes.join(","));
|
||||
}
|
||||
|
||||
const oauthUrl = `${BACKEND_URL}/oauth/authorize?${params.toString()}`;
|
||||
await openUrl(oauthUrl);
|
||||
|
||||
setState({ phase: "oauth_waiting", oauth });
|
||||
}, [state, skillId]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (values: Record<string, unknown>) => {
|
||||
if (state.phase !== "step") return;
|
||||
@@ -132,13 +196,15 @@ export default function SkillSetupWizard({
|
||||
);
|
||||
|
||||
const handleCancel = useCallback(async () => {
|
||||
try {
|
||||
await skillManager.cancelSetup(skillId);
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
if (state.phase !== "oauth" && state.phase !== "oauth_waiting") {
|
||||
try {
|
||||
await skillManager.cancelSetup(skillId);
|
||||
} catch {
|
||||
// Ignore cancel errors
|
||||
}
|
||||
}
|
||||
onCancel();
|
||||
}, [skillId, onCancel]);
|
||||
}, [skillId, onCancel, state.phase]);
|
||||
|
||||
// Render based on current wizard state
|
||||
switch (state.phase) {
|
||||
@@ -171,6 +237,26 @@ export default function SkillSetupWizard({
|
||||
</div>
|
||||
);
|
||||
|
||||
case "oauth":
|
||||
return (
|
||||
<OAuthLoginView
|
||||
provider={state.oauth.provider}
|
||||
onLogin={handleOAuthLogin}
|
||||
onCancel={handleCancel}
|
||||
waiting={false}
|
||||
/>
|
||||
);
|
||||
|
||||
case "oauth_waiting":
|
||||
return (
|
||||
<OAuthLoginView
|
||||
provider={state.oauth.provider}
|
||||
onLogin={handleOAuthLogin}
|
||||
onCancel={handleCancel}
|
||||
waiting={true}
|
||||
/>
|
||||
);
|
||||
|
||||
case "step":
|
||||
return (
|
||||
<SetupFormRenderer
|
||||
@@ -259,3 +345,198 @@ export default function SkillSetupWizard({
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth Login View
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatProviderName(provider: string): string {
|
||||
const names: Record<string, string> = {
|
||||
notion: "Notion",
|
||||
google: "Google",
|
||||
github: "GitHub",
|
||||
slack: "Slack",
|
||||
discord: "Discord",
|
||||
twitter: "Twitter",
|
||||
linear: "Linear",
|
||||
};
|
||||
return names[provider] ?? provider.charAt(0).toUpperCase() + provider.slice(1);
|
||||
}
|
||||
|
||||
interface OAuthLoginViewProps {
|
||||
provider: string;
|
||||
onLogin: () => void;
|
||||
onCancel: () => void;
|
||||
waiting: boolean;
|
||||
}
|
||||
|
||||
function OAuthLoginView({
|
||||
provider,
|
||||
onLogin,
|
||||
onCancel,
|
||||
waiting,
|
||||
}: OAuthLoginViewProps) {
|
||||
const providerName = formatProviderName(provider);
|
||||
|
||||
return (
|
||||
<div className="py-6">
|
||||
{/* Provider icon */}
|
||||
<div className="flex justify-center mb-5">
|
||||
<div className="w-14 h-14 rounded-2xl bg-stone-800 border border-stone-700 flex items-center justify-center">
|
||||
<ProviderIcon provider={provider} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title and description */}
|
||||
<div className="text-center mb-6">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">
|
||||
Connect to {providerName}
|
||||
</h3>
|
||||
<p className="text-sm text-stone-400">
|
||||
{waiting
|
||||
? "Waiting for authorization. Complete the login in your browser..."
|
||||
: `Sign in with your ${providerName} account to connect this skill.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login button or waiting state */}
|
||||
{waiting ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-3 px-4 py-3 bg-stone-800/50 border border-stone-700 rounded-xl">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4 text-primary-400"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm text-stone-300">
|
||||
Waiting for {providerName} authorization...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onLogin}
|
||||
className="text-xs text-primary-400 hover:text-primary-300 transition-colors"
|
||||
>
|
||||
Open login page again
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onLogin}
|
||||
className="w-full py-3 text-sm font-medium text-white bg-primary-500 rounded-xl hover:bg-primary-600 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
/>
|
||||
</svg>
|
||||
Sign in with {providerName}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel */}
|
||||
<div className="mt-4">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-full py-2.5 text-sm font-medium text-stone-400 bg-stone-800/50 border border-stone-700 rounded-xl hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider Icons
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProviderIcon({ provider }: { provider: string }) {
|
||||
switch (provider) {
|
||||
case "notion":
|
||||
return (
|
||||
<svg className="w-7 h-7" viewBox="0 0 100 100" fill="none">
|
||||
<path
|
||||
d="M6.017 4.313l55.333-4.087c6.797-.583 8.543-.19 12.817 2.917l17.663 12.443c2.913 2.14 3.883 2.723 3.883 5.053v68.243c0 4.277-1.553 6.807-6.99 7.193L24.467 99.967c-4.08.193-6.023-.39-8.16-3.113L3.3 79.94c-2.333-3.113-3.3-5.443-3.3-8.167V11.113c0-3.497 1.553-6.413 6.017-6.8z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M61.35.227L6.017 4.313C1.553 4.7 0 7.617 0 11.113v60.66c0 2.723.967 5.053 3.3 8.167l12.993 16.913c2.137 2.723 4.08 3.307 8.16 3.113l64.257-3.89c5.433-.387 6.99-2.917 6.99-7.193V20.64c0-2.21-.873-2.847-3.443-4.733L75.34 3.57l-.027-.02C71.587.527 69.087-.36 61.35.227zM25.723 19.01c-5.167.39-6.337.477-9.277-1.84l-6.44-5.057c-.773-.78-.39-1.75 1.553-1.947l52.893-3.89c4.473-.39 6.797 1.167 8.543 2.527l7.41 5.443c.39.193.97 1.357 0 1.357l-54.88 3.213-.003.003v-.01zm-6.6 73.793V28.883c0-2.53.777-3.697 3.107-3.89L81.44 21.78c2.14-.193 3.107 1.167 3.107 3.693v63.537c0 2.53-1.16 4.667-4.467 4.86L27.45 97.077c-3.113.193-4.337-.97-4.337-4.273h.01zm51.57-62.177c.39 1.75 0 3.5-1.75 3.7l-2.527.48v47.013c-2.14 1.167-4.273 1.75-5.637 1.75-2.53 0-3.303-.78-5.247-3.107l-16.08-25.283v24.477l5.25 1.17s0 3.5-4.857 3.5L28.337 79.4c-.39-.78 0-2.723 1.357-3.11l3.497-.97V40.763l-4.857-.39c-.39-1.75.583-4.277 3.3-4.473L42.63 35.32l16.667 25.477V37.853l-4.473-.58c-.39-2.14 1.163-3.5 3.303-3.697l12.567-.75z"
|
||||
fill="#000"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case "google":
|
||||
return (
|
||||
<svg className="w-6 h-6" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
|
||||
fill="#4285F4"
|
||||
/>
|
||||
<path
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
fill="#34A853"
|
||||
/>
|
||||
<path
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||
fill="#FBBC05"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||
fill="#EA4335"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
case "github":
|
||||
return (
|
||||
<svg className="w-7 h-7 text-white" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<svg
|
||||
className="w-7 h-7 text-stone-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,6 +233,34 @@ class SkillManager {
|
||||
await this.activateSkill(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify a skill that OAuth completed successfully.
|
||||
* Called by the deep link handler after backend OAuth callback.
|
||||
*/
|
||||
async notifyOAuthComplete(
|
||||
skillId: string,
|
||||
integrationId: string,
|
||||
provider?: string,
|
||||
): Promise<void> {
|
||||
const runtime = this.runtimes.get(skillId);
|
||||
if (!runtime || !runtime.isRunning) {
|
||||
console.warn(`[SkillManager] Cannot notify OAuth complete: skill ${skillId} not running`);
|
||||
return;
|
||||
}
|
||||
|
||||
const manifest = store.getState().skills.skills[skillId]?.manifest;
|
||||
|
||||
await runtime.oauthComplete({
|
||||
credentialId: integrationId,
|
||||
provider: provider ?? manifest?.setup?.oauth?.provider ?? "unknown",
|
||||
grantedScopes: manifest?.setup?.oauth?.scopes ?? [],
|
||||
});
|
||||
|
||||
// Mark setup as complete and activate
|
||||
store.dispatch(setSkillSetupComplete({ skillId, complete: true }));
|
||||
await this.activateSkill(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward session start to all ready skills.
|
||||
*/
|
||||
|
||||
@@ -160,6 +160,29 @@ export class SkillRuntime {
|
||||
await this.transport.request("skill/sessionEnd", { sessionId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the skill that OAuth completed successfully.
|
||||
* Sets the credential on the bridge and calls onOAuthComplete.
|
||||
*/
|
||||
async oauthComplete(args: {
|
||||
credentialId: string;
|
||||
provider: string;
|
||||
grantedScopes?: string[];
|
||||
accountLabel?: string;
|
||||
}): Promise<void> {
|
||||
await this.transport.request("oauth/complete", args as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify the skill that an OAuth credential was revoked.
|
||||
*/
|
||||
async oauthRevoked(args: {
|
||||
credentialId: string;
|
||||
reason: string;
|
||||
}): Promise<void> {
|
||||
await this.transport.request("oauth/revoked", args as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload and stop the skill.
|
||||
*/
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface SkillManifest {
|
||||
setup?: {
|
||||
required: boolean;
|
||||
label?: string;
|
||||
oauth?: {
|
||||
provider: string;
|
||||
scopes: string[];
|
||||
apiBaseUrl: string;
|
||||
};
|
||||
};
|
||||
/** Platform filter. When present, only listed platforms load this skill.
|
||||
* When absent or empty, the skill is available on all platforms. */
|
||||
|
||||
@@ -1,15 +1,80 @@
|
||||
import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core';
|
||||
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
|
||||
|
||||
import { skillManager } from '../lib/skills/manager';
|
||||
import { consumeLoginToken } from '../services/api/authApi';
|
||||
import { store } from '../store';
|
||||
import { setToken } from '../store/authSlice';
|
||||
import { IS_DEV } from './config';
|
||||
|
||||
/**
|
||||
* Handle an `alphahuman://auth?token=...` deep link for login.
|
||||
*/
|
||||
const handleAuthDeepLink = async (parsed: URL) => {
|
||||
const token = parsed.searchParams.get('token');
|
||||
if (!token) {
|
||||
console.warn('[DeepLink] URL did not contain a token query parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[DeepLink] Received auth token');
|
||||
|
||||
try {
|
||||
await invoke('show_window');
|
||||
} catch (err) {
|
||||
console.warn('[DeepLink] Failed to show window:', err);
|
||||
}
|
||||
|
||||
const jwtToken = await consumeLoginToken(token);
|
||||
store.dispatch(setToken(jwtToken));
|
||||
window.location.hash = '/onboarding';
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle `alphahuman://oauth/success?integrationId=...&skillId=...`
|
||||
* and `alphahuman://oauth/error?error=...&provider=...` deep links.
|
||||
*/
|
||||
const handleOAuthDeepLink = async (parsed: URL) => {
|
||||
// pathname is "/success" or "/error" (hostname is "oauth")
|
||||
const path = parsed.pathname.replace(/^\/+/, '');
|
||||
|
||||
try {
|
||||
await invoke('show_window');
|
||||
} catch {
|
||||
// Not fatal
|
||||
}
|
||||
|
||||
if (path === 'success') {
|
||||
const integrationId = parsed.searchParams.get('integrationId');
|
||||
const skillId = parsed.searchParams.get('skillId');
|
||||
|
||||
if (!integrationId || !skillId) {
|
||||
console.error('[DeepLink] OAuth success missing integrationId or skillId', parsed.href);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[DeepLink] OAuth success for skill=${skillId} integration=${integrationId}`);
|
||||
|
||||
try {
|
||||
await skillManager.notifyOAuthComplete(skillId, integrationId);
|
||||
} catch (err) {
|
||||
console.error('[DeepLink] Failed to notify OAuth complete:', err);
|
||||
}
|
||||
} else if (path === 'error') {
|
||||
const error = parsed.searchParams.get('error') ?? 'Unknown error';
|
||||
const provider = parsed.searchParams.get('provider') ?? 'unknown';
|
||||
console.error(`[DeepLink] OAuth error for provider=${provider}: ${error}`);
|
||||
} else {
|
||||
console.warn('[DeepLink] Unknown OAuth path:', path);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle a list of deep link URLs delivered by the Tauri deep-link plugin.
|
||||
* Parses `alphahuman://auth?token=...` URLs and exchanges the token for a
|
||||
* desktop session via the backend.
|
||||
* Routes to the appropriate handler based on the URL hostname:
|
||||
* - `alphahuman://auth?token=...` → login flow
|
||||
* - `alphahuman://oauth/success?...` → OAuth completion
|
||||
* - `alphahuman://oauth/error?...` → OAuth failure
|
||||
*/
|
||||
const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
|
||||
if (!urls || urls.length === 0) {
|
||||
@@ -23,33 +88,18 @@ const handleDeepLinkUrls = async (urls: string[] | null | undefined) => {
|
||||
if (parsed.protocol !== 'alphahuman:') {
|
||||
return;
|
||||
}
|
||||
// Harden: ensure this deep link is intended for auth handoff
|
||||
if (parsed.hostname !== 'auth') {
|
||||
return;
|
||||
|
||||
switch (parsed.hostname) {
|
||||
case 'auth':
|
||||
await handleAuthDeepLink(parsed);
|
||||
break;
|
||||
case 'oauth':
|
||||
await handleOAuthDeepLink(parsed);
|
||||
break;
|
||||
default:
|
||||
console.warn('[DeepLink] Unknown deep link hostname:', parsed.hostname);
|
||||
break;
|
||||
}
|
||||
|
||||
const token = parsed.searchParams.get('token');
|
||||
if (!token) {
|
||||
console.warn('[DeepLink] URL did not contain a token query parameter');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[DeepLink] Received token', token);
|
||||
|
||||
try {
|
||||
// Bring app window to foreground so macOS users actually see completion.
|
||||
// (In this app, the window can start hidden and live in the tray.)
|
||||
await invoke('show_window');
|
||||
} catch (err) {
|
||||
// Not fatal; we still continue the auth flow.
|
||||
console.warn('[DeepLink] Failed to show window:', err);
|
||||
}
|
||||
|
||||
const jwtToken = await consumeLoginToken(token);
|
||||
store.dispatch(setToken(jwtToken));
|
||||
|
||||
// Navigate to post-login flow. We use HashRouter, so update the hash route.
|
||||
window.location.hash = '/onboarding';
|
||||
} catch (error) {
|
||||
console.error('[DeepLink] Failed to handle deep link URL:', url, error);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user