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:
Steven Enamakel
2026-02-06 05:48:10 +05:30
parent b3838625a9
commit 65d42f2526
10 changed files with 542 additions and 41 deletions
+1 -1
Submodule skills updated: 74d3cb1a2e...9dc1855541
+10 -4
View File
@@ -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,
})
})
+2
View File
@@ -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.
+28 -2
View File
@@ -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(&params).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(&params).unwrap_or_else(|_| "{}".to_string());
handle_js_call(ctx, "onOAuthComplete", &params_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(&params).unwrap_or_else(|_| "{}".to_string());
handle_js_void_call(ctx, "onOAuthRevoked", &params_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
View File
@@ -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)
// ============================================================================
+287 -6
View File
@@ -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>
);
}
}
+28
View File
@@ -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.
*/
+23
View File
@@ -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.
*/
+5
View File
@@ -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. */
+78 -28
View File
@@ -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);
}