Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/next/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Profile } from "components/Profile";
import { SignMessage } from "components/SignMessage";
import { Transfer } from "components/Transfer";
import { Starterpack } from "components/Starterpack";
import { UpdateSession } from "components/UpdateSession";
import { ControllerToaster } from "@cartridge/ui";

const Home: FC = () => {
Expand All @@ -30,6 +31,7 @@ const Home: FC = () => {
<Transfer />
<ManualTransferEth />
<Starterpack />
<UpdateSession />
<DelegateAccount />
<InvalidTxn />
<SignMessage />
Expand Down
44 changes: 44 additions & 0 deletions examples/next/src/components/UpdateSession.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"use client";

import { useAccount } from "@starknet-react/core";
import ControllerConnector from "@cartridge/connector/controller";
import { Button } from "@cartridge/ui";
import { useState } from "react";

export const UpdateSession = () => {
const { account, connector } = useAccount();
const [loading, setLoading] = useState(false);

const controllerConnector = connector as unknown as ControllerConnector;

if (!account) {
return null;
}

return (
<div className="flex flex-col gap-2">
<h2>Update Session</h2>
<div className="flex items-center gap-2">
<Button
disabled={loading}
onClick={async () => {
setLoading(true);
try {
const response =
await controllerConnector.controller.updateSession({
preset: "loot-survivor",
});
console.log("Session updated:", response);
} catch (e) {
console.error("Failed to update session:", e);
} finally {
setLoading(false);
}
}}
>
{loading ? "Updating..." : "Update Session (loot-survivor)"}
</Button>
</div>
</div>
);
};
42 changes: 42 additions & 0 deletions packages/controller/src/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
OpenOptions,
HeadlessUsernameLookupResult,
StarterpackOptions,
UpdateSessionOptions,
} from "./types";
import { validateRedirectUrl } from "./url-validator";
import { parseChainId } from "./utils";
Expand Down Expand Up @@ -508,6 +509,47 @@ export default class ControllerProvider extends BaseProvider {
this.iframes.keychain.close();
}

async updateSession(options: UpdateSessionOptions = {}) {
if (!options.policies && !options.preset) {
throw new Error("Either `policies` or `preset` must be provided");
}

if (!this.iframes) {
return;
}

// Ensure iframe is created if using lazy loading
if (!this.iframes.keychain) {
this.iframes.keychain = this.createKeychainIframe();
}

await this.waitForKeychain();

if (!this.keychain || !this.iframes.keychain) {
console.error(new NotReadyToConnect().message);
return;
}

this.iframes.keychain.open();

try {
const response = await this.keychain.updateSession(
options.policies,
options.preset,
);

if (response.code !== ResponseCodes.SUCCESS) {
throw new Error((response as ConnectError).message);
}

return response as ConnectReply;
} catch (e) {
console.error(e);
} finally {
this.iframes.keychain.close();
}
}

revoke(origin: string, _policy: Policy[]) {
if (!this.keychain) {
console.error(new NotReadyToConnect().message);
Expand Down
12 changes: 12 additions & 0 deletions packages/controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,10 @@ export interface Keychain {
account: string,
async?: boolean,
): Promise<Signature | ConnectError>;
updateSession(
policies?: SessionPolicies,
preset?: string,
): Promise<ConnectReply | ConnectError>;
openSettings(): Promise<void | ConnectError>;
session(): Promise<KeychainSession>;
sessions(): Promise<{
Expand Down Expand Up @@ -295,6 +299,14 @@ export interface ConnectOptions {
password?: string;
}

/** Options for updating session policies at runtime */
export type UpdateSessionOptions = {
/** Session policies to set */
policies?: SessionPolicies;
/** Preset name to resolve policies from */
preset?: string;
};

export type HeadlessConnectOptions = Required<
Pick<ConnectOptions, "username" | "signer">
> &
Expand Down
230 changes: 230 additions & 0 deletions packages/keychain/src/components/UpdateSessionRoute.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { useCallback, useEffect, useState } from "react";
import { ResponseCodes, getPresetSessionPolicies } from "@cartridge/controller";
import { loadConfig } from "@cartridge/presets";
import { useConnection } from "@/hooks/connection";
import {
parseSessionPolicies,
type ParsedSessionPolicies,
} from "@/hooks/session";
import { cleanupCallbacks } from "@/utils/connection/callbacks";
import { parseUpdateSessionParams } from "@/utils/connection/update-session";
import { CreateSession } from "./connect/CreateSession";
import {
createVerifiedSession,
requiresSessionApproval,
} from "@/utils/connection/session-creation";
import {
useRouteParams,
useRouteCompletion,
useRouteCallbacks,
} from "@/hooks/route";
import { ControllerErrorAlert } from "@/components/ErrorAlert";
import {
Button,
HeaderInner,
LayoutContent,
LayoutFooter,
SpinnerIcon,
} from "@cartridge/ui";

const CANCEL_RESPONSE = {
code: ResponseCodes.CANCELED,
message: "Canceled",
};

export function UpdateSessionRoute() {
const { controller, origin, theme, chainId, verified } = useConnection();
const [resolvedPolicies, setResolvedPolicies] =
useState<ParsedSessionPolicies>();
const [isLoading, setIsLoading] = useState(false);
const [isSessionCreating, setIsSessionCreating] = useState(false);
const [sessionError, setSessionError] = useState<Error>();

const params = useRouteParams((searchParams: URLSearchParams) => {
return parseUpdateSessionParams(searchParams);
});

const handleCompletion = useRouteCompletion();

useRouteCallbacks(params, CANCEL_RESPONSE);

// Resolve policies from params (either direct policies or from preset)
useEffect(() => {
if (!params) return;

const { policies, preset } = params.params;

if (policies) {
// Direct policies provided - parse them
const parsed = parseSessionPolicies({
policies,
verified,
});
setResolvedPolicies(parsed);
return;
}

if (preset && chainId) {
// Resolve policies from preset
setIsLoading(true);
loadConfig(preset)
.then((config) => {
if (!config) {
console.error(`Failed to load preset: ${preset}`);
return;
}

const sessionPolicies = getPresetSessionPolicies(
config as Record<string, unknown>,
chainId,
);
if (!sessionPolicies) {
console.error(
`No policies found for chain ${chainId} in preset ${preset}`,
);
return;
}

const parsed = parseSessionPolicies({
policies: sessionPolicies,
verified,
});
setResolvedPolicies(parsed);
})
.catch((error) => {
console.error("Failed to resolve preset policies:", error);
})
.finally(() => {
setIsLoading(false);
});
}
}, [params, chainId, verified]);

const handleConnect = useCallback(async () => {
if (!params || !controller) {
return;
}

params.resolve?.({
code: ResponseCodes.SUCCESS,
address: controller.address(),
});
if (params.params.id) {
cleanupCallbacks(params.params.id);
}
handleCompletion();
}, [params, controller, handleCompletion]);

// Auto-create session for verified policies that don't require approval
useEffect(() => {
if (!resolvedPolicies || !controller || !params) return;

if (!requiresSessionApproval(resolvedPolicies)) {
const autoCreate = async () => {
try {
setIsSessionCreating(true);
await createVerifiedSession({
controller,
origin,
policies: resolvedPolicies,
});
params.resolve?.({
code: ResponseCodes.SUCCESS,
address: controller.address(),
});
if (params.params.id) {
cleanupCallbacks(params.params.id);
}
handleCompletion();
} catch (e) {
console.error("Failed to auto-create session:", e);
setSessionError(e instanceof Error ? e : new Error(String(e)));
} finally {
setIsSessionCreating(false);
}
};

void autoCreate();
}
}, [resolvedPolicies, controller, params, origin, handleCompletion]);

// Loading state
if (!controller || isLoading) {
return (
<>
<HeaderInner
className="pb-0"
title={theme ? theme.name : "Update Session"}
/>
<LayoutContent className="flex items-center justify-center">
<SpinnerIcon className="animate-spin" />
</LayoutContent>
</>
);
}

if (!resolvedPolicies) {
return null;
}

// Verified policies auto-creating
if (resolvedPolicies.verified && !requiresSessionApproval(resolvedPolicies)) {
if (sessionError) {
return (
<>
<HeaderInner
className="pb-0"
title={theme ? theme.name : "Update Session"}
/>
<LayoutContent />
<LayoutFooter>
<ControllerErrorAlert className="mb-3" error={sessionError} />
<Button
className="w-full"
disabled={isSessionCreating}
isLoading={isSessionCreating}
onClick={async () => {
if (!controller) return;
setIsSessionCreating(true);
setSessionError(undefined);
try {
await createVerifiedSession({
controller,
origin,
policies: resolvedPolicies,
});
params?.resolve?.({
code: ResponseCodes.SUCCESS,
address: controller.address(),
});
if (params?.params.id) {
cleanupCallbacks(params.params.id);
}
handleCompletion();
} catch (e) {
setSessionError(
e instanceof Error ? e : new Error(String(e)),
);
} finally {
setIsSessionCreating(false);
}
}}
>
retry
</Button>
</LayoutFooter>
</>
);
}
return null;
}

// Show CreateSession for policies that require approval
return (
<CreateSession
policies={resolvedPolicies}
onConnect={handleConnect}
isUpdate
/>
);
}
Loading
Loading