-
-
- Cookies are copied into Godmode's browser on this machine and never uploaded.
- {isMac && " macOS may ask for Keychain access — allow it so Chrome's cookies can be decrypted."}
-
-
- );
-}
diff --git a/apps/desktop/src/components/onboarding/claude-sign-in.tsx b/apps/desktop/src/components/onboarding/claude-sign-in.tsx
index 858085cf..48b3e102 100644
--- a/apps/desktop/src/components/onboarding/claude-sign-in.tsx
+++ b/apps/desktop/src/components/onboarding/claude-sign-in.tsx
@@ -1,11 +1,12 @@
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useId, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { ExternalLink } from "lucide-react";
+import { Check, Copy, ExternalLink } from "lucide-react";
import { toast } from "sonner";
import type { ClaudeLoginStatus } from "@godmode/shared";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
+import { writeClipboard } from "@/components/vault/clipboard";
import { api, errorMessage } from "@/lib/api";
import { cloudContext, isTauri } from "@/lib/core";
import { openExternal } from "@/lib/desktop";
@@ -20,6 +21,23 @@ export const signsInFromAfar = !!cloudContext || (!isTauri && typeof window !==
/** Who may sign Claude in: on Godmode Cloud only the computer's owner. */
export const maySignIn = !cloudContext || cloudContext.role === "owner";
+/**
+ * Puts the sign-in link on the clipboard. On this computer the core writes it: the desktop webview only lets the page
+ * copy during a click, and the link arrives seconds after it. From afar only this device's clipboard is of any use.
+ */
+async function copySignInLink(url: string, clicked: boolean): Promise {
+ const viaCore = async () => !signsInFromAfar && !!(await api.doctor.copyClaudeLoginLink().catch(() => null))?.ok;
+ const viaPage = () =>
+ writeClipboard(url).then(
+ () => true,
+ () => false,
+ );
+ return clicked ? (await viaPage()) || viaCore() : (await viaCore()) || viaPage();
+}
+
+/** The last link copied without a click: every hook on screen sees it arrive, one copy is enough. */
+let autoCopied: string | null = null;
+
/**
* Claude Code's sign-in on this computer: its state (also whether runs found it signed out) and the steps to sign in.
* `onSignedIn` runs when a sign-in finishes while the component is on screen, wherever it was started.
@@ -71,80 +89,118 @@ export function useClaudeLogin({ onSignedIn }: { onSignedIn?: () => void } = {})
prev.current = state;
}, [state, onSignedIn]);
- return { status: data, state, mine: mine && state === "waiting", start, cancel, submit };
+ // The sign-in this component started copies its link as soon as it arrives.
+ const url = state === "waiting" ? (data?.url ?? null) : null;
+ const [copied, setCopied] = useState<{ url: string; ok: boolean } | null>(null);
+ const ownsLink = mine && state === "waiting";
+ useEffect(() => {
+ if (!ownsLink || !url || autoCopied === url) return;
+ autoCopied = url;
+ void copySignInLink(url, false).then((ok) => setCopied({ url, ok }));
+ }, [ownsLink, url]);
+ const copy = async () => {
+ if (!url) return;
+ const ok = await copySignInLink(url, true);
+ setCopied({ url, ok });
+ if (ok) toast.success("Sign-in link copied", { id: "claude-link-copied" });
+ else toast.error("Could not copy the link", { description: "Open the sign-in page instead." });
+ };
+ const link = { url, copied: copied?.url === url ? copied.ok : null, copy };
+
+ return { status: data, state, mine: ownsLink, start, cancel, submit, link };
}
export type ClaudeLogin = ReturnType;
-/** While the sign-in runs: finish it in the browser, or (no browser window, a phone) open the page and paste its code. */
+/**
+ * While the sign-in runs: approve it in the browser that opened. The link is on the clipboard for when no window
+ * opened (or on a phone): that page shows a code to paste back here.
+ */
export function ClaudeSignInSteps({ login, className }: { login: ClaudeLogin; className?: string }) {
+ const id = useId();
const [code, setCode] = useState("");
- const [manual, setManual] = useState(signsInFromAfar);
- const url = login.status?.url ?? null;
+ const { url, copied, copy } = login.link;
const send = () => {
if (!code.trim()) return;
login.submit.mutate(code.trim(), { onSuccess: () => setCode("") });
};
const openPage = () => {
- if (!url) return;
- setManual(true);
- void openExternal(url);
+ if (url) void openExternal(url);
};
return (
-
-
+
+
{signsInFromAfar ? "Sign in on this device" : "Finish signing in in your browser"}
-
- {signsInFromAfar ? (
-
-
-
- {url ? "Open the Claude sign-in page" : "Getting the sign-in page…"} {url ? : }
-
-
-
- Sign in with your Claude account and click Authorize.
-
-
Paste the code the page shows:
-
- ) : (
-
- Sign in with your Claude account and click Authorize. This updates by itself.
- {url && !manual && (
- <>
- {" "}
-
- No browser window?
-
- >
- )}
-
- )}
- {manual && (
+
+ {signsInFromAfar ? "Open the sign-in page, sign in with your Claude account and click " : "Sign in with your Claude account and click "}
+ Authorize.{signsInFromAfar ? " Then paste the code it shows below." : " This updates by itself."}
+
+
+
+ {!url ? (
+
+ Getting the sign-in link…
+
+ ) : (
+ <>
+ {copied ? (
+
+
+
+ Sign-in link copied.{" "}
+
+ {signsInFromAfar ? "Paste it into a browser on this device if the page did not open." : "No browser window opened? Paste it into your browser."}
+
+
+
+ ) : (
+
+ {signsInFromAfar ? "Open the page here, or copy the link into another browser." : "No browser window opened? Open the page, or copy the link into your browser."}
+
@@ -240,17 +206,9 @@ function DependencyRow({
}
/** Signs Claude Code in from Godmode: the browser opens, the person approves, the row turns green by itself. */
-function ClaudeSignIn({ onDone, auto = false, onAuto }: { onDone: () => void; auto?: boolean; onAuto?: () => void }) {
+function ClaudeSignIn({ onDone }: { onDone: () => void }) {
const login = useClaudeLogin({ onSignedIn: onDone });
const { status: data, state, start } = login;
- const autoStarted = useRef(false);
- useEffect(() => {
- if (!auto || !data || autoStarted.current) return;
- autoStarted.current = true;
- onAuto?.();
- if (data.state === "idle" && maySignIn) start.mutate();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [auto, data]);
if (state === "done")
return (
diff --git a/apps/desktop/src/components/onboarding/done-step.tsx b/apps/desktop/src/components/onboarding/done-step.tsx
index 5854fdfb..0ad0cfaf 100644
--- a/apps/desktop/src/components/onboarding/done-step.tsx
+++ b/apps/desktop/src/components/onboarding/done-step.tsx
@@ -1,26 +1,35 @@
-import { useState } from "react";
+import { useState, type ReactNode } from "react";
+import { useMutation, useQuery } from "@tanstack/react-query";
import { format } from "date-fns";
-import { motion } from "motion/react";
-import { ArrowRight, Bot, CircleCheck, CircleDashed, KeyRound, ShieldCheck } from "lucide-react";
-import { MASCOT_CHARACTER, MASCOT_COLOR, type LicenseState } from "@godmode/shared";
+import { AnimatePresence, motion } from "motion/react";
+import { ArrowRight, Bot, ChevronDown, CircleCheck, CircleDashed, Cookie, KeyRound, ShieldCheck } from "lucide-react";
+import { MASCOT_CHARACTER, MASCOT_COLOR, type ChromeImportResult, type LicenseState, type LocalChromeProfile } from "@godmode/shared";
import { Character } from "@/components/character";
+import { ActivatePanel } from "@/components/license/activate";
+import { Button } from "@/components/ui/button";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { api, errorMessage } from "@/lib/api";
+import { isMac } from "@/lib/desktop";
+import { useLicense } from "@/lib/hooks";
+import { qk } from "@/lib/queryKeys";
+import { cn } from "@/lib/utils";
import { FormError, SubmitButton } from "./auth-layout";
-import { Confetti, StepCard } from "./step-kit";
+import { Confetti, StepCard, StepFooter } from "./step-kit";
export interface OnboardingSummary {
doctorOk: boolean | null;
vault: "created" | "unlocked" | "existing" | null;
- importedCookies: number | null;
dashboardPassword: boolean | null;
- license: LicenseState | null;
}
const NEXT = [
{ to: "/agents/new", icon: , title: "Hire your first agent", body: "Pick a template or describe the job." },
- { to: "/vault/logins", icon: , title: "Save your logins", body: "Agents sign in like you, 2FA included. No connectors needed." },
+ { to: "/vault/logins", icon: , title: "Save your logins", body: "Agents sign in like you, 2FA included." },
{ to: "/vault/2fa?import=1", icon: , title: "Import 2FA codes", body: "Scan Google Authenticator exports." },
];
+const LICENSED: LicenseState["status"][] = ["active", "trial", "past_due", "unverified"];
+
export function DoneStep({
userName,
summary,
@@ -34,99 +43,109 @@ export function DoneStep({
}) {
const [busy, setBusy] = useState(null);
const [error, setError] = useState(null);
+ const { data: license, isLoading: licenseLoading } = useLicense();
+ const licensed = !!license?.keyHint && !license.blocked && LICENSED.includes(license.status);
+ const freeUntil = license?.enforced && license.status === "grace" && license.graceEndsAt ? new Date(license.graceEndsAt) : null;
+ const needsKey = !!license && !licensed && !freeUntil;
+ const [activateOpen, setActivateOpen] = useState(false);
+ const blocked = !!license?.blocked;
const first = userName.trim().split(/\s+/)[0];
const finish = async (to: string) => {
- if (busy) return;
+ if (busy || blocked || licenseLoading) return;
setBusy(to);
setError(null);
try {
await onFinish(to);
} catch (e) {
- setError(e instanceof Error ? e.message : String(e));
+ setError(errorMessage(e));
setBusy(null);
}
};
- const items: { label: string; ok: boolean | null; detail?: string }[] = [
- { label: "System check", ok: summary.doctorOk, detail: summary.doctorOk === false ? "Some tools still missing — see Settings → System" : undefined },
- {
- label: "Encrypted vault",
- ok: summary.vault !== null,
- detail: summary.vault === "created" ? "Created" : summary.vault ? "Unlocked" : undefined,
- },
- {
- label: "Browser sessions",
- ok: summary.importedCookies !== null ? true : null,
- detail: summary.importedCookies !== null ? `${summary.importedCookies.toLocaleString()} cookies imported` : "Skipped — import any time from Browser",
- },
- ];
- const license = summary.license;
- const licensed = !!license?.keyHint && !license.blocked && ["active", "trial", "past_due", "unverified"].includes(license.status);
- const freeUntil = license?.enforced && license.status === "grace" && license.graceEndsAt ? format(new Date(license.graceEndsAt), "MMM d") : null;
- items.push({
- label: "Godmode Pro",
- ok: licensed || freeUntil ? true : license?.blocked ? false : null,
- detail: licensed ? (license!.status === "trial" ? "Free trial active" : "Activated") : freeUntil ? `Free until ${freeUntil}` : "Skipped",
- });
- if (serverMode)
- items.push({
- label: "Dashboard password",
- ok: summary.dashboardPassword,
- detail: summary.dashboardPassword ? "Set" : "Not set — sign in with access tokens",
- });
+ const licenseDetail = licensed
+ ? license!.status === "trial" && license!.trialEndsAt
+ ? `Free trial until ${format(new Date(license!.trialEndsAt), "MMM d")}`
+ : "Activated"
+ : freeUntil
+ ? `Free until ${format(freeUntil, "MMM d")}`
+ : license && !license.enforced
+ ? "Free in this build"
+ : blocked
+ ? "Needs a licence key"
+ : "Not activated yet";
return (
-
+ {!blocked && }
-
+
-
Setup complete
-
+
You're all set{first ? `, ${first}` : ""}.
Your coworker is ready.
- Tell it what you need in plain words — it will sign in, browse and report back.
+ Tell it what you need in plain words. It signs in, browses and reports back.
+
+
+
+ {allOk && started
+ ? "All set. I'm ready to work."
+ : running || login.state === "waiting"
+ ? "Getting myself ready. This takes a few minutes."
+ : first
+ ? `Hi ${first}, I'm Godmode, your new coworker.`
+ : "Hi, I'm Godmode, your new coworker."}
+
+
+
+ Let's get Godmode ready.
+ One click, a few minutes.
+
+
+ {allOk && !started
+ ? "Everything Godmode needs is already on this computer. Pick how it should look and carry on."
+ : "Godmode sets up everything it needs on this computer by itself. You only approve the Claude sign-in in your browser."}
+
- {allGood ? (
- Everything your coworker needs is in place.
- ) : (
- <>
-
- {missingRequired.length} required {missingRequired.length === 1 ? "item needs" : "items need"} attention.
- {" "}
- You can continue and fix this later in Settings → System — agents won't run until it's resolved.
- >
- )}
-