diff --git a/.env.example b/.env.example index fead81b4e1..817edaea4d 100644 --- a/.env.example +++ b/.env.example @@ -127,6 +127,7 @@ STRIPE_PRICE_BUSINESS_1M_YEARLY= STRIPE_PRICE_ENTERPRISE_MONTHLY= STRIPE_PRICE_ENTERPRISE_YEARLY= + # Turnkey Wallet Provider TURNKEY_API_PUBLIC_KEY= TURNKEY_API_PRIVATE_KEY= diff --git a/.github/workflows/deploy-keeperhub.yaml b/.github/workflows/deploy-keeperhub.yaml index 9ecc44882a..5849cdc23c 100644 --- a/.github/workflows/deploy-keeperhub.yaml +++ b/.github/workflows/deploy-keeperhub.yaml @@ -177,6 +177,54 @@ jobs: --filter "name=${SERVICE_NAME}-common-test-irsa-auth" \ --logs + - name: Seed onboarding workflows + if: steps.skip.outputs.skip_deploy != 'true' + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + IMAGE_TAG: ${{ steps.vars.outputs.sha_short }} + REGION: ${{ env.REGION }} + NAMESPACE: ${{ env.NAMESPACE }} + SEED_EMAIL: ${{ vars.ONBOARDING_SEED_EMAIL }} + run: | + set -euo pipefail + if [[ "${{ github.ref_name }}" == "prod" ]]; then + SSM_PATH="/eks/techops-prod/keeperhub/db-url" + MIGRATOR_IMAGE="${ECR_REGISTRY}/keeperhub-prod:migrator-${IMAGE_TAG}" + else + SSM_PATH="/eks/techops-staging/keeperhub/db-url" + MIGRATOR_IMAGE="${ECR_REGISTRY}/keeperhub-staging:migrator-${IMAGE_TAG}" + fi + DATABASE_URL=$(aws ssm get-parameter --name "${SSM_PATH}" --with-decryption --query "Parameter.Value" --output text --region "${REGION}") + kubectl delete job keeperhub-onboarding-seed -n "${NAMESPACE}" --ignore-not-found + kubectl apply -n "${NAMESPACE}" -f - <` component instead of `` tags - Add `rel="noopener"` when using `target="_blank"` @@ -102,6 +103,63 @@ When you must use an ignore comment: const result = externalLib.call() as any; ``` +## Logging + +Logging rules differ between server and client code. The Biome `noConsole` rule enforces this boundary: it bans `console.warn/error` on server paths while disabling the rule entirely for `components/**`, `lib/hooks/**`, `app/page.tsx`, `app/workflows/**`, `lib/api-client.ts`, and `tests/**`. + +### Server-side (app/api/**, lib/**, app/*/route.ts, scripts/**) + +Use the functions from `lib/logging.ts`. Never use `console.warn` or `console.error` on server paths — Biome treats these as errors. + +| Function | When to use | Sentry | Prometheus | +|---|---|---|---| +| `logSystemError(category, message, error, labels?)` | Infrastructure/DB/auth failures the system should not encounter | yes (error) | yes | +| `logUserError(category, message, error?, labels?)` | Validation, bad input, external-service rejections caused by the caller | no | yes | +| `logSystemWarn(category, message, error, labels?)` | Recovery events, pre-reconciliation notes, expected fallbacks worth tracking | yes (warning) | no | +| `logInfo(message, labels?)` | State transitions and lifecycle events | no | no | +| `logWarn(message, labels?)` | Benign anomalies that are not operational failures | no | no | +| `logDebug(message, labels?)` | Verbose tracing, gated by `LOG_LEVEL=debug` | no | no | +| `logSecurityEvent(name, fields?, sentry?)` | Security detection signals (KEEP-612) | yes | no | + +`ErrorCategory` values: `VALIDATION`, `CONFIGURATION`, `EXTERNAL_SERVICE`, `NETWORK_RPC`, `TRANSACTION`, `BILLING`, `DATABASE`, `AUTH`, `INFRASTRUCTURE`, `WORKFLOW_ENGINE`, `UNKNOWN`. + +**Message format**: Use a `[Context] message` prefix. The context string is extracted by regex and becomes the `error_context` label in Loki and Prometheus. + +```typescript +// Good +logSystemError(ErrorCategory.DATABASE, "Failed to create workflow", error, { + endpoint: "/api/workflows/create", + operation: "create", +}); + +logUserError(ErrorCategory.VALIDATION, "[Withdraw] Invalid amount", undefined, { + userId: session.user.id, +}); + +// Wrong — banned by Biome on server paths +console.error("something broke", error); +console.warn("unexpected state"); +``` + +`console.log/info/debug` are technically allowed by Biome on server paths (the lib/logger facade normalises them to structured JSON) but prefer `logInfo`/`logDebug` so the structured labels and workflow context are included automatically. + +### Client-side (components/**, lib/hooks/**, lib/api-client.ts, app/page.tsx, app/workflows/**) + +`console.*` is unrestricted — the `noConsole` rule is off for these paths. Logs go to the browser devtools and client-side Sentry; the server observability pipeline (Prometheus, Loki) does not apply. + +Use a `[Component]` prefix to match server-side convention and make devtools filtering easy: + +```typescript +// Good +console.log("[AI Prompt] Generating workflow", { hasNodes, existingWorkflow: !!existingWorkflow }); +console.error("Failed to generate workflow:", error); + +// Fine but noisy — keep to meaningful state transitions, not every render +console.log("[AIPrompt] re-render"); +``` + +--- + ## Design System Before writing or modifying any UI code, read the relevant spec file in `specs/design-system/`. Use only tokens from `specs/design-system/tokens.css`. Run `node scripts/token-audit.js` before committing UI changes. Zero errors required. diff --git a/Dockerfile b/Dockerfile index 56caef4cd8..fe1fd09bbe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -83,6 +83,7 @@ ENV NEXT_PUBLIC_BILLING_ENABLED=$NEXT_PUBLIC_BILLING_ENABLED ENV NEXT_PUBLIC_GAS_SPONSORSHIP_ENABLED=$NEXT_PUBLIC_GAS_SPONSORSHIP_ENABLED ENV NEXT_PUBLIC_TURNSTILE_SITE_KEY=$NEXT_PUBLIC_TURNSTILE_SITE_KEY + # Sentry DSN baked into client bundle for error reporting. # SENTRY_ORG/PROJECT/AUTH_TOKEN/RELEASE are intentionally NOT set here # so this stage is cache-deterministic across commits (see sentry-upload stage). diff --git a/app/accept-invite/[inviteId]/page.tsx b/app/accept-invite/[inviteId]/page.tsx index 8ba5bd4da2..be46547ba1 100644 --- a/app/accept-invite/[inviteId]/page.tsx +++ b/app/accept-invite/[inviteId]/page.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; +import { isWalletEmail } from "@/lib/auth/wallet-constants"; import { authClient, signIn, @@ -18,6 +19,7 @@ import { } from "@/lib/auth-client"; import { refetchOrganizations } from "@/lib/refetch-organizations"; import { refetchSidebar } from "@/lib/refetch-sidebar"; +import { acceptWalletInvite } from "@/lib/wallet/invite-accept-client"; type InvitationData = { id: string; @@ -122,7 +124,11 @@ function LoadingState() { ); } -async function trySignUp(email: string, password: string, captchaToken?: string) { +async function trySignUp( + email: string, + password: string, + captchaToken?: string +) { const response = await signUp.email({ email, password, @@ -199,12 +205,20 @@ function AcceptDirectState({ }) { const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(""); + const isWallet = isWalletEmail(invitation.email); const handleAccept = async () => { setSubmitting(true); setError(""); try { + // Wallet (SIWE) invites are accepted by signing the invite challenge with + // the connected wallet; the email-match path doesn't apply to them. + if (isWallet) { + await acceptWalletInvite(invitation.id); + onSuccess(); + return; + } const result = await acceptInvitation(invitation.id); if (!result.success) { setError(result.error || "Failed to accept invitation"); @@ -236,8 +250,12 @@ function AcceptDirectState({
- You're signed in as{" "} - {invitation.email} + {isWallet + ? "Sign the challenge with your wallet to join." + : "You're signed in as "} + {!isWallet && ( + {invitation.email} + )}
{error && ( @@ -252,7 +270,12 @@ function AcceptDirectState({ onClick={handleAccept} > {submitting && } - {submitting ? "Joining..." : "Accept Invitation"} + {(() => { + if (submitting) { + return isWallet ? "Waiting for signature..." : "Joining..."; + } + return isWallet ? "Sign to join" : "Accept Invitation"; + })()}
@@ -507,6 +530,34 @@ function VerificationFormState({ ); } +// Logged-out wallet invite: the invitee can't use email/password signup, so +// steer them to connect their wallet (SIWE). After connecting, their session +// email matches the invite and the page re-renders into the sign-to-join state. +function WalletConnectState({ invitation }: { invitation: InvitationData }) { + const { openAuthPrompt } = useAuthPrompt(); + return ( +
+
+
+

+ Join {invitation.organizationName} +

+

+ {invitation.inviterName} invited you to join as{" "} + + {invitation.role} + + . Connect your wallet to continue, then sign to join. +

+
+ +
+
+ ); +} + // Component for logged-out users with sign-in/sign-up toggle function AuthFormState({ invitation, @@ -865,6 +916,14 @@ export default function AcceptInvitePage() { ); } + if (isWalletEmail(invitation.email)) { + return ( +
+ +
+ ); + } + return (
({}))) as { code?: string; emailOtp?: string; + signature?: string; }; - const dual = await requireDualFactor({ - userId: session.user.id, - email: session.user.email, - action: "agentic_wallet_approve", - code: body.code, - emailOtp: body.emailOtp, + const authorized = await authorizeAction({ + session, + action: STEP_UP_ACTIONS.agenticWalletApprove, + roleFloor: "none", + body, headers: request.headers, }); - if (!dual.ok) { - return dualFactorErrorResponse(dual); + if (!authorized.ok) { + return authorized.response; } const { id } = await params; diff --git a/app/api/agentic-wallet/[id]/reject/route.ts b/app/api/agentic-wallet/[id]/reject/route.ts index 44e3c21b05..e11b3937eb 100644 --- a/app/api/agentic-wallet/[id]/reject/route.ts +++ b/app/api/agentic-wallet/[id]/reject/route.ts @@ -16,11 +16,8 @@ import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { agenticWallets } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; -import { - dualFactorErrorResponse, - requireDualFactor, -} from "@/lib/mfa/dual-factor"; -import { requireMfaEnrolled } from "@/lib/middleware/owner-mfa-guard"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; +import { authorizeAction } from "@/lib/middleware/authorize-action"; import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; export const dynamic = "force-dynamic"; @@ -56,39 +53,20 @@ export async function POST( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Symmetric with the /approve gate. Reject doesn't move funds, but a - // stolen session that can reject can DoS legitimate requests by - // pre-emptively cancelling them; gating prevents that and keeps the - // approve/reject pair on identical authorization rules. - const sessionRow = session.session as { requiresMfa?: boolean | null }; - const mfa = await requireMfaEnrolled( - session.user.id, - sessionRow.requiresMfa === true - ); - if (!mfa.ok) { - return NextResponse.json( - { error: mfa.error, code: mfa.code }, - { status: mfa.status } - ); - } - - // Dual-factor: symmetric with /approve. A stolen session must not be - // able to pre-emptively cancel legitimate requests without proving - // both factors at the moment of rejection. const body = (await request.json().catch(() => ({}))) as { code?: string; emailOtp?: string; + signature?: string; }; - const dual = await requireDualFactor({ - userId: session.user.id, - email: session.user.email, - action: "agentic_wallet_reject", - code: body.code, - emailOtp: body.emailOtp, + const authorized = await authorizeAction({ + session, + action: STEP_UP_ACTIONS.agenticWalletReject, + roleFloor: "none", + body, headers: request.headers, }); - if (!dual.ok) { - return dualFactorErrorResponse(dual); + if (!authorized.ok) { + return authorized.response; } const { id } = await params; diff --git a/app/api/api-keys/[keyId]/route.ts b/app/api/api-keys/[keyId]/route.ts index 5318ddd799..a62eb133d5 100644 --- a/app/api/api-keys/[keyId]/route.ts +++ b/app/api/api-keys/[keyId]/route.ts @@ -4,11 +4,8 @@ import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { apiKeys } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; -import { - dualFactorErrorResponse, - requireDualFactor, -} from "@/lib/mfa/dual-factor"; -import { requireMfaEnrolled } from "@/lib/middleware/owner-mfa-guard"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; +import { authorizeAction } from "@/lib/middleware/authorize-action"; import { notifyApiKeyChange } from "@/lib/security/api-key-notification"; import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; @@ -27,36 +24,20 @@ export async function DELETE( return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // User-scoped API keys (wfb_ prefix) aren't tied to an org so the - // owner-role gate doesn't apply; require MFA enrolled + step-up - // cleared. Symmetric with creation (same gate in POST). - const sessionRow = session.session as { requiresMfa?: boolean | null }; - const guard = await requireMfaEnrolled( - session.user.id, - sessionRow.requiresMfa === true - ); - if (!guard.ok) { - return NextResponse.json( - { error: guard.error, code: guard.code }, - { status: guard.status } - ); - } - - // Dual-factor at revoke time. Symmetric with create. const body = (await request.json().catch(() => ({}))) as { code?: string; emailOtp?: string; + signature?: string; }; - const dual = await requireDualFactor({ - userId: session.user.id, - email: session.user.email, - action: "user_api_key_revoke", - code: body.code, - emailOtp: body.emailOtp, + const authorized = await authorizeAction({ + session, + action: STEP_UP_ACTIONS.apiKeyManage, + roleFloor: "none", + body, headers: request.headers, }); - if (!dual.ok) { - return dualFactorErrorResponse(dual); + if (!authorized.ok) { + return authorized.response; } // Delete the key (only if it belongs to the user) @@ -76,7 +57,8 @@ export async function DELETE( // Out-of-band alert symmetric with creation: the owner learns a bypass // credential was revoked even if their own session did it. Non-blocking. notifyApiKeyChange({ - email: session.user.email, + userId: session.user.id, + loginEmail: session.user.email, action: "revoked", tokenName: deleted.name, keyPrefix: deleted.keyPrefix, diff --git a/app/api/api-keys/route.ts b/app/api/api-keys/route.ts index 478d1e1cba..058bbee9f5 100644 --- a/app/api/api-keys/route.ts +++ b/app/api/api-keys/route.ts @@ -6,11 +6,8 @@ import { db } from "@/lib/db"; import { apiKeys } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; import { parseScopeInput } from "@/lib/mcp/oauth-scopes"; -import { - dualFactorErrorResponse, - requireDualFactor, -} from "@/lib/mfa/dual-factor"; -import { requireMfaEnrolled } from "@/lib/middleware/owner-mfa-guard"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; +import { authorizeAction } from "@/lib/middleware/authorize-action"; import { buildPage, parsePageRequest } from "@/lib/pagination"; import { notifyApiKeyChange } from "@/lib/security/api-key-notification"; import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; @@ -86,53 +83,22 @@ export async function POST(request: Request) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - // Check if user is anonymous - const isAnonymous = - session.user.name === "Anonymous" || - session.user.email?.startsWith("temp-"); - - if (isAnonymous) { - return NextResponse.json( - { error: "Anonymous users cannot create API keys" }, - { status: 403 } - ); - } - - // User-scoped key creation is the highest-leverage forever-bypass - // a session can mint. Even though the apiKeys table has no org - // column to gate on, we require MFA enrolled + step-up cleared so - // a stolen session alone can't issue a key that survives any - // future MFA enforcement. - const sessionRow = session.session as { requiresMfa?: boolean | null }; - const guard = await requireMfaEnrolled( - session.user.id, - sessionRow.requiresMfa === true - ); - if (!guard.ok) { - return NextResponse.json( - { error: guard.error, code: guard.code }, - { status: guard.status } - ); - } - const body = await request.json().catch(() => ({})); - const name = body.name || null; - const scope = parseScopeInput(body.scopes); - // Dual-factor at mint time. Long-lived bypass credentials warrant - // a fresh challenge on BOTH factors at the exact moment of issue. - const dual = await requireDualFactor({ - userId: session.user.id, - email: session.user.email, - action: "user_api_key_create", - code: typeof body.code === "string" ? body.code : undefined, - emailOtp: typeof body.emailOtp === "string" ? body.emailOtp : undefined, + const authorized = await authorizeAction({ + session, + action: STEP_UP_ACTIONS.apiKeyManage, + roleFloor: "none", + body, headers: request.headers, }); - if (!dual.ok) { - return dualFactorErrorResponse(dual); + if (!authorized.ok) { + return authorized.response; } + const name = body.name || null; + const scope = parseScopeInput(body.scopes); + // Generate new API key const { key, hash, prefix } = generateApiKey(); @@ -156,7 +122,8 @@ export async function POST(request: Request) { // Out-of-band alert so the owner learns a long-lived bypass credential // was minted, even if their own session did it. Non-blocking. notifyApiKeyChange({ - email: session.user.email, + userId: session.user.id, + loginEmail: session.user.email, action: "created", tokenName: newKey.name, keyPrefix: newKey.keyPrefix, diff --git a/app/api/gas-sponsorship/route.ts b/app/api/gas-sponsorship/route.ts new file mode 100644 index 0000000000..47c1553158 --- /dev/null +++ b/app/api/gas-sponsorship/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { getGasCreditCapCents } from "@/lib/billing/gas-credits"; +import { formatGasCreditUsd } from "@/lib/billing/gas-sponsorship-public"; +import { isGasSponsorshipEnabled } from "@/lib/web3/sponsorship-feature-flag"; + +/** + * GET /api/gas-sponsorship + * + * Public, read-only view of the free-tier gas sponsorship amount so onboarding + * surfaces can explain "every account gets $X of sponsored gas on mainnet" + * without baking the amount into a NEXT_PUBLIC build env. The authoritative + * value is the server-only GAS_CREDITS_FREE_CENTS (lib/billing/gas-credits.ts). + */ +export function GET(): NextResponse { + const freeCents = getGasCreditCapCents("free"); + return NextResponse.json({ + enabled: isGasSponsorshipEnabled(), + freeCents, + label: formatGasCreditUsd(freeCents), + }); +} diff --git a/app/api/keys/[keyId]/route.ts b/app/api/keys/[keyId]/route.ts index a8379f2dd3..e746cf70be 100644 --- a/app/api/keys/[keyId]/route.ts +++ b/app/api/keys/[keyId]/route.ts @@ -5,12 +5,9 @@ import { db } from "@/lib/db"; import { organizationApiKeys } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; import { SCOPE_MCP_WRITE } from "@/lib/mcp/oauth-scopes"; -import { - dualFactorErrorResponse, - requireDualFactor, -} from "@/lib/mfa/dual-factor"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; import { resolveOrganizationId } from "@/lib/middleware/auth-helpers"; -import { requireAdminOrOwnerWithMfa } from "@/lib/middleware/owner-mfa-guard"; +import { authorizeAction } from "@/lib/middleware/authorize-action"; import { requireScope } from "@/lib/middleware/require-scope"; import { notifyApiKeyChange } from "@/lib/security/api-key-notification"; import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; @@ -47,36 +44,22 @@ export async function DELETE( if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - const sessionRow = session.session as { requiresMfa?: boolean | null }; - const guard = await requireAdminOrOwnerWithMfa( - session.user.id, - activeOrgId, - sessionRow.requiresMfa === true - ); - if (!guard.ok) { - return NextResponse.json( - { error: guard.error, code: guard.code }, - { status: guard.status } - ); - } - // Dual-factor at revoke time. Same rationale as the create leg: - // a stolen session must not be able to rotate keys (revoke + mint - // elsewhere) without re-challenging on both factors. const body = (await request.json().catch(() => ({}))) as { code?: string; emailOtp?: string; + signature?: string; }; - const dual = await requireDualFactor({ - userId: session.user.id, - email: session.user.email, - action: "org_api_key_revoke", - code: body.code, - emailOtp: body.emailOtp, + const authorized = await authorizeAction({ + session, + action: STEP_UP_ACTIONS.orgApiKeyManage, + roleFloor: "admin", + organizationId: activeOrgId, + body, headers: request.headers, }); - if (!dual.ok) { - return dualFactorErrorResponse(dual); + if (!authorized.ok) { + return authorized.response; } // Revoke the key (soft delete) - only if it belongs to the organization @@ -105,7 +88,8 @@ export async function DELETE( // Out-of-band alert + durable audit record, symmetric with user keys. notifyApiKeyChange({ - email: session.user.email, + userId: session.user.id, + loginEmail: session.user.email, action: "revoked", tokenName: revoked.name, keyPrefix: revoked.keyPrefix, diff --git a/app/api/keys/route.ts b/app/api/keys/route.ts index 3342e28118..b283e21359 100644 --- a/app/api/keys/route.ts +++ b/app/api/keys/route.ts @@ -6,13 +6,10 @@ import { db } from "@/lib/db"; import { member, organizationApiKeys, users } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; import { parseScopeInput } from "@/lib/mcp/oauth-scopes"; -import { - dualFactorErrorResponse, - requireDualFactor, -} from "@/lib/mfa/dual-factor"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; import { resolveOrganizationId } from "@/lib/middleware/auth-helpers"; +import { authorizeAction } from "@/lib/middleware/authorize-action"; import { getOrgContext } from "@/lib/middleware/org-context"; -import { requireAdminOrOwnerWithMfa } from "@/lib/middleware/owner-mfa-guard"; import { buildPage, parsePageRequest } from "@/lib/pagination"; import { notifyApiKeyChange } from "@/lib/security/api-key-notification"; import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; @@ -143,53 +140,22 @@ export async function POST(request: Request) { ); } - // Check if user is anonymous - const isAnonymous = - session.user.name === "Anonymous" || - session.user.email?.startsWith("temp-"); - - if (isAnonymous) { - return NextResponse.json( - { error: "Anonymous users cannot create API keys" }, - { status: 403 } - ); - } - - // Creating an org API key mints a long-lived credential that - // bypasses session MFA forever afterward, so gate the act of - // minting with admin/owner role + MFA enrolled + step-up cleared. - // Once issued the key itself is not MFA-aware; the time to enforce - // is at creation. - const sessionRow = session.session as { requiresMfa?: boolean | null }; - const guard = await requireAdminOrOwnerWithMfa( - session.user.id, - activeOrgId, - sessionRow.requiresMfa === true - ); - if (!guard.ok) { - return NextResponse.json( - { error: guard.error, code: guard.code }, - { status: guard.status } - ); - } - - // Parse request body + // Parse the body once: step-up codes plus the key fields used below. const body = await request.json().catch(() => ({})); - // Dual-factor challenge — minting a forever-bypass credential - // warrants both a fresh TOTP from the authenticator AND a fresh - // email OTP from the user's inbox. Symmetric with withdraw / - // export-key. - const dual = await requireDualFactor({ - userId: session.user.id, - email: session.user.email, - action: "org_api_key_create", - code: typeof body.code === "string" ? body.code : undefined, - emailOtp: typeof body.emailOtp === "string" ? body.emailOtp : undefined, + // Minting an org API key creates a long-lived credential that bypasses + // session MFA forever, so gate it with admin role + a fresh step-up + // (dual-factor for email/OAuth, a wallet signature for wallet accounts). + const authorized = await authorizeAction({ + session, + action: STEP_UP_ACTIONS.orgApiKeyManage, + roleFloor: "admin", + organizationId: activeOrgId, + body, headers: request.headers, }); - if (!dual.ok) { - return dualFactorErrorResponse(dual); + if (!authorized.ok) { + return authorized.response; } const name = body.name || null; const expiresAt = body.expiresAt ? new Date(body.expiresAt) : null; @@ -218,13 +184,10 @@ export async function POST(request: Request) { expiresAt: organizationApiKeys.expiresAt, }); - console.log( - `[API Keys] Created new API key for organization ${activeOrgId}: ${newKey.id}` - ); - // Out-of-band alert + durable audit record, symmetric with user keys. notifyApiKeyChange({ - email: session.user.email, + userId: session.user.id, + loginEmail: session.user.email, action: "created", tokenName: newKey.name, keyPrefix: newKey.keyPrefix, diff --git a/app/api/onboarding/recommendations/route.ts b/app/api/onboarding/recommendations/route.ts new file mode 100644 index 0000000000..3da68ced46 --- /dev/null +++ b/app/api/onboarding/recommendations/route.ts @@ -0,0 +1,111 @@ +import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { member, workflows } from "@/lib/db/schema"; +import { ONBOARDING_WORKFLOW_FIXTURES } from "@/scripts/seed/fixtures/onboarding-workflows"; + +const CHIP_SLUGS = [ + "aave-health", + "whale-withdrawal", + "governance", + "sky-staking", + "steth-wrap", + "usds-savings", +] as const; + +const SLUG_SET = new Set(CHIP_SLUGS); + +export async function GET(request: Request): Promise { + const rows = await db + .select({ id: workflows.id, listedSlug: workflows.listedSlug }) + .from(workflows) + .where( + and( + inArray(workflows.listedSlug, [...CHIP_SLUGS]), + isNull(workflows.deletedAt), + isNotNull(workflows.listedSlug) + ) + ); + + const map: Record = {}; + for (const row of rows) { + if (row.listedSlug) { + map[row.listedSlug] = row.id; + } + } + + const missing = ONBOARDING_WORKFLOW_FIXTURES.filter( + (f) => SLUG_SET.has(f.listedSlug) && !map[f.listedSlug] + ); + + if (missing.length > 0) { + try { + const session = await auth.api + .getSession({ headers: request.headers }) + .catch(() => null); + const userId = session?.user?.id; + + if (userId) { + const memberRow = await db + .select({ organizationId: member.organizationId }) + .from(member) + .where(eq(member.userId, userId)) + .limit(1); + + const orgId = memberRow[0]?.organizationId; + if (orgId) { + const now = new Date(); + await Promise.allSettled( + missing.map((fixture) => + db + .insert(workflows) + .values({ + id: fixture.id, + name: fixture.name, + description: fixture.description, + userId, + organizationId: orgId, + nodes: fixture.nodes, + edges: fixture.edges, + visibility: "public", + enabled: true, + featured: true, + featuredProtocol: fixture.featuredProtocol, + listedSlug: fixture.listedSlug, + seededAt: now, + createdAt: now, + updatedAt: now, + deletedAt: null, + }) + .onConflictDoNothing() + ) + ); + + const freshRows = await db + .select({ id: workflows.id, listedSlug: workflows.listedSlug }) + .from(workflows) + .where( + and( + inArray(workflows.listedSlug, [...CHIP_SLUGS]), + isNull(workflows.deletedAt), + isNotNull(workflows.listedSlug) + ) + ); + + for (const row of freshRows) { + if (row.listedSlug) { + map[row.listedSlug] = row.id; + } + } + } + } + } catch { + // best-effort; fall through and return whatever is in map + } + } + + return NextResponse.json(map, { + headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=30" }, + }); +} diff --git a/app/api/onboarding/status/route.ts b/app/api/onboarding/status/route.ts new file mode 100644 index 0000000000..af08adf35c --- /dev/null +++ b/app/api/onboarding/status/route.ts @@ -0,0 +1,99 @@ +import { and, eq, inArray, isNull, ne, sql } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { + integrations, + organizationApiKeys, + workflowExecutions, + workflows, +} from "@/lib/db/schema"; +import { resolveOrganizationId } from "@/lib/middleware/auth-helpers"; + +/** + * GET /api/onboarding/status[?workflowIds=a,b,c] + * + * Real-state completion signals for the getting-started launcher (KEEP-878). + * Each is an org-scoped existence check, so an outcome step only turns green + * once the user has actually done it. + * + * `workflowIds` are the drafts/clones the launcher itself created for its "run + * a workflow" / "pick a starter" steps. We return: + * - `executedWorkflowIds`: the subset that has at least one execution, so a + * step completes when *that* workflow has been run, never because some + * other pre-existing workflow in the org has historical runs. + * - `existingWorkflowIds`: the subset that still exists (not deleted) in the + * org, so the launcher can tell a live clone from a stale localStorage + * pointer to a workflow that was since deleted. + * Informational steps ("open your wallet") complete client-side and are not + * reported here. + */ +export async function GET(request: Request): Promise { + const authCtx = await resolveOrganizationId(request); + if ("error" in authCtx) { + return NextResponse.json( + { error: authCtx.error }, + { status: authCtx.status } + ); + } + const orgId = authCtx.organizationId; + + const url = new URL(request.url); + const idsParam = url.searchParams.get("workflowIds"); + const workflowIds = idsParam + ? idsParam.split(",").filter((id) => id.length > 0) + : []; + + const [apiKey, integration, executed, existing] = await Promise.all([ + db + .select({ one: sql`1` }) + .from(organizationApiKeys) + .where( + and( + eq(organizationApiKeys.organizationId, orgId), + isNull(organizationApiKeys.revokedAt) + ) + ) + .limit(1), + db + .select({ one: sql`1` }) + .from(integrations) + .where( + and( + eq(integrations.organizationId, orgId), + ne(integrations.type, "web3") + ) + ) + .limit(1), + workflowIds.length === 0 + ? Promise.resolve([] as { workflowId: string }[]) + : db + .selectDistinct({ workflowId: workflowExecutions.workflowId }) + .from(workflowExecutions) + .innerJoin(workflows, eq(workflows.id, workflowExecutions.workflowId)) + .where( + and( + eq(workflows.organizationId, orgId), + inArray(workflowExecutions.workflowId, workflowIds) + ) + ), + workflowIds.length === 0 + ? Promise.resolve([] as { id: string }[]) + : db + .select({ id: workflows.id }) + .from(workflows) + .where( + and( + eq(workflows.organizationId, orgId), + inArray(workflows.id, workflowIds), + isNull(workflows.deletedAt) + ) + ), + ]); + + return NextResponse.json({ + hasApiKey: apiKey.length > 0, + hasIntegration: integration.length > 0, + executedWorkflowIds: executed.map((row) => row.workflowId), + existingWorkflowIds: existing.map((row) => row.id), + }); +} diff --git a/app/api/organizations/[organizationId]/mfa-enforcement/route.ts b/app/api/organizations/[organizationId]/mfa-enforcement/route.ts new file mode 100644 index 0000000000..2116ae2c89 --- /dev/null +++ b/app/api/organizations/[organizationId]/mfa-enforcement/route.ts @@ -0,0 +1,182 @@ +import { and, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { db } from "@/lib/db"; +import { member, organization } from "@/lib/db/schema"; +import { ErrorCategory, logSystemError } from "@/lib/logging"; +import { SCOPE_MCP_WRITE } from "@/lib/mcp/oauth-scopes"; +import { invalidateOrgMfaEnforcement, parseEnforcedFactors } from "@/lib/mfa/org-mfa-enforcement"; +import type { StepUpFactor } from "@/lib/mfa/step-up-policy"; +import { getDualAuthContext } from "@/lib/middleware/auth-helpers"; +import { requireScope } from "@/lib/middleware/require-scope"; +import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; + +type OwnerOk = { + ok: true; + userId: string; + authMethod: string; + apiKeyId: string | null; + scope?: string; +}; +type OwnerErr = { ok: false; status: number; error: string }; + +// MFA enforcement is a security-critical org setting, so only the owner may +// read or change it (stricter than the owner+admin digest settings). +async function requireOrgRole( + request: Request, + organizationId: string, + allowedRoles: ReadonlySet = new Set(["owner"]) +): Promise { + const authContext = await getDualAuthContext(request); + if ("error" in authContext) { + return { ok: false, status: authContext.status, error: authContext.error }; + } + const { userId, organizationId: callerOrgId, authMethod } = authContext; + if (!userId) { + return { ok: false, status: 400, error: "Auth context missing user" }; + } + if (authMethod === "api-key" && callerOrgId !== organizationId) { + return { ok: false, status: 403, error: "Forbidden" }; + } + + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where( + and(eq(member.organizationId, organizationId), eq(member.userId, userId)) + ) + .limit(1); + + if (!(membership && allowedRoles.has(membership.role))) { + return { ok: false, status: 403, error: "Forbidden" }; + } + return { + ok: true, + userId, + authMethod, + apiKeyId: authContext.apiKeyId, + scope: authContext.scope, + }; +} + +export async function GET( + request: Request, + context: { params: Promise<{ organizationId: string }> } +): Promise { + try { + const { organizationId } = await context.params; + // Admins may read the enforcement status (read-only); only the owner writes. + const access = await requireOrgRole( + request, + organizationId, + new Set(["owner", "admin"]) + ); + if (!access.ok) { + return NextResponse.json( + { error: access.error }, + { status: access.status } + ); + } + + const [row] = await db + .select({ + enforceMfa: organization.enforceMfa, + enforcedMfaFactors: organization.enforcedMfaFactors, + }) + .from(organization) + .where(eq(organization.id, organizationId)) + .limit(1); + + return NextResponse.json({ + enforce: row?.enforceMfa ?? false, + factors: parseEnforcedFactors(row?.enforcedMfaFactors), + }); + } catch (error) { + logSystemError( + ErrorCategory.DATABASE, + "Failed to load org MFA enforcement", + error, + { endpoint: "/api/organizations/[organizationId]/mfa-enforcement" } + ); + return NextResponse.json( + { error: "Failed to load settings" }, + { status: 500 } + ); + } +} + +type PutBody = { + enforce?: boolean; + factors?: string[]; +}; + +export async function PUT( + request: Request, + context: { params: Promise<{ organizationId: string }> } +): Promise { + try { + const { organizationId } = await context.params; + const owner = await requireOrgRole(request, organizationId); + if (!owner.ok) { + return NextResponse.json( + { error: owner.error }, + { status: owner.status } + ); + } + + const scopeError = requireScope(owner.scope, SCOPE_MCP_WRITE); + if (scopeError) { + return scopeError; + } + + const body = (await request.json().catch(() => ({}))) as PutBody; + const enforce = Boolean(body.enforce); + const factors: StepUpFactor[] = parseEnforcedFactors(body.factors); + + // Turning enforcement on with no factor selected would gate members against + // a requirement nobody can satisfy. Require at least one factor when on. + if (enforce && factors.length === 0) { + return NextResponse.json( + { + error: "Select at least one factor to enforce.", + code: "no_factor_selected", + }, + { status: 400 } + ); + } + + const enforcedMfaFactors = enforce ? factors : null; + await db + .update(organization) + .set({ enforceMfa: enforce, enforcedMfaFactors }) + .where(eq(organization.id, organizationId)); + + invalidateOrgMfaEnforcement(organizationId); + + await recordAuditEvent({ + actor: { + userId: owner.userId, + organizationId, + authMethod: owner.authMethod, + apiKeyId: owner.apiKeyId, + }, + action: "org.mfa_enforcement_changed", + resourceType: "organization", + resourceId: organizationId, + after: { enforce, factors: enforcedMfaFactors }, + metadata: buildAuditMetadata(request), + }); + + return NextResponse.json({ enforce, factors }); + } catch (error) { + logSystemError( + ErrorCategory.DATABASE, + "Failed to save org MFA enforcement", + error, + { endpoint: "/api/organizations/[organizationId]/mfa-enforcement" } + ); + return NextResponse.json( + { error: "Failed to save settings" }, + { status: 500 } + ); + } +} diff --git a/app/api/organizations/[organizationId]/wallet-lookup/route.ts b/app/api/organizations/[organizationId]/wallet-lookup/route.ts new file mode 100644 index 0000000000..0da89abc36 --- /dev/null +++ b/app/api/organizations/[organizationId]/wallet-lookup/route.ts @@ -0,0 +1,106 @@ +import { and, eq, sql } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { isAddress } from "viem"; +import { isWalletEmail } from "@/lib/auth/wallet-constants"; +import { db } from "@/lib/db"; +import { member, users, walletAddress } from "@/lib/db/schema"; +import { ErrorCategory, logSystemError } from "@/lib/logging"; +import { getDualAuthContext } from "@/lib/middleware/auth-helpers"; + +type ManagerOk = { ok: true; userId: string }; +type ManagerErr = { ok: false; status: number; error: string }; + +// Resolving an address to an account is enumeration-sensitive, so restrict it +// to people who can already invite: owners and admins of the target org. +async function requireOrgManager( + request: Request, + organizationId: string +): Promise { + const authContext = await getDualAuthContext(request); + if ("error" in authContext) { + return { ok: false, status: authContext.status, error: authContext.error }; + } + const { userId, organizationId: callerOrgId, authMethod } = authContext; + if (!userId) { + return { ok: false, status: 400, error: "Auth context missing user" }; + } + if (authMethod === "api-key" && callerOrgId !== organizationId) { + return { ok: false, status: 403, error: "Forbidden" }; + } + const [membership] = await db + .select({ role: member.role }) + .from(member) + .where( + and(eq(member.organizationId, organizationId), eq(member.userId, userId)) + ) + .limit(1); + if (!(membership?.role === "owner" || membership?.role === "admin")) { + return { ok: false, status: 403, error: "Forbidden" }; + } + return { ok: true, userId }; +} + +export async function GET( + request: Request, + context: { params: Promise<{ organizationId: string }> } +): Promise { + try { + const { organizationId } = await context.params; + const manager = await requireOrgManager(request, organizationId); + if (!manager.ok) { + return NextResponse.json( + { error: manager.error }, + { status: manager.status } + ); + } + + const address = new URL(request.url).searchParams.get("address")?.trim(); + if (!(address && isAddress(address))) { + return NextResponse.json( + { error: "Invalid wallet address", code: "invalid_address" }, + { status: 400 } + ); + } + + const [wallet] = await db + .select({ + userId: walletAddress.userId, + email: users.email, + name: users.name, + }) + .from(walletAddress) + .innerJoin(users, eq(users.id, walletAddress.userId)) + .where(sql`lower(${walletAddress.address}) = ${address.toLowerCase()}`) + .limit(1); + + if (!(wallet && isWalletEmail(wallet.email))) { + return NextResponse.json({ found: false }); + } + + const [existing] = await db + .select({ id: member.id }) + .from(member) + .where( + and( + eq(member.organizationId, organizationId), + eq(member.userId, wallet.userId) + ) + ) + .limit(1); + + return NextResponse.json({ + found: true, + email: wallet.email, + name: wallet.name, + alreadyMember: Boolean(existing), + }); + } catch (error) { + logSystemError( + ErrorCategory.DATABASE, + "Failed to look up wallet address for invite", + error, + { endpoint: "/api/organizations/[organizationId]/wallet-lookup" } + ); + return NextResponse.json({ error: "Lookup failed" }, { status: 500 }); + } +} diff --git a/app/api/organizations/invitations/[invitationId]/wallet-accept/route.ts b/app/api/organizations/invitations/[invitationId]/wallet-accept/route.ts new file mode 100644 index 0000000000..92ea6d6f94 --- /dev/null +++ b/app/api/organizations/invitations/[invitationId]/wallet-accept/route.ts @@ -0,0 +1,239 @@ +import { and, eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { isAddress } from "viem"; +import { auth } from "@/lib/auth"; +import { isWalletEmail } from "@/lib/auth/wallet-constants"; +import { db } from "@/lib/db"; +import { invitation, member } from "@/lib/db/schema"; +import { ErrorCategory, logSystemError } from "@/lib/logging"; +import { + getInviteChallengeMessage, + verifyInviteChallenge, +} from "@/lib/org/wallet-invite-challenge"; +import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; +import { generateId } from "@/lib/utils/id"; + +type InvitationRow = { + id: string; + email: string; + role: string | null; + status: string; + organizationId: string; + expiresAt: Date; +}; + +type Loaded = + | { ok: true; userId: string; invite: InvitationRow; address: string } + | { ok: false; status: number; error: string; code?: string }; + +// Shared guard for both GET (fetch challenge) and POST (accept): the caller must +// hold a SIWE session that IS the invited wallet account, and the invitation +// must be a live, wallet-typed, pending invite. +async function loadContext( + request: Request, + invitationId: string +): Promise { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user) { + return { + ok: false, + status: 401, + error: "Sign in with your wallet first.", + code: "wallet_signin_required", + }; + } + + const [invite] = await db + .select({ + id: invitation.id, + email: invitation.email, + role: invitation.role, + status: invitation.status, + organizationId: invitation.organizationId, + expiresAt: invitation.expiresAt, + }) + .from(invitation) + .where(eq(invitation.id, invitationId)) + .limit(1); + + if (!invite) { + return { ok: false, status: 404, error: "Invitation not found" }; + } + if (!isWalletEmail(invite.email)) { + return { + ok: false, + status: 400, + error: "This invitation is not for a wallet account.", + code: "not_wallet_invite", + }; + } + if (invite.status !== "pending") { + return { + ok: false, + status: 409, + error: "This invitation is no longer pending.", + code: "not_pending", + }; + } + if (invite.expiresAt.getTime() < Date.now()) { + return { + ok: false, + status: 410, + error: "This invitation has expired.", + code: "expired", + }; + } + // The session user must be the invited account. + if (session.user.email.toLowerCase() !== invite.email.toLowerCase()) { + return { + ok: false, + status: 403, + error: "Sign in with the invited wallet to accept.", + code: "wrong_account", + }; + } + + const address = invite.email.split("@")[0]; + if (!(address && isAddress(address))) { + return { + ok: false, + status: 400, + error: "Invitation address is invalid.", + code: "invalid_address", + }; + } + + return { ok: true, userId: session.user.id, invite, address }; +} + +export async function GET( + request: Request, + context: { params: Promise<{ invitationId: string }> } +): Promise { + try { + const { invitationId } = await context.params; + const loaded = await loadContext(request, invitationId); + if (!loaded.ok) { + return NextResponse.json( + { error: loaded.error, code: loaded.code }, + { status: loaded.status } + ); + } + const message = await getInviteChallengeMessage(invitationId); + return NextResponse.json({ message }); + } catch (error) { + logSystemError( + ErrorCategory.AUTH, + "Failed to build wallet invite challenge", + error, + { + endpoint: "/api/organizations/invitations/[invitationId]/wallet-accept", + } + ); + return NextResponse.json( + { error: "Failed to start acceptance" }, + { status: 500 } + ); + } +} + +export async function POST( + request: Request, + context: { params: Promise<{ invitationId: string }> } +): Promise { + try { + const { invitationId } = await context.params; + const loaded = await loadContext(request, invitationId); + if (!loaded.ok) { + return NextResponse.json( + { error: loaded.error, code: loaded.code }, + { status: loaded.status } + ); + } + + const body = (await request.json().catch(() => ({}))) as { + signature?: string; + }; + const signature = + typeof body.signature === "string" ? body.signature.trim() : ""; + if (!signature) { + return NextResponse.json( + { + error: "A wallet signature is required.", + code: "signature_required", + }, + { status: 400 } + ); + } + + const verified = await verifyInviteChallenge( + invitationId, + loaded.address, + signature + ); + if (!verified) { + return NextResponse.json( + { error: "Invalid wallet signature.", code: "signature_invalid" }, + { status: 401 } + ); + } + + // Verified the invited wallet signed the live challenge while signed in as + // that account; complete the join. Wrapped in a transaction so the existence + // check, member insert, and invitation update are atomic — concurrent POSTs + // that both pass signature verification cannot both insert a member row. + await db.transaction(async (tx) => { + const [existing] = await tx + .select({ id: member.id }) + .from(member) + .where( + and( + eq(member.organizationId, loaded.invite.organizationId), + eq(member.userId, loaded.userId) + ) + ) + .limit(1); + + if (!existing) { + await tx.insert(member).values({ + id: generateId(), + organizationId: loaded.invite.organizationId, + userId: loaded.userId, + role: loaded.invite.role ?? "member", + createdAt: new Date(), + }); + } + await tx + .update(invitation) + .set({ status: "accepted" }) + .where(eq(invitation.id, invitationId)); + }); + + await recordAuditEvent({ + actor: { + userId: loaded.userId, + organizationId: loaded.invite.organizationId, + authMethod: "session", + }, + action: "member.joined", + resourceType: "organization", + resourceId: loaded.invite.organizationId, + metadata: buildAuditMetadata(request), + }); + + return NextResponse.json({ ok: true }); + } catch (error) { + logSystemError( + ErrorCategory.AUTH, + "Failed to accept wallet invitation", + error, + { + endpoint: "/api/organizations/invitations/[invitationId]/wallet-accept", + } + ); + return NextResponse.json( + { error: "Failed to accept invitation" }, + { status: 500 } + ); + } +} diff --git a/app/api/security/audit/export/route.ts b/app/api/security/audit/export/route.ts index 83384f2a9f..e76ad8b69f 100644 --- a/app/api/security/audit/export/route.ts +++ b/app/api/security/audit/export/route.ts @@ -3,12 +3,9 @@ import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { member, securityAuditLog, users } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; -import { - dualFactorErrorResponse, - requireDualFactor, -} from "@/lib/mfa/dual-factor"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; +import { authorizeAction } from "@/lib/middleware/authorize-action"; import { getActiveOrgId } from "@/lib/middleware/org-context"; -import { requireOwnerWithMfa } from "@/lib/middleware/owner-mfa-guard"; import { redactAuditDiff } from "@/lib/security/audit-redaction"; import { toCsvCell } from "@/lib/security/csv"; @@ -48,6 +45,7 @@ type ExportBody = { resourceTypes?: unknown; code?: string; emailOtp?: string; + signature?: string; }; export async function POST(request: Request): Promise { @@ -67,31 +65,16 @@ export async function POST(request: Request): Promise { const body = (await request.json().catch(() => ({}))) as ExportBody; - // Passive gate: owner role + MFA enrolled + session step-up cleared. - const sessionRow = session.session as { requiresMfa?: boolean | null }; - const guard = await requireOwnerWithMfa( - session.user.id, + const authorized = await authorizeAction({ + session, + action: STEP_UP_ACTIONS.auditExport, + roleFloor: "owner", organizationId, - sessionRow.requiresMfa === true - ); - if (!guard.ok) { - return Response.json( - { error: guard.error, code: guard.code }, - { status: guard.status } - ); - } - - // Active gate: authenticator + emailed code at the moment of export. - const dual = await requireDualFactor({ - userId: session.user.id, - email: session.user.email, - action: "audit_export", - code: body.code, - emailOtp: body.emailOtp, + body, headers: request.headers, }); - if (!dual.ok) { - return dualFactorErrorResponse(dual); + if (!authorized.ok) { + return authorized.response; } const rawDays = Number.parseInt(String(body.days ?? ""), 10); diff --git a/app/api/user/delete/route.ts b/app/api/user/delete/route.ts index 0c191e3a64..6725d0a1ca 100644 --- a/app/api/user/delete/route.ts +++ b/app/api/user/delete/route.ts @@ -1,13 +1,11 @@ -import { and, eq, isNull } from "drizzle-orm"; +import { and, count, eq, isNull } from "drizzle-orm"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; -import { organizationApiKeys, sessions, users } from "@/lib/db/schema"; +import { organizationApiKeys, sessions, users, walletAddress } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; -import { - dualFactorErrorResponse, - requireDualFactor, -} from "@/lib/mfa/dual-factor"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; +import { authorizeAction } from "@/lib/middleware/authorize-action"; import { buildActor, buildAuditMetadata, @@ -35,8 +33,9 @@ export async function POST(request: Request): Promise { confirmation?: string; code?: string; emailOtp?: string; + signature?: string; }; - const { confirmation, code, emailOtp } = body; + const { confirmation, code, emailOtp, signature } = body; if (confirmation !== "DEACTIVATE") { return NextResponse.json( @@ -69,20 +68,15 @@ export async function POST(request: Request): Promise { ); } - // Dual-factor challenge. Account deletion cascades to sessions, - // revokes API keys, and flips deactivatedAt which cascades to - // wallets — a stolen session must not be able to nuke the account - // without proving BOTH the authenticator and the inbox. - const dual = await requireDualFactor({ - userId, - email: session.user.email, - action: "account_deactivate", - code, - emailOtp, + const authorized = await authorizeAction({ + session, + action: STEP_UP_ACTIONS.accountDeactivate, + roleFloor: "none", + body: { code, emailOtp, signature }, headers: request.headers, }); - if (!dual.ok) { - return dualFactorErrorResponse(dual); + if (!authorized.ok) { + return authorized.response; } // Run the deactivation writes in one transaction so a partial failure @@ -132,6 +126,16 @@ export async function POST(request: Request): Promise { organizationId: organizationApiKeys.organizationId, }); + // Count wallet addresses without removing them: deactivation is a + // soft delete so the user row stays and the FK cascade does not fire. + // The addresses are deliberately retained so the identity remains + // reserved and cannot be re-registered. The audit event records the + // count so auditors have a complete picture of the account state. + const [walletCount] = await tx + .select({ count: count() }) + .from(walletAddress) + .where(eq(walletAddress.userId, userId)); + // Record the cascade atomically with the writes that produced it: a // root event plus one row per affected resource, all sharing the // correlation id. Passing `tx` makes a failed audit write roll the @@ -151,6 +155,7 @@ export async function POST(request: Request): Promise { ...metadata, sessionsRevoked: revokedSessions.length, apiKeysRevoked: revokedKeys.length, + walletAddressesRetained: walletCount?.count ?? 0, }, }, { diff --git a/app/api/user/display-name/route.ts b/app/api/user/display-name/route.ts new file mode 100644 index 0000000000..a047bf593d --- /dev/null +++ b/app/api/user/display-name/route.ts @@ -0,0 +1,77 @@ +import { eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { isWalletEmail } from "@/lib/auth/wallet-constants"; +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { ErrorCategory, logSystemError } from "@/lib/logging"; +import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; + +const MAX_NAME_LENGTH = 50; +const WALLET_ADDRESS_NAME = /^0x/i; + +/** + * Sets the display name for a wallet (SIWE) account and marks it confirmed so + * the rename modal stops prompting. Restricted to wallet users: email/OAuth + * users edit their name through /api/user. Rejects raw 0x addresses so the + * audit trail never falls back to a hex address. + */ +export async function POST(request: Request): Promise { + try { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isWalletEmail(session.user.email)) { + return NextResponse.json( + { error: "Only wallet accounts can set a display name here." }, + { status: 403 } + ); + } + + const body = (await request.json().catch(() => ({}))) as { name?: unknown }; + const name = typeof body.name === "string" ? body.name.trim() : ""; + if (name.length === 0 || name.length > MAX_NAME_LENGTH) { + return NextResponse.json( + { error: `Name must be 1-${MAX_NAME_LENGTH} characters.` }, + { status: 400 } + ); + } + if (WALLET_ADDRESS_NAME.test(name)) { + return NextResponse.json( + { error: "Please choose a name that is not a wallet address." }, + { status: 400 } + ); + } + + await db + .update(users) + .set({ name, displayNameConfirmed: true, updatedAt: new Date() }) + .where(eq(users.id, session.user.id)); + + await recordAuditEvent({ + actor: { + userId: session.user.id, + organizationId: null, + authMethod: "session", + }, + action: "user.display_name_updated", + resourceType: "user", + resourceId: session.user.id, + metadata: buildAuditMetadata(request), + }); + + return NextResponse.json({ success: true, name }); + } catch (error) { + logSystemError( + ErrorCategory.DATABASE, + "Failed to set wallet display name", + error, + { endpoint: "/api/user/display-name" } + ); + return NextResponse.json( + { error: "Failed to update display name." }, + { status: 500 } + ); + } +} diff --git a/app/api/user/onboarding/complete/route.ts b/app/api/user/onboarding/complete/route.ts new file mode 100644 index 0000000000..121de2469d --- /dev/null +++ b/app/api/user/onboarding/complete/route.ts @@ -0,0 +1,37 @@ +import { eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { users } from "@/lib/db/schema"; +import { ErrorCategory, logSystemError } from "@/lib/logging"; + +/** + * Marks the /welcome onboarding wizard as completed for the signed-in user, so + * it is not shown again on any device or browser. Idempotent. + */ +export async function POST(request: Request): Promise { + try { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + await db + .update(users) + .set({ onboardingCompleted: true, updatedAt: new Date() }) + .where(eq(users.id, session.user.id)); + + return NextResponse.json({ success: true }); + } catch (error) { + logSystemError( + ErrorCategory.DATABASE, + "Failed to mark onboarding complete", + error, + { endpoint: "/api/user/onboarding/complete" } + ); + return NextResponse.json( + { error: "Failed to update onboarding status." }, + { status: 500 } + ); + } +} diff --git a/app/api/user/sessions/[sessionId]/revoke/route.ts b/app/api/user/sessions/[sessionId]/revoke/route.ts index 4d3e0de2ae..253c6dc817 100644 --- a/app/api/user/sessions/[sessionId]/revoke/route.ts +++ b/app/api/user/sessions/[sessionId]/revoke/route.ts @@ -1,19 +1,17 @@ import { and, eq } from "drizzle-orm"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; -import { isAnonymousUserShape } from "@/lib/auth-anonymous-guard"; import { db } from "@/lib/db"; import { sessions } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; -import { - dualFactorErrorResponse, - requireDualFactor, -} from "@/lib/mfa/dual-factor"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; +import { authorizeAction } from "@/lib/middleware/authorize-action"; import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; type RequestBody = { code?: string; emailOtp?: string; + signature?: string; }; /** @@ -25,11 +23,10 @@ type RequestBody = { * they no longer trust) without affecting the device they're * currently using. * - * Gated by `requireDualFactor` so a stolen session cookie alone - * cannot weaponise this endpoint to nuke a user's other devices. - * The current session cannot be revoked here; the regular sign-out - * flow handles that and leaves no surprises about how the dialog - * closes. + * Gated by step-up so a stolen session cookie alone cannot weaponise + * this endpoint to nuke a user's other devices. The current session + * cannot be revoked here; the regular sign-out flow handles that and + * leaves no surprises about how the dialog closes. */ export async function POST( request: Request, @@ -39,12 +36,6 @@ export async function POST( if (!session?.user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (isAnonymousUserShape(session.user)) { - return NextResponse.json( - { error: "Sign in with a real account" }, - { status: 403 } - ); - } const { sessionId } = await params; if (!sessionId) { @@ -67,20 +58,16 @@ export async function POST( } const body = (await request.json().catch(() => ({}))) as RequestBody; - const code = typeof body.code === "string" ? body.code.trim() : ""; - const emailOtp = - typeof body.emailOtp === "string" ? body.emailOtp.trim() : ""; - const dual = await requireDualFactor({ - userId: session.user.id, - email: session.user.email, - action: "session_revoke", - code, - emailOtp, + const authorized = await authorizeAction({ + session, + action: STEP_UP_ACTIONS.sessionRevoke, + roleFloor: "none", + body, headers: request.headers, }); - if (!dual.ok) { - return dualFactorErrorResponse(dual); + if (!authorized.ok) { + return authorized.response; } try { diff --git a/app/api/user/step-up/email/route.ts b/app/api/user/step-up/email/route.ts new file mode 100644 index 0000000000..2666ee5918 --- /dev/null +++ b/app/api/user/step-up/email/route.ts @@ -0,0 +1,277 @@ +import { randomInt, timingSafeEqual } from "node:crypto"; +import { symmetricDecrypt, symmetricEncrypt } from "better-auth/crypto"; +import { and, eq, gt, ne } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { isWalletEmail } from "@/lib/auth/wallet-constants"; +import { isDisposableEmailDomain } from "@/lib/auth-disposable-emails"; +import { db } from "@/lib/db"; +import { users, verifications } from "@/lib/db/schema"; +import { sendVerificationOTP } from "@/lib/email"; +import { ErrorCategory, logSystemError } from "@/lib/logging"; +import { checkDualFactorRateLimit } from "@/lib/mfa/dual-factor-rate-limit"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; +import { requireStepUp, stepUpErrorResponse } from "@/lib/mfa/wallet-step-up"; +import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; +import { generateId } from "@/lib/utils/id"; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const CODE_TTL_MINUTES = 10; + +function identifierFor(userId: string): string { + return `stepupemail:${userId}`; +} + +function constantTimeEquals(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} + +/** + * Adds a verified step-up email for a wallet account. Adding a factor is a + * "free" strengthening action, so it needs no step-up -- but the email itself + * must be proven by a code before it's stored. Two phases: + * 1. POST { email } -> emails a 6-digit code + * 2. POST { email, code } -> verifies and stores users.step_up_email + */ +export async function POST(request: Request): Promise { + try { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isWalletEmail(session.user.email)) { + return NextResponse.json( + { error: "Only wallet accounts add a step-up email." }, + { status: 403 } + ); + } + const serverSecret = process.env.BETTER_AUTH_SECRET; + if (!serverSecret) { + return NextResponse.json( + { error: "Server misconfigured" }, + { status: 500 } + ); + } + + const rateLimit = checkDualFactorRateLimit( + session.user.id, + STEP_UP_ACTIONS.stepUpEmailEnroll + ); + if (!rateLimit.allowed) { + return NextResponse.json( + { error: "Too many attempts. Wait and try again." }, + { + status: 429, + headers: { "Retry-After": String(rateLimit.retryAfter ?? 60) }, + } + ); + } + + const body = (await request.json().catch(() => ({}))) as { + email?: unknown; + code?: unknown; + }; + const email = + typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; + const code = typeof body.code === "string" ? body.code.trim() : ""; + if ( + !EMAIL_RE.test(email) || + isWalletEmail(email) || + isDisposableEmailDomain(email) + ) { + return NextResponse.json( + { error: "Enter a valid, non-disposable email." }, + { status: 400 } + ); + } + + // Reject if the email is already the primary login address for any other + // account. Step-up emails must be distinct from login identities: sharing + // an inbox between a login account and a wallet step-up address would + // route security OTPs to a third-party inbox and create confusing audit + // state. The check is on users.email (the login email column), not + // stepUpEmail, so two wallet users sharing a step-up inbox remains + // possible but is blocked from colliding with a real login identity. + const [existing] = await db + .select({ id: users.id }) + .from(users) + .where(and(eq(users.email, email), ne(users.id, session.user.id))) + .limit(1); + if (existing) { + return NextResponse.json( + { error: "This email is already registered with another account." }, + { status: 409 } + ); + } + + const identifier = identifierFor(session.user.id); + + // Phase 1: request a code. + if (!code) { + const otp = randomInt(100_000, 999_999).toString(); + const encrypted = await symmetricEncrypt({ + key: serverSecret, + data: `${email}:${otp}`, + }); + await db + .delete(verifications) + .where(eq(verifications.identifier, identifier)); + await db.insert(verifications).values({ + id: generateId(), + identifier, + value: encrypted, + expiresAt: new Date(Date.now() + CODE_TTL_MINUTES * 60 * 1000), + createdAt: new Date(), + updatedAt: new Date(), + }); + const sent = await sendVerificationOTP({ + email, + otp, + type: "email-verification", + }); + if (!sent) { + return NextResponse.json( + { error: "Failed to send verification email." }, + { status: 503 } + ); + } + return NextResponse.json({ ok: true, sent: true }); + } + + // Phase 2: verify the code and store the email. + const [row] = await db + .select({ id: verifications.id, value: verifications.value }) + .from(verifications) + .where( + and( + eq(verifications.identifier, identifier), + gt(verifications.expiresAt, new Date()) + ) + ) + .limit(1); + if (!row) { + return NextResponse.json( + { error: "Code expired. Request a new one." }, + { status: 400 } + ); + } + let stored: string; + try { + stored = await symmetricDecrypt({ key: serverSecret, data: row.value }); + } catch (err) { + logSystemError( + ErrorCategory.AUTH, + "[StepUpEmail] Failed to decrypt code", + err, + { endpoint: "/api/user/step-up/email" } + ); + return NextResponse.json( + { error: "Server misconfigured" }, + { status: 500 } + ); + } + const separator = stored.lastIndexOf(":"); + const storedEmail = stored.slice(0, separator); + const storedCode = stored.slice(separator + 1); + if (storedEmail !== email || !constantTimeEquals(storedCode, code)) { + return NextResponse.json({ error: "Invalid code." }, { status: 400 }); + } + + await db.delete(verifications).where(eq(verifications.id, row.id)); + await db + .update(users) + .set({ stepUpEmail: email, updatedAt: new Date() }) + .where(eq(users.id, session.user.id)); + await recordAuditEvent({ + actor: { + userId: session.user.id, + organizationId: null, + authMethod: "session", + }, + action: "step_up_email.added", + resourceType: "user", + resourceId: session.user.id, + metadata: buildAuditMetadata(request), + }); + return NextResponse.json({ ok: true, email }); + } catch (error) { + logSystemError( + ErrorCategory.DATABASE, + "Failed to enroll step-up email", + error, + { endpoint: "/api/user/step-up/email" } + ); + return NextResponse.json( + { error: "Failed to add step-up email." }, + { status: 500 } + ); + } +} + +/** + * Removes the step-up email. Removing a factor is a weakening action, so it + * requires passing step-up first. + */ +export async function DELETE(request: Request): Promise { + try { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + if (!isWalletEmail(session.user.email)) { + return NextResponse.json( + { error: "Only wallet accounts manage a step-up email." }, + { status: 403 } + ); + } + + const body = (await request.json().catch(() => ({}))) as { + signature?: string; + code?: string; + emailOtp?: string; + }; + const stepUp = await requireStepUp({ + userId: session.user.id, + email: session.user.email, + action: STEP_UP_ACTIONS.stepUpEmailRemove, + signature: body.signature, + code: body.code, + emailOtp: body.emailOtp, + headers: request.headers, + }); + if (!stepUp.ok) { + return stepUpErrorResponse(stepUp); + } + + await db + .update(users) + .set({ stepUpEmail: null, updatedAt: new Date() }) + .where(eq(users.id, session.user.id)); + await recordAuditEvent({ + actor: { + userId: session.user.id, + organizationId: null, + authMethod: "session", + }, + action: "step_up_email.removed", + resourceType: "user", + resourceId: session.user.id, + metadata: buildAuditMetadata(request), + }); + return NextResponse.json({ ok: true }); + } catch (error) { + logSystemError( + ErrorCategory.DATABASE, + "Failed to remove step-up email", + error, + { endpoint: "/api/user/step-up/email" } + ); + return NextResponse.json( + { error: "Failed to remove step-up email." }, + { status: 500 } + ); + } +} diff --git a/app/api/user/step-up/policy/route.ts b/app/api/user/step-up/policy/route.ts new file mode 100644 index 0000000000..b59c925588 --- /dev/null +++ b/app/api/user/step-up/policy/route.ts @@ -0,0 +1,70 @@ +import { eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { auth } from "@/lib/auth"; +import { isWalletEmail } from "@/lib/auth/wallet-constants"; +import { db } from "@/lib/db"; +import { users, walletAddress } from "@/lib/db/schema"; +import { ErrorCategory, logSystemError } from "@/lib/logging"; + +type EnrolledFactors = { wallet: boolean; totp: boolean; email: boolean }; + +async function loadEnrolled( + userId: string, + stepUpEmail: string | null +): Promise { + const [[wallet], [user]] = await Promise.all([ + db + .select({ id: walletAddress.id }) + .from(walletAddress) + .where(eq(walletAddress.userId, userId)) + .limit(1), + db + .select({ twoFactorEnabled: users.twoFactorEnabled }) + .from(users) + .where(eq(users.id, userId)) + .limit(1), + ]); + return { + wallet: Boolean(wallet), + // TOTP counts as enrolled only once confirmed (twoFactorEnabled), not when + // a setup row exists but the code was never verified. + totp: user?.twoFactorEnabled === true, + email: Boolean(stepUpEmail), + }; +} + +// GET: which extra factors the wallet user has enrolled, so the settings UI can +// render the toggles. Enabling a factor is the only control -- once on, it is +// required on every sensitive action; there is no per-action configuration. +export async function GET(request: Request): Promise { + try { + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const [row] = await db + .select({ stepUpEmail: users.stepUpEmail }) + .from(users) + .where(eq(users.id, session.user.id)) + .limit(1); + const enrolled = await loadEnrolled( + session.user.id, + row?.stepUpEmail ?? null + ); + return NextResponse.json({ + walletUser: isWalletEmail(session.user.email), + enrolled, + }); + } catch (error) { + logSystemError( + ErrorCategory.DATABASE, + "Failed to load step-up enrollment", + error, + { endpoint: "/api/user/step-up/policy" } + ); + return NextResponse.json( + { error: "Failed to load step-up enrollment." }, + { status: 500 } + ); + } +} diff --git a/app/api/user/totp/disable/route.ts b/app/api/user/totp/disable/route.ts index 3720f4c362..95bf9b7085 100644 --- a/app/api/user/totp/disable/route.ts +++ b/app/api/user/totp/disable/route.ts @@ -5,15 +5,14 @@ import { isAnonymousUserShape } from "@/lib/auth-anonymous-guard"; import { db } from "@/lib/db"; import { twoFactor as twoFactorTable, users } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; -import { - dualFactorErrorResponse, - requireDualFactor, -} from "@/lib/mfa/dual-factor"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; +import { requireStepUp, stepUpErrorResponse } from "@/lib/mfa/wallet-step-up"; import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; type RequestBody = { code?: string; emailOtp?: string; + signature?: string; }; /** @@ -45,17 +44,20 @@ export async function POST(request: Request): Promise { const code = typeof body.code === "string" ? body.code.trim() : ""; const emailOtp = typeof body.emailOtp === "string" ? body.emailOtp.trim() : ""; + const signature = + typeof body.signature === "string" ? body.signature.trim() : undefined; - const dual = await requireDualFactor({ + const stepUp = await requireStepUp({ userId: session.user.id, email: session.user.email, - action: "totp_disable", + action: STEP_UP_ACTIONS.totpDisable, code, emailOtp, + signature, headers: request.headers, }); - if (!dual.ok) { - return dualFactorErrorResponse(dual); + if (!stepUp.ok) { + return stepUpErrorResponse(stepUp); } const userId = session.user.id; diff --git a/app/api/user/totp/enroll/route.ts b/app/api/user/totp/enroll/route.ts index e9c3d048ab..b99973cce9 100644 --- a/app/api/user/totp/enroll/route.ts +++ b/app/api/user/totp/enroll/route.ts @@ -182,6 +182,15 @@ export async function POST(request: Request): Promise { .update(twoFactorTable) .set({ backupCodes: encryptedBackupCodes }) .where(eq(twoFactorTable.userId, userId)); + // /setup writes the two_factor row out-of-band, so Better Auth's + // verifyTOTP verifies the code but never flips this flag (it only does so + // for enrollments it initiated). getEnrolledFactors reads exactly this + // column, so set it explicitly here -- at verify time -- or TOTP would + // forever read as "not enrolled". Mirrors the pending-signup path below. + await db + .update(users) + .set({ twoFactorEnabled: true }) + .where(eq(users.id, userId)); const newRawToken = extractNewSessionToken( readAllSetCookies(verifyHeaders) ); diff --git a/app/api/user/totp/setup/route.ts b/app/api/user/totp/setup/route.ts index 929a94ea98..5339ff6990 100644 --- a/app/api/user/totp/setup/route.ts +++ b/app/api/user/totp/setup/route.ts @@ -6,7 +6,7 @@ import { db } from "@/lib/db"; import { twoFactor as twoFactorTable, users } from "@/lib/db/schema"; import { resolveEnrollMfaCaller } from "@/lib/enroll-mfa-caller"; import { ErrorCategory, logSystemError } from "@/lib/logging"; -import { requireDualFactor } from "@/lib/mfa/dual-factor"; +import { requireStepUp, stepUpErrorResponse } from "@/lib/mfa/wallet-step-up"; const ISSUER = "KeeperHub"; const SECRET_LENGTH = 32; @@ -18,6 +18,7 @@ type SetupRequest = { name?: string; code?: string; emailOtp?: string; + signature?: string; }; type SetupResponse = { @@ -170,19 +171,18 @@ export async function POST(request: Request): Promise { const alreadyEnrolled = userRow?.twoFactorEnabled === true && Boolean(existingFactor); if (alreadyEnrolled) { - const dual = await requireDualFactor({ + const stepUp = await requireStepUp({ userId, email: userEmail, action: "totp_setup", code: typeof body.code === "string" ? body.code.trim() : "", emailOtp: typeof body.emailOtp === "string" ? body.emailOtp.trim() : "", + signature: + typeof body.signature === "string" ? body.signature.trim() : undefined, headers: request.headers, }); - if (!dual.ok) { - return NextResponse.json( - { error: dual.error, code: dual.code }, - { status: dual.status } - ); + if (!stepUp.ok) { + return stepUpErrorResponse(stepUp); } } diff --git a/app/api/user/totp/verify-stepup/route.ts b/app/api/user/totp/verify-stepup/route.ts index 1611fde0c5..b395c3aed4 100644 --- a/app/api/user/totp/verify-stepup/route.ts +++ b/app/api/user/totp/verify-stepup/route.ts @@ -5,14 +5,12 @@ import { isAnonymousUserShape } from "@/lib/auth-anonymous-guard"; import { db } from "@/lib/db"; import { sessions } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; -import { - dualFactorErrorResponse, - requireDualFactor, -} from "@/lib/mfa/dual-factor"; +import { requireStepUp, stepUpErrorResponse } from "@/lib/mfa/wallet-step-up"; type RequestBody = { code?: string; emailOtp?: string; + signature?: string; }; /** @@ -44,16 +42,19 @@ export async function POST(request: Request): Promise { const emailOtp = typeof body.emailOtp === "string" ? body.emailOtp.trim() : ""; - const dual = await requireDualFactor({ + const signature = + typeof body.signature === "string" ? body.signature.trim() : undefined; + const stepUp = await requireStepUp({ userId: session.user.id, email: session.user.email, action: "session_stepup", code, emailOtp, + signature, headers: request.headers, }); - if (!dual.ok) { - return dualFactorErrorResponse(dual); + if (!stepUp.ok) { + return stepUpErrorResponse(stepUp); } try { diff --git a/app/api/user/wallet/export-key/verify/route.ts b/app/api/user/wallet/export-key/verify/route.ts index 8c68f70bff..72df856ef7 100644 --- a/app/api/user/wallet/export-key/verify/route.ts +++ b/app/api/user/wallet/export-key/verify/route.ts @@ -6,9 +6,9 @@ import { auth } from "@/lib/auth"; import { db } from "@/lib/db"; import { organizationWallets } from "@/lib/db/schema"; import { ErrorCategory, logSystemError } from "@/lib/logging"; -import { requireDualFactor } from "@/lib/mfa/dual-factor"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; +import { authorizeAction } from "@/lib/middleware/authorize-action"; import { getActiveOrgId } from "@/lib/middleware/org-context"; -import { requireOwnerWithMfa } from "@/lib/middleware/owner-mfa-guard"; import { exportKeyVerifySchema } from "@/lib/schemas/wallet"; import { buildAuditMetadata, recordAuditEvent } from "@/lib/security/audit-log"; import { exportTurnkeyPrivateKey } from "@/lib/turnkey/turnkey-client"; @@ -21,22 +21,14 @@ import { checkVerifyRateLimit } from "../_lib/rate-limit"; * Exports a Turnkey wallet's private key in plaintext. Authorization * stack (every check must pass): * - * 1. requireOwnerWithMfa — org role = owner, users.two_factor_enabled - * = true, sessions.requires_mfa = false. - * 2. Wallet-creator check — wallet.userId === session.user.id. The - * TOTP secret belongs to the user, but the - * wallet might have been created by a - * different owner of the same org. - * 3. Fresh TOTP challenge — auth.api.verifyTOTP validates the code - * the user just typed into the dialog. - * This is the "step up at action time" - * factor; passive session MFA is not - * enough for an action that exfiltrates - * signing material in plaintext. - * - * Replaces the previous wallet-inbox email-OTP factor. The old - * /request endpoint that emailed a 6-digit code is gone; the UI now - * prompts directly for a TOTP code from the user's authenticator. + * 1. authorizeAction — owner role floor, MFA enrolled, session step-up + * cleared, and action step-up challenge (TOTP + email + * for email/OAuth users; wallet signature for wallet + * accounts). + * 2. Wallet-creator check — wallet.userId === session.user.id. The TOTP + * secret belongs to the user, but the wallet + * might have been created by a different owner + * of the same org. */ export async function POST(request: Request): Promise { try { @@ -56,19 +48,6 @@ export async function POST(request: Request): Promise { ); } - const sessionRow = session.session as { requiresMfa?: boolean | null }; - const guard = await requireOwnerWithMfa( - session.user.id, - activeOrgId, - sessionRow.requiresMfa === true - ); - if (!guard.ok) { - return NextResponse.json( - { error: guard.error, code: guard.code }, - { status: guard.status } - ); - } - const rateLimit = checkVerifyRateLimit(session.user.id); if (!rateLimit.allowed) { return NextResponse.json( @@ -88,19 +67,17 @@ export async function POST(request: Request): Promise { return bodyValidation.response; } const body = bodyValidation.data; - const dual = await requireDualFactor({ - userId: session.user.id, - email: session.user.email, - action: "wallet_export_key", - code: body.code, - emailOtp: body.emailOtp, + + const authorized = await authorizeAction({ + session, + action: STEP_UP_ACTIONS.walletExportKey, + roleFloor: "owner", + organizationId: activeOrgId, + body, headers: request.headers, }); - if (!dual.ok) { - return NextResponse.json( - { error: dual.error, code: dual.code }, - { status: dual.status } - ); + if (!authorized.ok) { + return authorized.response; } const wallets = await db diff --git a/app/api/user/wallet/withdraw/route.ts b/app/api/user/wallet/withdraw/route.ts index ecdcb30907..a5e4dd1bc1 100644 --- a/app/api/user/wallet/withdraw/route.ts +++ b/app/api/user/wallet/withdraw/route.ts @@ -8,9 +8,9 @@ import { chains } from "@/lib/db/schema"; import { safeWallets } from "@/lib/db/schema-extensions"; import { ErrorCategory, logSystemError } from "@/lib/logging"; import { recordSafeWithdraw } from "@/lib/metrics/instrumentation/safe"; -import { requireDualFactor } from "@/lib/mfa/dual-factor"; +import { STEP_UP_ACTIONS } from "@/lib/mfa/step-up-policy"; +import { authorizeAction } from "@/lib/middleware/authorize-action"; import { getActiveOrgId } from "@/lib/middleware/org-context"; -import { requireOwnerWithMfa } from "@/lib/middleware/owner-mfa-guard"; import { getRpcProvider } from "@/lib/rpc/provider-factory"; import { executeContractCallAsSafe, @@ -105,99 +105,47 @@ async function executeNativeTransfer( return receipt?.hash || tx.hash; } -// Withdraw is the highest-leverage action a session cookie can trigger: -// it moves funds off the org's wallet. Gated on: -// 1. owner-only role via requireOwnerWithMfa (admin not accepted) -// 2. MFA enrolled + session step-up cleared (passive gate) -// 3. Dual-factor at request time via requireDualFactor: the client -// must submit BOTH a fresh TOTP from the authenticator and the -// 6-digit code emailed at this moment. The first call (no codes) -// mints + sends the email OTP; the second call (both codes) -// verifies and consumes them. -async function validateUserAndOrganization( - request: Request, - code: string | undefined, - emailOtp: string | undefined -) { - const session = await auth.api.getSession({ - headers: request.headers, - }); - - if (!session?.user) { - return { error: "Unauthorized", status: 401 }; - } - - const activeOrgId = getActiveOrgId(session); - - if (!activeOrgId) { - return { - error: "No active organization. Please select or create an organization.", - status: 400, - }; - } - - const sessionRow = session.session as { requiresMfa?: boolean | null }; - const guard = await requireOwnerWithMfa( - session.user.id, - activeOrgId, - sessionRow.requiresMfa === true - ); - if (!guard.ok) { - return { error: guard.error, status: guard.status, code: guard.code }; - } - - const dual = await requireDualFactor({ - userId: session.user.id, - email: session.user.email, - action: "wallet_withdraw", - code, - emailOtp, - headers: request.headers, - }); - if (!dual.ok) { - return { - error: dual.error, - status: dual.status, - code: dual.code, - retryAfter: dual.retryAfter, - }; - } - - return { user: session.user, organizationId: activeOrgId }; -} - export async function POST(request: Request) { try { - // Validate the untrusted payload at the boundary (KEEP-828) before any - // fund-moving logic. The schema enforces a valid recipient address, - // numeric chainId, the fromMax/amount/tokenAddress invariants, and - // rejects unknown fields. + // Validate the untrusted payload at the boundary before any fund-moving + // logic. The schema enforces a valid recipient address, numeric chainId, + // the fromMax/amount/tokenAddress invariants, and rejects unknown fields. const bodyValidation = await validateBody(request, withdrawSchema); if (!bodyValidation.success) { return bodyValidation.response; } const body = bodyValidation.data; - // 1. Validate user and permissions (includes dual-factor challenge). - const validation = await validateUserAndOrganization( - request, - body.code, - body.emailOtp - ); - if ("error" in validation) { - const retryAfter = - "retryAfter" in validation ? validation.retryAfter : undefined; + const session = await auth.api.getSession({ headers: request.headers }); + if (!session?.user) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const activeOrgId = getActiveOrgId(session); + if (!activeOrgId) { return NextResponse.json( - { error: validation.error, code: validation.code }, - validation.status === 429 && retryAfter !== undefined - ? { - status: validation.status, - headers: { "Retry-After": String(retryAfter) }, - } - : { status: validation.status } + { + error: + "No active organization. Please select or create an organization.", + }, + { status: 400 } ); } - const { organizationId, user } = validation; + + const authorized = await authorizeAction({ + session, + action: STEP_UP_ACTIONS.walletWithdraw, + roleFloor: "owner", + organizationId: activeOrgId, + body, + headers: request.headers, + }); + if (!authorized.ok) { + return authorized.response; + } + + const { user } = session; + const organizationId = activeOrgId; const { chainId: rawChainId, tokenAddress, amount, fromMax, safeId } = body; const recipientAddr: string = body.recipient; diff --git a/app/enforce-mfa/enforce-mfa-form.tsx b/app/enforce-mfa/enforce-mfa-form.tsx new file mode 100644 index 0000000000..d9e34abc35 --- /dev/null +++ b/app/enforce-mfa/enforce-mfa-form.tsx @@ -0,0 +1,303 @@ +"use client"; + +import { Check, Mail, ShieldCheck, Smartphone } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { TotpSetupDialog } from "@/components/settings/totp-setup-dialog"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { authClient } from "@/lib/auth-client"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import type { StepUpFactor } from "@/lib/mfa/step-up-policy"; + +type Enrolled = { totp: boolean; email: boolean }; + +async function readError(response: Response): Promise { + const data = (await response.json().catch(() => ({}))) as { error?: string }; + return data.error ?? "Something went wrong."; +} + +function AddEmailDialog({ + open, + onOpenChange, + onAdded, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdded: () => void; +}): React.ReactElement { + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [phase, setPhase] = useState<"email" | "code">("email"); + const [loading, setLoading] = useState(false); + + const send = (body: Record): Promise => + fetch("/api/user/step-up/email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const requestCode = async (): Promise => { + setLoading(true); + try { + const res = await send({ email: email.trim() }); + if (!res.ok) { + toast.error(await readError(res)); + return; + } + setPhase("code"); + toast.success("Verification code sent."); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Could not send code."); + } finally { + setLoading(false); + } + }; + + const verify = async (): Promise => { + setLoading(true); + try { + const res = await send({ email: email.trim(), code: code.trim() }); + if (!res.ok) { + toast.error(await readError(res)); + return; + } + toast.success("Email added."); + onAdded(); + } catch (err) { + toast.error( + err instanceof Error ? err.message : "Verification failed." + ); + } finally { + setLoading(false); + } + }; + + return ( + + + + Add a verified email + + {phase === "email" + ? "We'll send a code to confirm this inbox." + : `Enter the code we sent to ${email}.`} + + + {phase === "email" ? ( +
{ + e.preventDefault(); + requestCode(); + }} + > + setEmail(e.target.value)} + placeholder="you@example.com" + required + type="email" + value={email} + /> + +
+ ) : ( +
{ + e.preventDefault(); + verify(); + }} + > + setCode(e.target.value)} + placeholder="123456" + value={code} + /> + +
+ )} +
+
+ ); +} + +export function EnforceMfaForm({ + required, + enrolled, + orgName, + otherOrgs, + next, +}: { + required: StepUpFactor[]; + enrolled: Enrolled; + orgName: string; + otherOrgs: { id: string; name: string }[]; + next: string; +}): React.ReactElement { + const [totpOpen, setTotpOpen] = useState(false); + const [emailOpen, setEmailOpen] = useState(false); + const [leaving, setLeaving] = useState(false); + + const acceptsTotp = required.includes("totp"); + const acceptsEmail = required.includes("email"); + + // The gate keys off the active org, so switching to another org (or signing + // out) is a legitimate way to leave without enrolling. Force a full reload so + // the proxy re-evaluates compliance against the new active context. + const switchTo = async (organizationId: string): Promise => { + setLeaving(true); + try { + await authClient.organization.setActive({ organizationId }); + window.location.assign("/"); + } catch { + toast.error("Could not switch organization."); + setLeaving(false); + } + }; + + const signOut = async (): Promise => { + setLeaving(true); + try { + await authClient.signOut(); + window.location.assign("/"); + } catch { + toast.error("Sign out failed. Please try again."); + setLeaving(false); + } + }; + + // A factor was just added. Hard-reload to where they were headed so the proxy + // re-checks compliance against fresh state (rotated session + flipped flag) + // and the UI reflects enrollment; if another factor is still required the + // proxy bounces them back here showing only what's left. + const onEnrolled = (): void => { + setTotpOpen(false); + setEmailOpen(false); + toast.success("Second factor added."); + window.location.assign(next); + }; + + return ( + + +
+
+ Two-factor required + + {orgName} requires every member to secure their account.{" "} + {required.length > 1 + ? "Add both factors below to continue." + : "Add the factor below to continue."} + +
+ + {acceptsTotp && ( + + )} + {acceptsEmail && ( + + )} + + + +

+ Don't want to add a factor? Switch to another organization or + sign out. +

+ {otherOrgs.map((org) => ( + + ))} + +
+ + + +
+ ); +} diff --git a/app/enforce-mfa/page.tsx b/app/enforce-mfa/page.tsx new file mode 100644 index 0000000000..e7297e2b28 --- /dev/null +++ b/app/enforce-mfa/page.tsx @@ -0,0 +1,85 @@ +import { and, eq, ne } from "drizzle-orm"; +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; +import { isWalletEmail } from "@/lib/auth/wallet-constants"; +import { db } from "@/lib/db"; +import { member, organization } from "@/lib/db/schema"; +import { + checkWalletOrgMfaCompliance, + getEnrolledFactors, +} from "@/lib/mfa/org-mfa-enforcement"; +import { sanitizeNextPath } from "@/lib/sanitize-next-path"; +import { EnforceMfaForm } from "./enforce-mfa-form"; + +/** + * Hard gate for wallet (SIWE) members whose active org enforces MFA. The proxy + * routes them here on every request until they enroll a factor the org + * accepts. Email/TOTP users never reach this page; their dual-factor gate lives + * at /enroll-mfa and /verify-mfa. + */ +export default async function EnforceMfaPage({ + searchParams, +}: { + searchParams: Promise<{ next?: string }>; +}): Promise { + const requestHeaders = await headers(); + const next = sanitizeNextPath((await searchParams).next); + + const session = await auth.api.getSession({ headers: requestHeaders }); + if (!session?.user) { + redirect("/"); + } + if (!isWalletEmail(session.user.email)) { + redirect(next || "/"); + } + + const activeOrganizationId = ( + session.session as { activeOrganizationId?: string | null } + ).activeOrganizationId; + + const { compliant, required } = await checkWalletOrgMfaCompliance({ + userId: session.user.id, + activeOrganizationId, + }); + if (compliant) { + redirect(next || "/"); + } + + const [[org], enrolled, otherOrgs] = await Promise.all([ + activeOrganizationId + ? db + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, activeOrganizationId)) + .limit(1) + : Promise.resolve([]), + getEnrolledFactors(session.user.id), + // The gate is per active org, so switching context is a valid escape from + // enforcement -- list the user's other orgs so they aren't forced to enroll. + activeOrganizationId + ? db + .select({ id: organization.id, name: organization.name }) + .from(member) + .innerJoin(organization, eq(member.organizationId, organization.id)) + .where( + and( + eq(member.userId, session.user.id), + ne(member.organizationId, activeOrganizationId) + ) + ) + : Promise.resolve([]), + ]); + + return ( +
+ +
+ ); +} diff --git a/app/globals.css b/app/globals.css index 6ffbc8432d..1a30df1f76 100644 --- a/app/globals.css +++ b/app/globals.css @@ -144,7 +144,9 @@ truncated addresses and hashes (e.g. 0x1479...E38E renders as one dot). Disable ligatures on monospace text so the literal characters render. */ .font-mono { - font-feature-settings: "liga" 0, "calt" 0; + font-feature-settings: + "liga" 0, + "calt" 0; font-variant-ligatures: none; } } @@ -425,3 +427,116 @@ color: var(--muted-foreground) !important; font-size: 0.75rem !important; } + +/* driver.js onboarding popover - dark, rounded, matched to the app surfaces. + Selectors are doubled (`.driver-popover.driver-popover`) / scoped under the + popover so they out-specify driver.css's own same-class rules - otherwise the + win depends on CSS bundle order and the default white surface can leak in. */ +.driver-popover.driver-popover { + background-color: var(--background); + color: var(--foreground); + border: 1px solid var(--border); + border-radius: 1rem; + box-shadow: none; + font-family: var(--font-sans), sans-serif; + max-width: 22rem; + padding: 1.125rem; +} + +.driver-popover.driver-popover * { + font-family: var(--font-sans), sans-serif; +} + +.driver-popover .driver-popover-title { + font-size: 0.95rem; + font-weight: 600; + line-height: 1.4; + color: var(--foreground); +} + +.driver-popover .driver-popover-description { + margin-top: 0.375rem; + font-size: 0.85rem; + line-height: 1.5; + color: var(--muted-foreground); +} + +.driver-popover .driver-popover-arrow-side-top.driver-popover-arrow { + border-top-color: var(--background); +} + +.driver-popover .driver-popover-arrow-side-bottom.driver-popover-arrow { + border-bottom-color: var(--background); +} + +.driver-popover .driver-popover-arrow-side-left.driver-popover-arrow { + border-left-color: var(--background); +} + +.driver-popover .driver-popover-arrow-side-right.driver-popover-arrow { + border-right-color: var(--background); +} + +.driver-popover .driver-popover-footer { + margin-top: 0.875rem; + gap: 0.5rem; +} + +.driver-popover .driver-popover-footer button { + font-size: 0.8rem; + font-weight: 500; + border-radius: var(--radius-md); + padding: 0.4rem 0.75rem; + text-shadow: none; + transition: + background-color 0.15s ease, + color 0.15s ease; +} + +.driver-popover .driver-popover-next-btn { + background-color: var(--keeperhub-green); + color: var(--primary-foreground); + border: 1px solid var(--keeperhub-green); +} + +.driver-popover .driver-popover-next-btn:hover { + background-color: var(--keeperhub-green-dark); + border-color: var(--keeperhub-green-dark); +} + +.driver-popover .driver-popover-prev-btn { + background-color: transparent; + color: var(--muted-foreground); + border: 1px solid var(--border); +} + +.driver-popover .driver-popover-prev-btn:hover { + background-color: var(--accent); + color: var(--accent-foreground); +} + +.driver-popover .driver-popover-close-btn { + color: var(--muted-foreground); + transition: color 0.15s ease; +} + +.driver-popover .driver-popover-close-btn:hover { + color: var(--foreground); +} + +/* While a driver tour is active, Radix popper portals (Select / Popover / + Dropdown option lists) render at the end of , outside the highlighted + element. driver.css's blanket `.driver-active * { pointer-events: none }` + then makes their options unclickable, and the z-index 10000 overlay tints + them. Tour steps that edit form fields (the network and address selectors on + the balance node) need those lists usable, so re-enable interaction and lift + the portal above the overlay. Scoped to `.driver-active`, so it only applies + mid-tour. */ +.driver-active [data-radix-popper-content-wrapper], +.driver-active [data-radix-popper-content-wrapper] * { + pointer-events: auto !important; +} + +.driver-active [data-radix-popper-content-wrapper] { + z-index: 1000000001 !important; +} diff --git a/app/layout.tsx b/app/layout.tsx index 29afa77781..91c1873aed 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -12,6 +12,8 @@ import { GlobalModals } from "@/components/global-modals"; import { PendingTemplateRunner } from "@/components/hub/pending-template-runner"; import { LayoutContent } from "@/components/layout-content"; import { MobileWarningDialog } from "@/components/mobile-warning-dialog"; +import { EditorWalkthrough } from "@/components/onboarding/editor-walkthrough"; +import { SignInTourDriver } from "@/components/onboarding/signin-tour-driver"; import { OverlayProvider } from "@/components/overlays/overlay-provider"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; @@ -131,6 +133,8 @@ const RootLayout = async ({ children }: RootLayoutProps) => { + + diff --git a/app/page.tsx b/app/page.tsx index c13a31155e..128db9f2c6 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,18 +1,22 @@ "use client"; -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { nanoid } from "nanoid"; import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef } from "react"; import { toast } from "sonner"; +import { Spinner } from "@/components/ui/spinner"; import { api } from "@/lib/api-client"; import { authClient, useSession } from "@/lib/auth-client"; import { isAnonymousUser } from "@/lib/is-anonymous"; +import { rootGateAtom } from "@/lib/onboarding/root-gate"; import { refetchSidebar } from "@/lib/refetch-sidebar"; +import { isContinueAsGuest } from "@/lib/welcome-status"; import { currentWorkflowIdAtom, currentWorkflowNameAtom, edgesAtom, + editorTourRequestedAtom, hasSidebarBeenShownAtom, isTransitioningFromHomepageAtom, nodesAtom, @@ -63,7 +67,7 @@ function createDefaultNodes() { const Home = () => { const router = useRouter(); - const { data: session } = useSession(); + const { data: session, isPending: sessionPending } = useSession(); const setNodes = useSetAtom(nodesAtom); const setEdges = useSetAtom(edgesAtom); const setCurrentWorkflowId = useSetAtom(currentWorkflowIdAtom); @@ -74,12 +78,53 @@ const Home = () => { ); const hasCreatedWorkflowRef = useRef(false); const currentWorkflowName = useAtomValue(currentWorkflowNameAtom); + const tourRequested = useAtomValue(editorTourRequestedAtom); + const setTourRequested = useSetAtom(editorTourRequestedAtom); // Reset sidebar animation state when on homepage useEffect(() => { setHasSidebarBeenShown(false); }, [setHasSidebarBeenShown]); + // Welcome gating: a visitor without a real session (none, or anonymous) lands + // on the welcome page instead of the bare canvas, unless they explicitly chose + // to continue without an account. A signed-in user who has not gone through + // the onboarding wizard is sent into it. Until the session resolves and this + // decision is made we render a loader over the canvas, so a redirected user + // never sees the canvas flash before being sent to /welcome. + const welcomeRedirectedRef = useRef(false); + const [gate, setGate] = useAtom(rootGateAtom); + useEffect(() => { + if (sessionPending || welcomeRedirectedRef.current) { + return; + } + const isSignedIn = + Boolean(session?.user) && !isAnonymousUser(session?.user); + if (!isSignedIn) { + if (isContinueAsGuest()) { + setGate("canvas"); + } else { + welcomeRedirectedRef.current = true; + setGate("redirecting"); + router.replace("/welcome"); + } + return; + } + // A real user who has not completed onboarding (new signup, or an anonymous + // guest who just signed in) is sent into the wizard. The server flag is + // authoritative so this is not skipped by a stale device flag. + const onboardingDone = + (session?.user as { onboardingCompleted?: boolean } | undefined) + ?.onboardingCompleted === true; + if (onboardingDone) { + setGate("canvas"); + } else { + welcomeRedirectedRef.current = true; + setGate("redirecting"); + router.replace("/welcome/create-org"); + } + }, [sessionPending, session, router, setGate]); + // Update page title when workflow name changes useEffect(() => { document.title = `${currentWorkflowName} - KeeperHub`; @@ -137,6 +182,7 @@ const Home = () => { console.error("Failed to create workflow:", error); toast.error("Failed to create workflow"); hasCreatedWorkflowRef.current = false; + setTourRequested(false); } }, [ session, @@ -145,8 +191,21 @@ const Home = () => { ensureSession, router, setIsTransitioningFromHomepage, + setTourRequested, ]); + // Launch the editor walkthrough when "Take a tour" was requested (from the + // account menu or the Setup Guide): build the fresh default workflow the + // walkthrough controller drives. handleAddNode then navigates into the editor. + const handleAddNodeRef = useRef(handleAddNode); + handleAddNodeRef.current = handleAddNode; + + useEffect(() => { + if (tourRequested && session && !isAnonymousUser(session.user)) { + void handleAddNodeRef.current(); + } + }, [tourRequested, session]); + // Initialize with a temporary "add" node on mount useEffect(() => { const addNodePlaceholder: WorkflowNode = { @@ -156,7 +215,7 @@ const Home = () => { data: { label: "", type: "add", - onClick: handleAddNode, + onClick: () => void handleAddNodeRef.current(), }, draggable: false, selectable: false, @@ -166,13 +225,19 @@ const Home = () => { setCurrentWorkflowId(null); setCurrentWorkflowName("New Workflow"); hasCreatedWorkflowRef.current = false; - }, [ - setNodes, - setEdges, - setCurrentWorkflowId, - setCurrentWorkflowName, - handleAddNode, - ]); + }, [setNodes, setEdges, setCurrentWorkflowId, setCurrentWorkflowName]); + + // Until the session/onboarding gate resolves to "canvas", cover the + // layout's PersistentCanvas with a loader so a redirected user never sees a + // canvas flash before /welcome. Once decided, render nothing and let the + // canvas show through. + if (gate !== "canvas") { + return ( +
+ +
+ ); + } // Canvas and toolbar are rendered by PersistentCanvas in the layout return null; diff --git a/app/welcome/connect-agent/page.tsx b/app/welcome/connect-agent/page.tsx new file mode 100644 index 0000000000..361d413555 --- /dev/null +++ b/app/welcome/connect-agent/page.tsx @@ -0,0 +1,7 @@ +import { ConnectAgentStep } from "@/components/welcome/steps/connect-agent-step"; +import { requireOnboardingSession } from "@/lib/onboarding-session"; + +export default async function ConnectAgentPage(): Promise { + await requireOnboardingSession(); + return ; +} diff --git a/app/welcome/create-org/page.tsx b/app/welcome/create-org/page.tsx new file mode 100644 index 0000000000..566ff52c27 --- /dev/null +++ b/app/welcome/create-org/page.tsx @@ -0,0 +1,7 @@ +import { CreateOrgStep } from "@/components/welcome/steps/create-org-step"; +import { requireOnboardingSession } from "@/lib/onboarding-session"; + +export default async function CreateOrgPage(): Promise { + await requireOnboardingSession(); + return ; +} diff --git a/app/welcome/invite-members/page.tsx b/app/welcome/invite-members/page.tsx new file mode 100644 index 0000000000..4cec384613 --- /dev/null +++ b/app/welcome/invite-members/page.tsx @@ -0,0 +1,7 @@ +import { InviteMembersStep } from "@/components/welcome/steps/invite-members-step"; +import { requireOnboardingSession } from "@/lib/onboarding-session"; + +export default async function InviteMembersPage(): Promise { + await requireOnboardingSession(); + return ; +} diff --git a/app/welcome/layout.tsx b/app/welcome/layout.tsx new file mode 100644 index 0000000000..2a27835fda --- /dev/null +++ b/app/welcome/layout.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { AnimatePresence, motion } from "motion/react"; +import { LayoutRouterContext } from "next/dist/shared/lib/app-router-context.shared-runtime"; +import { usePathname } from "next/navigation"; +import { type ReactNode, useContext, useEffect, useRef } from "react"; + +// Order used to decide the transition direction between wizard steps. +const STEP_ORDER = [ + "/welcome", + "/welcome/create-org", + "/welcome/invite-members", + "/welcome/connect-agent", +]; + +const variants = { + enter: (direction: number) => ({ + opacity: 0, + x: direction >= 0 ? 48 : -48, + }), + center: { opacity: 1, x: 0 }, + exit: (direction: number) => ({ + opacity: 0, + x: direction >= 0 ? -48 : 48, + }), +}; + +/** + * Freezes the router context for the duration of an exit animation so the + * outgoing step keeps rendering its own content instead of flashing the new + * route's content mid-transition (the App Router + AnimatePresence gotcha). + */ +function FrozenRouter({ + children, +}: { + children: ReactNode; +}): React.ReactElement { + const context = useContext(LayoutRouterContext); + const frozen = useRef(context).current; + return ( + + {children} + + ); +} + +/** + * Slides the welcome/onboarding steps in and out based on navigation direction: + * moving forward, the exiting step leaves to the left and the entering step + * enters from the right; moving back reverses it. + */ +export default function WelcomeLayout({ + children, +}: { + children: ReactNode; +}): React.ReactElement { + const pathname = usePathname(); + const prevRef = useRef(pathname); + const direction = + STEP_ORDER.indexOf(pathname) >= STEP_ORDER.indexOf(prevRef.current) + ? 1 + : -1; + + useEffect(() => { + prevRef.current = pathname; + }, [pathname]); + + return ( +
+ + + {children} + + +
+ ); +} diff --git a/app/welcome/page.tsx b/app/welcome/page.tsx new file mode 100644 index 0000000000..1a60107bd0 --- /dev/null +++ b/app/welcome/page.tsx @@ -0,0 +1,22 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { WelcomeAuth } from "@/components/welcome/welcome-auth"; +import { auth } from "@/lib/auth"; +import { isAnonymousUser } from "@/lib/is-anonymous"; + +/** + * Welcome landing shown to visitors without a real session. A signed-in user + * who reaches it directly is bounced home, where the onboarding gate takes over. + */ +export default async function WelcomePage(): Promise { + const session = await auth.api.getSession({ headers: await headers() }); + if (session?.user && !isAnonymousUser(session.user)) { + redirect("/"); + } + + return ( +
+ +
+ ); +} diff --git a/components/ai-elements/prompt.tsx b/components/ai-elements/prompt.tsx index 7733fd55e3..f4a8185727 100644 --- a/components/ai-elements/prompt.tsx +++ b/components/ai-elements/prompt.tsx @@ -15,6 +15,7 @@ import { edgesAtom, isGeneratingAtom, nodesAtom, + pendingAiPromptAtom, selectedNodeAtom, } from "@/lib/workflow/store"; @@ -23,7 +24,7 @@ type AIPromptProps = { onWorkflowCreated?: (workflowId: string) => void; }; -export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { +export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps): React.ReactElement { const [isGenerating, setIsGenerating] = useAtom(isGeneratingAtom); const [prompt, setPrompt] = useState(""); const [isExpanded, setIsExpanded] = useState(false); @@ -36,6 +37,7 @@ export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { const [_currentWorkflowId, setCurrentWorkflowId] = useAtom(currentWorkflowIdAtom); const [_currentWorkflowName, setCurrentWorkflowName] = useAtom(currentWorkflowNameAtom); const [_selectedNodeId, setSelectedNodeId] = useAtom(selectedNodeAtom); + const [pendingAiPrompt, setPendingAiPrompt] = useAtom(pendingAiPromptAtom); const { fitView } = useReactFlow(); // Filter out placeholder "add" nodes to get real nodes @@ -73,11 +75,9 @@ export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { } }; - const handleGenerate = useCallback( - async (e: React.FormEvent) => { - e.preventDefault(); - - if (!prompt.trim() || isGenerating) { + const runGenerate = useCallback( + async (promptText: string) => { + if (!promptText.trim() || isGenerating) { return; } @@ -104,7 +104,7 @@ export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { // Use streaming API with incremental updates const workflowData = await api.ai.generateStream( - prompt, + promptText, (partialData) => { // Update UI incrementally with animated edges const edgesWithAnimatedType = (partialData.edges || []).map((edge) => ({ @@ -241,7 +241,6 @@ export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { ); } else { console.log("[AI Prompt] Setting workflow for empty canvas"); - toast.success("Generated workflow"); } @@ -274,7 +273,6 @@ export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { } }, [ - prompt, isGenerating, workflowId, hasNodes, @@ -291,6 +289,27 @@ export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { ] ); + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + runGenerate(prompt); + }, + [runGenerate, prompt] + ); + + // A getting-started chip can seed a preset prompt then route here; consume it + // once on arrival, prefill the box, and generate. + useEffect(() => { + if (!pendingAiPrompt || isGenerating) { + return; + } + const seeded = pendingAiPrompt; + setPendingAiPrompt(null); + setPrompt(seeded); + setIsExpanded(true); + runGenerate(seeded); + }, [pendingAiPrompt, isGenerating, runGenerate, setPendingAiPrompt]); + return ( <> {/* Always visible prompt input */} @@ -318,7 +337,7 @@ export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { e.preventDefault(); } }} - onSubmit={handleGenerate} + onSubmit={handleSubmit} role="search" > {isGenerating && prompt ? ( @@ -340,7 +359,7 @@ export function AIPrompt({ workflowId, onWorkflowCreated }: AIPromptProps) { onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - handleGenerate(e as any); + runGenerate(prompt); } else if (e.key === 'Escape') { e.preventDefault(); setPrompt(""); diff --git a/components/auth/connect-auth-panel.tsx b/components/auth/connect-auth-panel.tsx new file mode 100644 index 0000000000..e288ddddae --- /dev/null +++ b/components/auth/connect-auth-panel.tsx @@ -0,0 +1,863 @@ +"use client"; + +import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile"; +import { Wallet } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import Image from "next/image"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { setConnectPanelActive } from "@/components/auth/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { authClient, signIn, signUp } from "@/lib/auth-client"; +import { DISPOSABLE_EMAIL_REJECTION_MESSAGE } from "@/lib/auth-disposable-emails-message"; +import { AUTH_SUCCESS_EVENT } from "@/lib/auth-events"; +import { getEnabledAuthProviders } from "@/lib/auth-providers"; + +type View = + | "main" + | "signup" + | "forgot" + | "reset" + | "verify" + | "mfa-email" + | "mfa-totp"; + +type Item = { key: string; node: React.ReactNode }; + +const EXISTING_ACCOUNT = /already|exists|duplicate/i; + +const GitHubIcon = (): React.ReactElement => ( + +); + +const GoogleIcon = (): React.ReactElement => ( + +); + +function reloadHome(): void { + window.dispatchEvent(new CustomEvent(AUTH_SUCCESS_EVENT)); + window.location.assign("/"); +} + +/** + * Inline email + social auth for the Connect modal's right panel. All steps — + * credential entry, sign-up, email verification, MFA email OTP, and TOTP — are + * handled as views in the same animated panel so the user never leaves the modal. + */ +export function ConnectAuthPanel({ + hideChooserHeader = false, + onWalletClick, +}: { + // When the surrounding surface already carries a title (e.g. the welcome + // page), suppress the default view's own header to avoid repeating the same + // phrase. Contextual step headers (verify, MFA) stay. + hideChooserHeader?: boolean; + // When provided, a "Wallet" button is shown in the social row; clicking it + // hands off to the surrounding surface to reveal the wallet picker. + onWalletClick?: () => void; +} = {}): React.ReactElement { + const providers = getEnabledAuthProviders(); + const turnstileSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; + + const [view, setView] = useState("main"); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [otp, setOtp] = useState(""); + const [mfaEmailOtp, setMfaEmailOtp] = useState(""); + const [mfaTotpCode, setMfaTotpCode] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmNewPassword, setConfirmNewPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + const [social, setSocial] = useState<"github" | "google" | null>(null); + const [captchaToken, setCaptchaToken] = useState(""); + const captchaRef = useRef(null); + + const inCodeStep = + view === "verify" || view === "mfa-email" || view === "mfa-totp"; + + useEffect(() => { + setConnectPanelActive(loading || inCodeStep); + return () => setConnectPanelActive(false); + }, [loading, inCodeStep]); + + const contentRef = useRef(null); + const [height, setHeight] = useState("auto"); + useLayoutEffect(() => { + const el = contentRef.current; + if (!el) { + return; + } + const observer = new ResizeObserver(() => setHeight(el.scrollHeight)); + observer.observe(el); + setHeight(el.scrollHeight); + return () => observer.disconnect(); + }, []); + + const handleSocial = async (provider: "github" | "google"): Promise => { + setSocial(provider); + try { + // Land on "/" so the onboarding gate runs (a new social user goes to the + // wizard; a returning user goes home). + await signIn.social({ provider, callbackURL: "/" }); + } catch { + setSocial(null); + toast.error(`Could not start ${provider} sign-in.`); + } + }; + + const handleSignIn = async (e: React.FormEvent): Promise => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const response = await fetch("/api/auth/strict-signin/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + const body = (await response.json().catch(() => ({}))) as { + error?: string; + signedIn?: boolean; + }; + if (!response.ok) { + setError(body.error ?? "Sign in failed"); + return; + } + if (body.signedIn) { + toast.success("Signed in successfully!"); + reloadHome(); + return; + } + setMfaEmailOtp(""); + setView("mfa-email"); + } catch (err) { + setError(err instanceof Error ? err.message : "Sign in failed"); + } finally { + setLoading(false); + } + }; + + const handleSignUp = async (e: React.FormEvent): Promise => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const result = await signUp.email({ + email, + password, + name: email.split("@")[0], + ...(captchaToken && { + fetchOptions: { headers: { "x-captcha-response": captchaToken } }, + }), + }); + if (result.error) { + const msg = result.error.message || "Sign up failed"; + if (msg === DISPOSABLE_EMAIL_REJECTION_MESSAGE) { + setError(DISPOSABLE_EMAIL_REJECTION_MESSAGE); + } else if (EXISTING_ACCOUNT.test(msg)) { + await authClient.emailOtp.sendVerificationOtp({ + email, + type: "email-verification", + }); + setOtp(""); + setView("verify"); + toast.info("This email already exists. Verify it to continue."); + } else { + setError(msg); + } + captchaRef.current?.reset(); + setCaptchaToken(""); + return; + } + setOtp(""); + setView("verify"); + toast.success(`Verification code sent to ${email}.`); + } catch (err) { + setError(err instanceof Error ? err.message : "Sign up failed"); + captchaRef.current?.reset(); + } finally { + setLoading(false); + } + }; + + const handleVerify = async (e: React.FormEvent): Promise => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const verifyResult = await authClient.emailOtp.verifyEmail({ + email, + otp, + }); + if (verifyResult.error) { + setError(verifyResult.error.message ?? "Verification failed"); + return; + } + const finish = await fetch("/api/auth/finish-credential-signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + const finishBody = (await finish.json().catch(() => ({}))) as { + error?: string; + redirect?: string; + }; + if (!finish.ok) { + setError(finishBody.error ?? "Could not finish signup"); + return; + } + toast.success("Email verified! Set up your authenticator to continue."); + window.dispatchEvent(new CustomEvent(AUTH_SUCCESS_EVENT)); + window.location.assign(finishBody.redirect ?? "/enroll-mfa"); + } catch (err) { + setError(err instanceof Error ? err.message : "Verification failed"); + } finally { + setLoading(false); + } + }; + + const handleResendVerification = async (): Promise => { + setError(""); + setLoading(true); + try { + const res = await authClient.emailOtp.sendVerificationOtp({ + email, + type: "email-verification", + }); + if (res.error) { + setError(res.error.message ?? "Failed to resend code"); + return; + } + toast.success("New code sent"); + setOtp(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to resend code"); + } finally { + setLoading(false); + } + }; + + const handleMfaResend = async (): Promise => { + const res = await authClient.emailOtp.sendVerificationOtp({ + email, + type: "sign-in", + }); + if (res.error) { + toast.error(res.error.message ?? "Could not resend code"); + return; + } + toast.success("Code resent"); + }; + + const handleMfaEmailContinue = (e: React.FormEvent): void => { + e.preventDefault(); + if (mfaEmailOtp.trim().length !== 6) { + setError("Enter the 6-digit email code"); + return; + } + setError(""); + setMfaTotpCode(""); + setView("mfa-totp"); + }; + + const handleMfaComplete = async (e: React.FormEvent): Promise => { + e.preventDefault(); + if (mfaTotpCode.trim().length !== 6) { + setError("Enter the 6-digit authenticator code"); + return; + } + if (mfaEmailOtp.trim().length !== 6) { + setError("Missing email code. Start sign-in again."); + setView("main"); + return; + } + setError(""); + setLoading(true); + try { + const response = await fetch("/api/auth/strict-signin", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + password, + emailOtp: mfaEmailOtp.trim(), + totpCode: mfaTotpCode.trim(), + }), + }); + const body = (await response.json().catch(() => ({}))) as { + error?: string; + code?: string; + redirect?: string; + }; + if (!response.ok) { + if (body.code === "invalid_email_otp") { + setError("Invalid email code"); + setMfaEmailOtp(""); + setView("mfa-email"); + return; + } + if (body.code === "invalid_totp") { + setError("Invalid authenticator code"); + setMfaTotpCode(""); + return; + } + setError(body.error ?? "Sign in failed"); + return; + } + if (body.redirect === "/verify-ip") { + window.location.assign("/verify-ip"); + return; + } + toast.success("Signed in successfully!"); + reloadHome(); + } catch (err) { + setError(err instanceof Error ? err.message : "Sign in failed"); + } finally { + setLoading(false); + } + }; + + const handleForgotRequest = async (e: React.FormEvent): Promise => { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const response = await fetch("/api/user/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "request", email }), + }); + const data = (await response.json().catch(() => ({}))) as { + error?: string; + }; + if (!response.ok) { + setError(data.error ?? "Failed to send reset code"); + return; + } + toast.success("Check your inbox for the reset code."); + setOtp(""); + setView("reset"); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to send reset code" + ); + } finally { + setLoading(false); + } + }; + + const handleReset = async (e: React.FormEvent): Promise => { + e.preventDefault(); + setError(""); + if (newPassword !== confirmNewPassword) { + setError("Passwords do not match"); + return; + } + if (newPassword.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + setLoading(true); + try { + const response = await fetch("/api/user/forgot-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + action: "reset", + email, + otp, + newPassword, + }), + }); + const data = (await response.json().catch(() => ({}))) as { + error?: string; + }; + if (!response.ok) { + setError(data.error ?? "Failed to reset password"); + return; + } + toast.success("Password reset! Please sign in."); + setPassword(""); + setView("main"); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to reset password"); + } finally { + setLoading(false); + } + }; + + const titles: Record = { + main: "Log in or sign up", + signup: "Create your account", + forgot: "Reset your password", + reset: "Enter your reset code", + verify: "Verify your email", + "mfa-email": "Check your email", + "mfa-totp": "Authenticator code", + }; + const descriptions: Record = { + main: "Use email, a social account, or your wallet to get started.", + signup: "Sign up with your email and a password.", + forgot: "We'll email you a code to reset your password.", + reset: "Enter the code we emailed and choose a new password.", + verify: `Enter the 6-digit code sent to ${email}.`, + "mfa-email": `Enter the 6-digit code sent to ${email}. We'll ask for your authenticator next.`, + "mfa-totp": "Enter the current 6-digit code from your authenticator app.", + }; + + const onSubmit = (e: React.FormEvent): void => { + if (view === "main") { + handleSignIn(e); + } else if (view === "signup") { + handleSignUp(e); + } else if (view === "verify") { + handleVerify(e); + } else if (view === "forgot") { + handleForgotRequest(e); + } else if (view === "reset") { + handleReset(e); + } else if (view === "mfa-email") { + handleMfaEmailContinue(e); + } else if (view === "mfa-totp") { + handleMfaComplete(e); + } else { + e.preventDefault(); + } + }; + + const items: Item[] = []; + if (!(hideChooserHeader && view === "main")) { + items.push({ + key: "header", + node: ( +
+

{titles[view]}

+

{descriptions[view]}

+
+ ), + }); + } + + const emailField = (autoComplete: string): React.ReactNode => ( +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + required + type="email" + value={email} + /> +
+ ); + + if (view === "main") { + items.push({ + key: "socials", + node: ( +
+ {providers.google ? ( + + ) : null} + {providers.github ? ( + + ) : null} + {onWalletClick ? ( + + ) : null} +
+ ), + }); + items.push({ + key: "or", + node: ( +
+ + OR + +
+ ), + }); + items.push({ key: "email", node: emailField("email") }); + items.push({ + key: "password", + node: ( +
+
+ + +
+ setPassword(e.target.value)} + placeholder="Enter your password" + required + type="password" + value={password} + /> +
+ ), + }); + items.push({ + key: "submit", + node: ( + + ), + }); + items.push({ + key: "signin-create", + node: ( +

+ New here?{" "} + +

+ ), + }); + } + + if (view === "signup") { + items.push({ key: "email", node: emailField("email") }); + items.push({ + key: "password", + node: ( +
+ + setPassword(e.target.value)} + placeholder="Create a password" + required + type="password" + value={password} + /> +
+ ), + }); + if (turnstileSiteKey) { + items.push({ + key: "captcha", + node: ( + setCaptchaToken("")} + onExpire={() => setCaptchaToken("")} + onSuccess={setCaptchaToken} + options={{ theme: "auto" }} + ref={captchaRef} + siteKey={turnstileSiteKey} + /> + ), + }); + } + items.push({ + key: "submit", + node: ( + + ), + }); + items.push({ + key: "signup-signin", + node: ( +

+ Already have an account?{" "} + +

+ ), + }); + } + + if (view === "verify") { + items.push({ + key: "otp", + node: ( + setOtp(e.target.value)} + placeholder="123456" + value={otp} + /> + ), + }); + items.push({ + key: "submit", + node: ( + + ), + }); + items.push({ + key: "verify-resend", + node: ( +

+ Didn't receive it?{" "} + +

+ ), + }); + } + + if (view === "mfa-email") { + items.push({ + key: "otp", + node: ( + setMfaEmailOtp(e.target.value)} + placeholder="123456" + value={mfaEmailOtp} + /> + ), + }); + items.push({ + key: "submit", + node: ( + + ), + }); + items.push({ + key: "mfa-email-resend", + node: ( +

+ Didn't receive it?{" "} + +

+ ), + }); + } + + if (view === "mfa-totp") { + items.push({ + key: "otp", + node: ( + setMfaTotpCode(e.target.value)} + placeholder="123456" + value={mfaTotpCode} + /> + ), + }); + items.push({ + key: "submit", + node: ( + + ), + }); + } + + if (view === "forgot") { + items.push({ key: "email", node: emailField("email") }); + items.push({ + key: "submit", + node: ( + + ), + }); + items.push({ + key: "forgot-back", + node: ( + + ), + }); + } + + if (view === "reset") { + items.push({ + key: "otp", + node: ( + setOtp(e.target.value)} + placeholder="Reset code" + value={otp} + /> + ), + }); + items.push({ + key: "new-password", + node: ( + setNewPassword(e.target.value)} + placeholder="New password" + type="password" + value={newPassword} + /> + ), + }); + items.push({ + key: "confirm-password", + node: ( + setConfirmNewPassword(e.target.value)} + placeholder="Confirm new password" + type="password" + value={confirmNewPassword} + /> + ), + }); + items.push({ + key: "submit", + node: ( + + ), + }); + } + + if (error) { + items.push({ + key: "error", + node:

{error}

, + }); + } + + return ( +
+
+ +
+ + {items.map(({ key, node }) => ( + + {node} + + ))} + +
+
+
+
+ ); +} diff --git a/components/auth/connect-button.tsx b/components/auth/connect-button.tsx new file mode 100644 index 0000000000..00709176ca --- /dev/null +++ b/components/auth/connect-button.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useState } from "react"; +import { SignInChoices } from "@/components/auth/sign-in-choices"; +import { KeeperHubLogo } from "@/components/icons/keeperhub-logo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; + +/** + * In-app "Connect" entry point. Opens a modal presenting the same single-column + * sign-in interface as the welcome page (email + social, plus a wallet reveal). + */ +export function ConnectButton(): React.ReactElement { + const [open, setOpen] = useState(false); + + return ( + + + + + + + + Sign in to KeeperHub + + + + + ); +} diff --git a/components/auth/dialog.tsx b/components/auth/dialog.tsx index 978a934380..5391056621 100644 --- a/components/auth/dialog.tsx +++ b/components/auth/dialog.tsx @@ -1,1655 +1,84 @@ "use client"; -import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile"; -import { useRouter } from "next/navigation"; -import { type ReactNode, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; -import { EmailOtpField } from "@/components/auth/email-otp-field"; -import { Button } from "@/components/ui/button"; +import { type ReactNode, useState } from "react"; +import { SignInChoices } from "@/components/auth/sign-in-choices"; +import { KeeperHubLogo } from "@/components/icons/keeperhub-logo"; import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Separator } from "@/components/ui/separator"; -import { Spinner } from "@/components/ui/spinner"; -import { authClient, signIn, signUp } from "@/lib/auth-client"; -import { DISPOSABLE_EMAIL_REJECTION_MESSAGE } from "@/lib/auth-disposable-emails-message"; -import { AUTH_SUCCESS_EVENT } from "@/lib/auth-events"; -import { - getEnabledAuthProviders, - getSingleProvider, -} from "@/lib/auth-providers"; -import { setPendingClaim } from "@/lib/hooks/use-claim-workflow"; - -const WORKFLOW_PATH_REGEX = /^\/workflows\/([^/]+)$/; export type AuthPromptIntent = { action?: string; redirectTo?: string; }; +// --- Auth-flow-in-progress tracking -------------------------------------- +// ConnectAuthPanel flips connectPanelActive during its email / OTP steps so +// surfaces like the user menu know an auth flow is mid-air and must not be +// torn down by a session refetch. Kept here so importers have one source. +let connectPanelActive = false; +export const setConnectPanelActive = (active: boolean): void => { + connectPanelActive = active; +}; +export const isAuthFlowInProgress = (): boolean => connectPanelActive; +// Retained for API compatibility. The single-provider auto-initiate UI was +// removed when every auth surface moved to the shared SignInChoices. +export const isSingleProviderSignInInitiated = (): boolean => false; + type AuthDialogProps = { children?: ReactNode; /** - * When provided, AuthDialog is in controlled mode: this prop drives the - * open state and `onControlledOpenChange` is invoked on every change. - * When absent, AuthDialog uses internal useState as today. - * Used by AuthProvider/useAuthPrompt for programmatic open. + * Controlled mode: `controlledOpen` drives the open state and + * `onControlledOpenChange` fires on every change. Used by + * AuthProvider/useAuthPrompt for programmatic open. When absent, the dialog + * manages its own open state and `children` is the trigger. */ controlledOpen?: boolean; onControlledOpenChange?: (open: boolean) => void; /** - * Optional intent payload — telemetry/analytics hint and post-sign-in - * redirect bias. Not consumed by the modal copy itself. - * Phase 43 will read `redirectTo` after OAuth callback. + * Accepted for API compatibility. Post-sign-in routing is handled by the + * shared sign-in flow, which is onboarding-aware (new users land in the + * wizard, returning users on the canvas). */ intent?: AuthPromptIntent; }; -type ModalView = - | "signin" - | "signup" - | "verify" - | "signin-email-otp" - | "totp" - | "forgot-password" - | "reset-password"; - -const GitHubIcon = () => ( - - GitHub - - -); - -const GoogleIcon = () => ( - - Google - - - - - -); - -type Provider = "email" | "github" | "google"; - -const getProviderIcon = (provider: Provider, compact = false) => { - const _iconClass = compact ? "size-3.5" : undefined; - switch (provider) { - case "github": - return ; - case "google": - return ; - default: - return null; - } -}; - -const getProviderLabel = (provider: Provider) => { - switch (provider) { - case "github": - return "GitHub"; - case "google": - return "Google"; - default: - return "Email"; - } -}; - -// Social buttons component -type SocialButtonsProps = { - enabledProviders: { github: boolean; google: boolean }; - onSignIn: (provider: "github" | "google") => void; - loadingProvider: "github" | "google" | null; -}; - -const SocialButtons = ({ - enabledProviders, - onSignIn, - loadingProvider, -}: SocialButtonsProps) => ( -
- {enabledProviders.github && ( - - )} - {enabledProviders.google && ( - - )} -
-); - -// Sign In Form -type SignInFormProps = { - email: string; - password: string; - error: string; - loading: boolean; - onEmailChange: (v: string) => void; - onPasswordChange: (v: string) => void; - onSubmit: (e: React.FormEvent) => void; - onCreateAccount: () => void; - onForgotPassword: () => void; -}; - -const SignInForm = ({ - email, - password, - error, - loading, - onEmailChange, - onPasswordChange, - onSubmit, - onCreateAccount, - onForgotPassword, -}: SignInFormProps) => ( -
-
-
- - onEmailChange(e.target.value)} - placeholder="you@example.com" - required - type="email" - value={email} - /> -
-
-
- - -
- onPasswordChange(e.target.value)} - placeholder="Enter your password" - required - type="password" - value={password} - /> -
- {error &&
{error}
} - -
-
- New here? - -
-
-); - -// Single provider button -let singleProviderSignInInitiated = false; -export const isSingleProviderSignInInitiated = () => - singleProviderSignInInitiated; - -// Track verification state to persist through component remounts -let pendingVerifyEmail: string | null = null; -let pendingVerifyPassword: string | null = null; - -// True while the dialog is on a sign-in/sign-up verification step (email OTP, -// TOTP, or signup email verify). UserMenu reads this to keep the AuthDialog -// mounted through the Better Auth session refetch that fires when the tab -// regains focus, so leaving to grab the code from the email tab does not drop -// the modal. -let authFlowInProgress = false; -export const isAuthFlowInProgress = () => authFlowInProgress; - -type SingleProviderButtonProps = { - provider: Provider; - loadingProvider: "github" | "google" | null; - onSignIn: (provider: "github" | "google") => Promise; -}; - -const SingleProviderButton = ({ - provider, - loadingProvider, - onSignIn, -}: SingleProviderButtonProps) => { - const [isInitiated, setIsInitiated] = useState(singleProviderSignInInitiated); - const isLoading = loadingProvider === provider || isInitiated; - - const handleClick = () => { - singleProviderSignInInitiated = true; - setIsInitiated(true); - onSignIn(provider as "github" | "google"); - }; - - return ( - - ); -}; - -/** - * Two-step indicator for the credential sign-in flow (email OTP then - * authenticator code). Visually matches the wizard pattern in - * components/auth/dual-factor-steps.tsx + components/settings/totp-setup-dialog.tsx - * so every dual-factor surface looks the same. - */ -const SIGN_IN_STEPS: ReadonlyArray<{ - key: "email" | "authenticator"; - label: string; -}> = [ - { key: "email", label: "Email code" }, - { key: "authenticator", label: "Authenticator" }, -] as const; - -function SignInStepIndicator({ - current, -}: { - current: "email" | "authenticator"; -}): React.ReactElement { - const currentIdx = SIGN_IN_STEPS.findIndex((s) => s.key === current); - return ( -
    - {SIGN_IN_STEPS.map((s, idx) => { - const status = - idx === currentIdx - ? "current" - : idx < currentIdx - ? "done" - : "pending"; - const isLast = idx === SIGN_IN_STEPS.length - 1; - const bubble = - status === "current" - ? "border-keeperhub-green-dark bg-keeperhub-green text-foreground dark:text-background" - : "border-border text-foreground"; - const labelClass = - status === "current" - ? "font-medium text-foreground" - : "text-foreground"; - return ( -
  1. - - {idx + 1} - - {s.label} - {!isLast && ( -
  2. - ); - })} -
- ); -} - -// Helper functions -const getViewTitle = (view: ModalView) => { - switch (view) { - case "signin": - return "Sign in"; - case "signup": - return "Create account"; - case "verify": - return "Verify your email"; - case "signin-email-otp": - return "Check your email"; - case "totp": - return "Confirm with your authenticator"; - case "forgot-password": - return "Reset password"; - case "reset-password": - return "Set new password"; - default: - return "Sign in"; - } -}; - -const getViewDescription = (view: ModalView, email?: string) => { - switch (view) { - case "signin": - return "Sign in to your account to continue."; - case "signup": - return "Create your account to get started."; - case "verify": - return email - ? `Enter the 6-digit code sent to ${email}` - : "Enter the verification code sent to your email."; - case "signin-email-otp": - return email - ? `Enter the 6-digit code emailed to ${email}. After this we'll ask for your authenticator code.` - : "Enter the 6-digit code emailed to you. After this we'll ask for your authenticator code."; - case "totp": - return "Enter the current 6-digit code from your authenticator app to finish signing in."; - case "forgot-password": - return "Enter your email to receive a password reset code."; - case "reset-password": - return email - ? `Enter the 6-digit code sent to ${email} and your new password.` - : "Enter the reset code and your new password."; - default: - return null; - } -}; - /** - * Restrict a post-sign-in redirect target to a same-origin relative path. - * Anything not starting with a single "/" - a protocol-relative "//", a - * scheme, or the "/\" form browsers normalize to "//" - is rejected so a - * caller-supplied redirectTo cannot become an open redirect. Returns null - * when the target is absent or unsafe. + * App-wide auth modal. Presents the shared SignInChoices interface -- the same + * one the Connect button and welcome page use -- so every entry point (sidebar + * requireAuth nav, activity, use-template, accept-invite, add-connection, the + * device page) shows an identical sign-in surface. Supports controlled mode + * (AuthProvider) and an uncontrolled trigger passed as children. */ -function safeRedirectPath(target: string | undefined): string | null { - if ( - typeof target === "string" && - target.startsWith("/") && - !target.startsWith("//") && - !target.startsWith("/\\") - ) { - return target; - } - return null; -} - -// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Auth dialog handles multiple views and flows export const AuthDialog = ({ children, controlledOpen, onControlledOpenChange, - // intent.redirectTo, when it is a same-origin relative path, is honored as - // the post-sign-in landing (guarded by safeRedirectPath); otherwise we fall - // back to "/". Entry points that set it: accept-invite and use-template. - intent, -}: AuthDialogProps) => { - // Internal state — used when not controlled. We always call useState to - // keep hook order stable; the value is just ignored when controlled. - const [internalOpen, setInternalOpen] = useState( - () => pendingVerifyEmail !== null - ); - +}: AuthDialogProps): React.ReactElement => { + const [internalOpen, setInternalOpen] = useState(false); const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : internalOpen; - const setOpen = (next: boolean) => { + const setOpen = (next: boolean): void => { if (isControlled) { onControlledOpenChange?.(next); } else { setInternalOpen(next); } }; - const router = useRouter(); - // Where to land after a successful sign-in: a guarded same-origin path from - // the auth intent, else "/". Consumed by the two sign-in success paths. - const redirectTarget = safeRedirectPath(intent?.redirectTo) ?? "/"; - const [view, setView] = useState(() => - pendingVerifyEmail === null ? "signin" : "verify" - ); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [verifyEmail, setVerifyEmail] = useState( - () => pendingVerifyEmail || "" - ); - const [verifyPassword, setVerifyPassword] = useState( - () => pendingVerifyPassword || "" - ); - const [otp, setOtp] = useState(""); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - const [forgotEmail, setForgotEmail] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmNewPassword, setConfirmNewPassword] = useState(""); - // Optional second factor on the password-reset path. Server only - // requires it when the target user has TOTP enrolled; we ask for - // it preemptively so the user doesn't have to round-trip. - const [forgotTotp, setForgotTotp] = useState(""); - const [totpCode, setTotpCode] = useState(""); - const [loadingProvider, setLoadingProvider] = useState< - "github" | "google" | null - >(null); - const [captchaToken, setCaptchaToken] = useState(""); - const captchaRef = useRef(null); - // Client-side captcha is keyed off NEXT_PUBLIC_TURNSTILE_SITE_KEY. The - // server-side gate is keyed off TURNSTILE_SECRET_KEY (lib/auth.ts) - both - // must be set together or signup will fail with a missing-token error. - const turnstileSiteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY; - - // Handle pending verification when component mounts/remounts - // Do NOT clear pendingVerify* here - they must survive remounts caused by - // session refetch on tab focus. They are cleared on successful verification - // (handleVerifyOtp) or when the user navigates away (Back to sign in). - useEffect(() => { - if (pendingVerifyEmail) { - setOpen(true); - setView("verify"); - setVerifyEmail(pendingVerifyEmail); - if (pendingVerifyPassword) { - setVerifyPassword(pendingVerifyPassword); - } - } - }, []); - - const enabledProviders = getEnabledAuthProviders(); - const singleProvider = getSingleProvider(); - const hasSocialProviders = enabledProviders.github || enabledProviders.google; - - const resetForm = () => { - setEmail(""); - setPassword(""); - setVerifyEmail(""); - setVerifyPassword(""); - setOtp(""); - setError(""); - setForgotEmail(""); - setNewPassword(""); - setConfirmNewPassword(""); - setForgotTotp(""); - setCaptchaToken(""); - captchaRef.current?.reset(); - }; - - // Reset the Turnstile widget when leaving the signup view so that - // returning to it always renders a fresh challenge. Tokens are - // single-use and short-lived; a stale token would 401 the next signup. - useEffect(() => { - if (view !== "signup") { - setCaptchaToken(""); - captchaRef.current?.reset(); - } - }, [view]); - - // Mirror the active verification step into a module flag so UserMenu can - // keep this dialog mounted through the tab-focus session refetch instead of - // swapping it for a skeleton and losing the in-progress code entry. - useEffect(() => { - authFlowInProgress = - open && - (view === "signin-email-otp" || view === "totp" || view === "verify"); - }, [open, view]); - - const resetCaptcha = () => { - setCaptchaToken(""); - captchaRef.current?.reset(); - }; - - const getClaimContext = async () => { - const workflowMatch = window.location.pathname.match(WORKFLOW_PATH_REGEX); - if (!workflowMatch) { - return null; - } - const currentSession = await authClient.getSession(); - const previousUserId = currentSession?.data?.user?.id; - if (!previousUserId) { - return null; - } - return { workflowId: workflowMatch[1], previousUserId }; - }; - - const storeClaimIfNeeded = ( - claimContext: { workflowId: string; previousUserId: string } | null - ) => { - if (claimContext) { - setPendingClaim(claimContext); - } - }; - - const handleOpenChange = (newOpen: boolean) => { - setOpen(newOpen); - if (!newOpen) { - pendingVerifyEmail = null; - pendingVerifyPassword = null; - setTimeout(() => { - setView("signin"); - resetForm(); - }, 200); - } - }; - - const handleSocialSignIn = async (provider: "github" | "google") => { - try { - setLoadingProvider(provider); - const claimContext = await getClaimContext(); - storeClaimIfNeeded(claimContext); - await signIn.social({ provider, callbackURL: window.location.pathname }); - } catch { - toast.error(`Failed to sign in with ${getProviderLabel(provider)}`); - setLoadingProvider(null); - } - }; - - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex auth flow with multiple verification paths - const handleSignIn = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - setLoading(true); - - const claimContext = await getClaimContext(); - - try { - // Strict atomic dual-factor sign-in: - // /strict-signin/start validates the password against the - // credential account directly (no Better Auth session minted) - // and triggers the email-OTP send. The user then provides the - // email OTP and TOTP across the next two dialog steps, and - // /api/auth/strict-signin verifies all three factors before - // any session is created. Better Auth's signIn.email is NOT - // called from the dialog because that path leaves a - // two_factor cookie that the standalone signIn.emailOtp path - // can convert into a session without TOTP. - const startResponse = await fetch("/api/auth/strict-signin/start", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ email, password }), - }); - const startBody = (await startResponse.json().catch(() => ({}))) as { - error?: string; - code?: string; - signedIn?: boolean; - }; - if (!startResponse.ok) { - // Unverified email: the account exists and the password matched, but the - // email isn't verified. Mirror the signup path -- (re)send the - // verification OTP and route to the verify view -- rather than surfacing - // a generic error. A send failure is non-fatal (the OTP is stored - // server-side and the verify view can resend), so don't block on it. - if (startBody.code === "email_not_verified") { - await authClient.emailOtp - .sendVerificationOtp({ email, type: "email-verification" }) - .catch(() => undefined); - setVerifyEmail(email); - setVerifyPassword(password); - setView("verify"); - setOtp(""); - pendingVerifyEmail = email; - pendingVerifyPassword = password; - setLoading(false); - return; - } - const message = startBody.error ?? "Sign in failed"; - if (startBody.code === "account_deactivated") { - toast.error(message); - } - setError(message); - setLoading(false); - return; - } - storeClaimIfNeeded(claimContext); - // Users without TOTP enrolled get a session minted directly by - // /strict-signin/start; the response carries the session cookies - // and we hard-reload to pick them up. Users with TOTP enrolled - // get signedIn=false and proceed to the email-OTP step. - if (startBody.signedIn) { - toast.success("Signed in successfully!"); - window.dispatchEvent(new CustomEvent(AUTH_SUCCESS_EVENT)); - if (typeof window !== "undefined") { - window.location.assign(redirectTarget); - } - return; - } - setOtp(""); - setVerifyEmail(email); - setView("signin-email-otp"); - setLoading(false); - return; - } catch (err) { - setError(err instanceof Error ? err.message : "Sign in failed"); - setLoading(false); - } - }; - - /** - * Re-send the sign-in email OTP from the same email-otp view. - * Mirrors authClient.emailOtp.sendVerificationOtp call we make in - * handleSignIn so the user can rerequest without bouncing back to - * the password screen. - */ - const handleResendSigninEmailOtp = async (): Promise => { - const targetEmail = verifyEmail || email; - if (!targetEmail) { - setError("Missing email, start sign-in again"); - return; - } - setError(""); - setLoading(true); - try { - const sendResult = await authClient.emailOtp.sendVerificationOtp({ - email: targetEmail, - type: "sign-in", - }); - if (sendResult.error) { - setError(sendResult.error.message ?? "Failed to resend code"); - return; - } - toast.success("Code resent"); - setOtp(""); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to resend code"); - } finally { - setLoading(false); - } - }; - - /** - * Submit the email OTP that finishes the FIRST factor of a - * mandatory dual-factor sign-in (password → email → TOTP). Calls - * signIn.emailOtp; for users with TOTP enrolled Better Auth then - * returns twoFactorRedirect again, which routes us to the totp - * view. Users without TOTP (shouldn't exist under the mandate, but - * defensive) would get a fully-signed-in session here. - */ - const handleSigninEmailOtp = (e: React.FormEvent): void => { - e.preventDefault(); - if (otp.trim().length !== 6) { - setError("Enter the 6-digit code from your email"); - return; - } - // Strict atomic dual-factor: do NOT verify the email OTP here. - // Hold onto it (already in `otp` state) and move to the TOTP - // step. The atomic /api/auth/strict-signin endpoint will verify - // email OTP and TOTP together, mint the session only if both - // pass, and reject otherwise. Verifying email OTP separately at - // this step would either bypass TOTP (the security bug we're - // fixing) or require holding partial state on the server. - setError(""); - setTotpCode(""); - setView("totp"); - }; - - /** - * Submit the TOTP code that finishes a sign-in that paused on - * Better Auth's twoFactorRedirect step. The plugin's verifyTotp - * endpoint converts the `better-auth.two_factor` challenge cookie - * into a real session cookie on success. - */ - const handleTotpVerify = async (e: React.FormEvent): Promise => { - e.preventDefault(); - if (totpCode.trim().length !== 6) { - setError("Enter the 6-digit code from your authenticator"); - return; - } - if (otp.trim().length !== 6) { - setError("Missing email code. Start sign-in again."); - setView("signin"); - return; - } - setError(""); - setLoading(true); - try { - const completeResponse = await fetch("/api/auth/strict-signin", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: verifyEmail || email, - password, - emailOtp: otp.trim(), - totpCode: totpCode.trim(), - }), - }); - const completeBody = (await completeResponse - .json() - .catch(() => ({}))) as { - error?: string; - code?: string; - redirect?: string; - }; - if (!completeResponse.ok) { - if (completeBody.code === "invalid_email_otp") { - setError("Invalid email code"); - setOtp(""); - setView("signin-email-otp"); - return; - } - if (completeBody.code === "invalid_totp") { - setError("Invalid authenticator code"); - setTotpCode(""); - return; - } - setError(completeBody.error ?? "Sign in failed"); - return; - } - setTotpCode(""); - setOtp(""); - // The atomic strict-signin endpoint can defer the session and - // return a redirect when the request came from an IP that the - // user has never signed in from. In that case a signed - // pending_ip_verify cookie has just been set, no session row - // exists yet, and /verify-ip is the page that will ask for a - // fresh email+TOTP dual factor against the same IP and only - // then mint the session. - if (completeBody.redirect === "/verify-ip") { - if (typeof window !== "undefined") { - window.location.assign("/verify-ip"); - } - return; - } - toast.success("Signed in successfully!"); - window.dispatchEvent(new CustomEvent(AUTH_SUCCESS_EVENT)); - // Hard reload so the server re-renders with the new - // better-auth.session_token cookie. authClient.getSession() - // does refresh the atom but the user-menu/useSession subtree - // races the AuthDialog unmount that happens once the dialog - // closes; the cleanest signal is a real navigation. - if (typeof window !== "undefined") { - window.location.assign(redirectTarget); - } - } catch (err) { - setError(err instanceof Error ? err.message : "Verification failed"); - } finally { - setLoading(false); - } - }; - - // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Handles signup with unverified user detection - const handleSignUp = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - setLoading(true); - - try { - const signUpResponse = await signUp.email({ - email, - password, - name: email.split("@")[0], - ...(captchaToken && { - fetchOptions: { - headers: { "x-captcha-response": captchaToken }, - }, - }), - }); - - if (signUpResponse.error) { - const errorMsg = signUpResponse.error.message || "Sign up failed"; - - // Server-side disposable-email rejection: the better-auth - // databaseHooks.user.create.before hook throws an APIError - // carrying DISPOSABLE_EMAIL_REJECTION_MESSAGE. Match the constant - // directly so the surfaced text never drifts out of sync. - if (errorMsg === DISPOSABLE_EMAIL_REJECTION_MESSAGE) { - setError(DISPOSABLE_EMAIL_REJECTION_MESSAGE); - resetCaptcha(); - setLoading(false); - return; - } - - // Check if error is about existing user (might be unverified) - if ( - errorMsg.toLowerCase().includes("already") || - errorMsg.toLowerCase().includes("exists") || - errorMsg.toLowerCase().includes("duplicate") - ) { - // Try to send verification OTP - if user exists but unverified, this will work - try { - const otpResponse = await authClient.emailOtp.sendVerificationOtp({ - email, - type: "email-verification", - }); - - if (otpResponse.error) { - // OTP send failed due to API error - const otpErrMsg = - otpResponse.error.message || "Failed to send verification code"; - toast.error(otpErrMsg); - setError( - "An account with this email already exists. Please sign in." - ); - resetCaptcha(); - setLoading(false); - return; - } - - // OTP sent successfully - user exists but is unverified - setVerifyEmail(email); - setVerifyPassword(password); - setView("verify"); - setOtp(""); - pendingVerifyEmail = email; - pendingVerifyPassword = password; - - toast.info( - "An account with this email already exists. Please verify your email.", - { duration: 5000 } - ); - setLoading(false); - return; - } catch (otpErr) { - // OTP send failed - user is already verified or email send failed - const otpErrMsg = - otpErr instanceof Error - ? otpErr.message - : "Failed to send verification code"; - if (otpErrMsg.toLowerCase().includes("email")) { - toast.error(otpErrMsg); - } - setError( - "An account with this email already exists. Please sign in." - ); - resetCaptcha(); - setLoading(false); - return; - } - } - - setError(errorMsg); - resetCaptcha(); - setLoading(false); - return; - } - - // Store email and password for verification view - const signedUpEmail = email; - const signedUpPassword = password; - - // Switch to verify view - setLoading(false); - setVerifyEmail(signedUpEmail); - setVerifyPassword(signedUpPassword); - setView("verify"); - setError(""); - setOtp(""); - - // Store in module-level vars in case component remounts - pendingVerifyEmail = signedUpEmail; - pendingVerifyPassword = signedUpPassword; - - toast.success( - `Verification code sent to ${signedUpEmail}. Please check your inbox.`, - { duration: 5000 } - ); - } catch (err) { - setError(err instanceof Error ? err.message : "Failed to create account"); - resetCaptcha(); - setLoading(false); - } - }; - - const handleVerifyOtp = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - setLoading(true); - - // Capture claim context BEFORE verification (while still anonymous) - const claimContext = await getClaimContext(); - - try { - const response = await authClient.emailOtp.verifyEmail({ - email: verifyEmail, - otp, - }); - - if (response.error) { - setError(response.error.message || "Verification failed"); - setLoading(false); - return; - } - - // Clear pending state - pendingVerifyEmail = null; - pendingVerifyPassword = null; - - // Do NOT call signIn.email here. New signups must enroll TOTP - // before any session row is minted. /api/auth/finish-credential-signup - // re-verifies the password, confirms email_verified, and issues - // a signed pending_signup_mfa cookie that only unlocks - // /enroll-mfa. The real session is created for the first time - // inside /api/user/totp/enroll once the user finishes the - // 3-step wizard. No usable session exists in between. - if (!verifyPassword) { - setError("Missing password state. Sign in again to continue."); - setView("signin"); - setEmail(verifyEmail); - setOtp(""); - setLoading(false); - return; - } - const finishResponse = await fetch("/api/auth/finish-credential-signup", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - email: verifyEmail, - password: verifyPassword, - }), - }); - const finishBody = (await finishResponse.json().catch(() => ({}))) as { - error?: string; - code?: string; - redirect?: string; - }; - if (!finishResponse.ok) { - setError(finishBody.error ?? "Could not finish signup"); - setLoading(false); - return; - } - - storeClaimIfNeeded(claimContext); - toast.success("Email verified! Set up your authenticator to continue."); - window.dispatchEvent(new CustomEvent(AUTH_SUCCESS_EVENT)); - if (typeof window !== "undefined") { - window.location.assign(finishBody.redirect ?? "/enroll-mfa"); - } - } catch (err) { - setError(err instanceof Error ? err.message : "Verification failed"); - setLoading(false); - } - }; - - const handleResendOtp = async () => { - setError(""); - setLoading(true); - - try { - const response = await authClient.emailOtp.sendVerificationOtp({ - email: verifyEmail, - type: "email-verification", - }); - - if (response.error) { - const errorMsg = response.error.message || "Failed to resend code"; - setError(errorMsg); - toast.error(errorMsg); - setLoading(false); - return; - } - - toast.success("New verification code sent!"); - setLoading(false); - } catch (err) { - const errorMsg = - err instanceof Error ? err.message : "Failed to resend code"; - setError(errorMsg); - toast.error(errorMsg); - setLoading(false); - } - }; - - const handleForgotPasswordRequest = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - setLoading(true); - - try { - const response = await fetch("/api/user/forgot-password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "request", email: forgotEmail }), - }); - - const data = (await response.json()) as { - error?: string; - message?: string; - }; - - if (!response.ok) { - throw new Error(data.error ?? "Failed to send reset code"); - } - - toast.success("Check your inbox for the reset code."); - setView("reset-password"); - setOtp(""); - } catch (err) { - const errorMsg = - err instanceof Error ? err.message : "Failed to send reset code"; - setError(errorMsg); - toast.error(errorMsg); - } finally { - setLoading(false); - } - }; - - const handlePasswordReset = async (e: React.FormEvent) => { - e.preventDefault(); - setError(""); - - if (newPassword !== confirmNewPassword) { - setError("Passwords do not match"); - return; - } - - if (newPassword.length < 8) { - setError("Password must be at least 8 characters"); - return; - } - - setLoading(true); - - try { - const response = await fetch("/api/user/forgot-password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - action: "reset", - email: forgotEmail, - otp, - newPassword, - code: forgotTotp ? forgotTotp.trim() : undefined, - }), - }); - - const data = (await response.json()) as { - error?: string; - code?: string; - }; - - if (!response.ok) { - if (data.code === "mfa_code_required") { - setError( - data.error ?? - "This account has two-factor enabled. Enter a code from your authenticator." - ); - return; - } - if (data.code === "mfa_code_invalid") { - setForgotTotp(""); - setError(data.error ?? "Invalid verification code"); - return; - } - throw new Error(data.error ?? "Failed to reset password"); - } - - toast.success("Password reset successfully! Please sign in."); - setView("signin"); - setEmail(forgotEmail); - resetForm(); - } catch (err) { - const errorMsg = - err instanceof Error ? err.message : "Failed to reset password"; - setError(errorMsg); - toast.error(errorMsg); - } finally { - setLoading(false); - } - }; - - const handleResendForgotOtp = async () => { - setError(""); - setLoading(true); - - try { - const response = await fetch("/api/user/forgot-password", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ action: "request", email: forgotEmail }), - }); - - const data = (await response.json()) as { error?: string }; - - if (!response.ok) { - throw new Error(data.error ?? "Failed to resend code"); - } - - toast.success("New reset code sent!"); - } catch (err) { - const errorMsg = - err instanceof Error ? err.message : "Failed to resend code"; - setError(errorMsg); - toast.error(errorMsg); - } finally { - setLoading(false); - } - }; - - if (singleProvider && singleProvider !== "email") { - return ( - - ); - } return ( - - {!isControlled && ( - - {children || ( - - )} - - )} - - - {getViewTitle(view)} - {getViewDescription(view, verifyEmail) && ( - - {getViewDescription(view, verifyEmail)} - - )} + + {children ? {children} : null} + + + + Sign in to KeeperHub - -
- {view === "signin" && ( - <> - {hasSocialProviders && enabledProviders.email && ( - <> - -
-
- -
-
- - Or continue with email - -
-
- - )} - {hasSocialProviders && !enabledProviders.email && ( - - )} - {enabledProviders.email && ( - { - setView("signup"); - setError(""); - }} - onEmailChange={setEmail} - onForgotPassword={() => { - setForgotEmail(email); - setView("forgot-password"); - setError(""); - }} - onPasswordChange={setPassword} - onSubmit={handleSignIn} - password={password} - /> - )} - - )} - - {view === "signup" && ( -
-
-
- - setEmail(e.target.value)} - placeholder="you@example.com" - required - type="email" - value={email} - /> -
-
- - setPassword(e.target.value)} - placeholder="Create a password" - required - type="password" - value={password} - /> -
- {error && ( -
{error}
- )} - {turnstileSiteKey && ( - setCaptchaToken("")} - onExpire={() => setCaptchaToken("")} - onSuccess={(token) => setCaptchaToken(token)} - options={{ theme: "auto" }} - ref={captchaRef} - siteKey={turnstileSiteKey} - /> - )} - - -
- - Already have an account? - - -
-
- )} - - {view === "verify" && ( -
-
-
- - - setOtp(e.target.value.replace(/\D/g, "").slice(0, 6)) - } - pattern="[0-9]*" - placeholder="000000" - required - value={otp} - /> -
- {error && ( -
{error}
- )} - -
-
- - Didn't receive the code? - - -
-
- -
-
- )} - - {view === "signin-email-otp" && ( -
- -
- { - if (otp.length === 6 && !loading) { - handleSigninEmailOtp({ - preventDefault: () => undefined, - } as React.FormEvent); - } - }} - value={otp} - /> - {error && ( -
{error}
- )} - - -
- - Didn't receive your code? - - -
-
- -
-
- )} - - {view === "totp" && ( -
- -
-
- - - setTotpCode(e.target.value.replace(/\D/g, "").slice(0, 6)) - } - pattern="[0-9]*" - placeholder="000000" - required - value={totpCode} - /> -
- {error && ( -
{error}
- )} - -
-
- -
-
- )} - - {view === "forgot-password" && ( -
-
-
- - setForgotEmail(e.target.value)} - placeholder="you@example.com" - required - type="email" - value={forgotEmail} - /> -
- {error && ( -
{error}
- )} - -
-
- -
-
- )} - - {view === "reset-password" && ( -
-
- -
- - - setForgotTotp( - e.target.value.replace(/\D/g, "").slice(0, 6) - ) - } - pattern="[0-9]*" - placeholder="000000" - value={forgotTotp} - /> -
-
- - setNewPassword(e.target.value)} - placeholder="Enter new password (min 8 characters)" - required - type="password" - value={newPassword} - /> -
-
- - setConfirmNewPassword(e.target.value)} - placeholder="Confirm new password" - required - type="password" - value={confirmNewPassword} - /> -
- {error && ( -
{error}
- )} - - -
- - Didn't receive the code? - - -
-
- -
-
- )} -
+
); diff --git a/components/auth/sign-in-choices.tsx b/components/auth/sign-in-choices.tsx new file mode 100644 index 0000000000..39c5b2b04e --- /dev/null +++ b/components/auth/sign-in-choices.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { ArrowLeft } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { useLayoutEffect, useRef, useState } from "react"; +import { ConnectAuthPanel } from "@/components/auth/connect-auth-panel"; +import { WalletPicker } from "@/components/auth/wallet-picker"; +import { Button } from "@/components/ui/button"; +import { resolvePostAuthDestination } from "@/lib/post-auth-destination"; + +/** + * Single-column sign-in: a Google / GitHub / Wallet row, an "OR" divider, and + * the email + password form (ConnectAuthPanel). Clicking Wallet reveals the + * wallet picker; the height animates as the two views swap. Shared by the + * welcome landing and the in-app Connect modal so both present the same + * interface. On success the panel navigates home, where the onboarding gate + * takes over. + */ +export function SignInChoices(): React.ReactElement { + const [showWallet, setShowWallet] = useState(false); + + const contentRef = useRef(null); + const [height, setHeight] = useState("auto"); + useLayoutEffect(() => { + const el = contentRef.current; + if (!el) { + return; + } + const observer = new ResizeObserver(() => setHeight(el.scrollHeight)); + observer.observe(el); + setHeight(el.scrollHeight); + return () => observer.disconnect(); + }, []); + + return ( + +
+ + {showWallet ? ( + +

+ Nothing leaves your device but a signature. +

+ { + window.location.assign(await resolvePostAuthDestination()); + }} + /> + +
+ ) : ( + + setShowWallet(true)} + /> + + )} +
+
+
+ ); +} diff --git a/components/auth/wallet-picker.tsx b/components/auth/wallet-picker.tsx new file mode 100644 index 0000000000..6c944fd5a3 --- /dev/null +++ b/components/auth/wallet-picker.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { ArrowRight, ArrowUpRight } from "lucide-react"; +import Image from "next/image"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { Spinner } from "@/components/ui/spinner"; +import { useSession } from "@/lib/auth-client"; +import { useInjectedWallets } from "@/lib/hooks/use-injected-wallets"; +import type { DiscoveredWallet, WalletBrand } from "@/lib/wallet/connect-types"; +import { loginWithWallet } from "@/lib/wallet/wallet-login"; + +const POPULAR_WALLETS: WalletBrand[] = [ + { + rdns: "io.metamask", + name: "MetaMask", + installUrl: "https://metamask.io/download/", + icon: "/wallets/metamask.svg", + }, + { + rdns: "io.rabby", + name: "Rabby", + installUrl: "https://rabby.io/", + icon: "/wallets/rabby.png", + }, + { + rdns: "com.coinbase.wallet", + name: "Coinbase Wallet", + installUrl: "https://www.coinbase.com/wallet/downloads", + icon: "/wallets/coinbase.png", + }, +]; + +// Icon tile for a wallet row. Detected wallets pass their official EIP-6963 +// icon; install suggestions pass their bundled brand icon. +function WalletIcon({ + src, + alt, +}: { + src: string; + alt: string; +}): React.ReactElement { + return ( + + {alt} + + ); +} + +/** + * Shared wallet sign-in list: EIP-6963 discovered wallets plus install + * suggestions for popular wallets that are not detected. Signs in via SIWE + * (loginWithWallet) and refreshes the session, then calls onConnected. Used by + * both the Connect modal and the welcome page's "Sign in with your wallet" view. + */ +export function WalletPicker({ + onConnected, +}: { + onConnected?: () => void; +}): React.ReactElement { + const { refetch } = useSession(); + const wallets = useInjectedWallets(); + // Hide an install suggestion once that wallet is actually detected. + const installedRdns = new Set(wallets.map((w) => w.info.rdns)); + const installable = POPULAR_WALLETS.filter((w) => !installedRdns.has(w.rdns)); + const [connecting, setConnecting] = useState(null); + const [connectHint, setConnectHint] = useState(false); + const hintTimerRef = useRef | null>(null); + + const clearHintTimer = (): void => { + if (hintTimerRef.current) { + clearTimeout(hintTimerRef.current); + hintTimerRef.current = null; + } + }; + + const handleConnect = async (wallet: DiscoveredWallet): Promise => { + setConnecting(wallet.info.rdns); + setConnectHint(false); + hintTimerRef.current = setTimeout(() => setConnectHint(true), 2000); + const result = await loginWithWallet(wallet.provider); + clearHintTimer(); + setConnecting(null); + setConnectHint(false); + if (result.ok) { + await refetch(); + toast.success(`Connected with ${wallet.info.name}`); + onConnected?.(); + return; + } + if (!result.rejected) { + toast.error(result.error); + } + }; + + return ( +
+ {wallets.length > 0 && ( +
+ {wallets.map((wallet) => ( + + ))} + {connectHint && ( +
+ + Check your wallet for a connection prompt. + + +
+ )} +
+ )} + + {installable.length > 0 && ( +
+

+ {wallets.length > 0 + ? "Don't see your wallet? Install one:" + : "No wallet detected. Install one to continue:"} +

+
+ {installable.map((w) => ( + + + {w.name} + Install + + + ))} +
+
+ )} +
+ ); +} diff --git a/components/icons/agent-icons.tsx b/components/icons/agent-icons.tsx new file mode 100644 index 0000000000..e61798c906 --- /dev/null +++ b/components/icons/agent-icons.tsx @@ -0,0 +1,179 @@ +import Image from "next/image"; +import type { SVGProps } from "react"; + +// Brand marks for the connect-agent tabs. Colored marks keep their brand +// fill; monochrome marks use currentColor. OpenClaw (openclaw.ai), Eve +// (vercel/eve), and Goose (block/goose) come from each project's own logo. +type AgentIconProps = SVGProps; + +export function ClaudeIcon(props: AgentIconProps): React.ReactElement { + return ( + + ); +} + +export function ClaudeCodeIcon(props: AgentIconProps): React.ReactElement { + return ( + + ); +} + +export function OpenAIIcon(props: AgentIconProps): React.ReactElement { + return ( + + ); +} + +export function GeminiIcon(props: AgentIconProps): React.ReactElement { + return ( + + ); +} + +export function HermesIcon(props: AgentIconProps): React.ReactElement { + return ( + + ); +} + +export function OpenClawIcon(props: AgentIconProps): React.ReactElement { + return ( + + ); +} + +export function EveIcon(props: AgentIconProps): React.ReactElement { + return ( + + ); +} + +export function GooseIcon({ + className, +}: { + className?: string; +}): React.ReactElement { + return ( + + ); +} diff --git a/components/icons/social-icons.tsx b/components/icons/social-icons.tsx new file mode 100644 index 0000000000..510c5742fe --- /dev/null +++ b/components/icons/social-icons.tsx @@ -0,0 +1,63 @@ +/** + * Brand glyphs for the community/social links (paths from the marketing site's + * svg module). Single-path, 24x24, `fill="currentColor"` so they inherit text + * color like the existing DiscordIcon. + */ + +type IconProps = { className?: string }; + +export function XIcon({ className }: IconProps): React.ReactElement { + return ( + + + + ); +} + +export function LinkedInIcon({ className }: IconProps): React.ReactElement { + return ( + + + + ); +} + +export function YouTubeIcon({ className }: IconProps): React.ReactElement { + return ( + + + + ); +} + +export function TelegramIcon({ className }: IconProps): React.ReactElement { + return ( + + + + ); +} diff --git a/components/layout-content.tsx b/components/layout-content.tsx index 6c8cc1cb5b..3139b425d3 100644 --- a/components/layout-content.tsx +++ b/components/layout-content.tsx @@ -11,23 +11,39 @@ import { WorkflowToolbar } from "@/components/workflow/workflow-toolbar"; * Routes that must render without any auth-aware shell. The MFA gates * land here either with NO session at all (OAuth-deferred path, the * pending_oauth_mfa cookie carries no auth power) or with a session - * that is intentionally restricted (`requires_mfa = true`). Rendering - * the navigation sidebar / workflow toolbar on these routes would - * leak auth-aware UI (user avatar, wallet, org name, etc.) before the - * user has proven both factors. + * that is intentionally restricted (`requires_mfa = true`, or a wallet + * member hard-gated by org MFA enforcement). Rendering the navigation + * sidebar / workflow toolbar on these routes would leak auth-aware UI + * (user avatar, wallet, org name, etc.) before the user has proven both + * factors, and the full layout wraps children in `pointer-events-none` + * plus fires gated api-client calls that 403 -- which on /enforce-mfa + * blocked the enrollment buttons and threw the dev error overlay. */ const BARE_LAYOUT_PATHS: ReadonlySet = new Set([ "/verify-mfa", "/enroll-mfa", + "/enforce-mfa", ]); +// Prefixes whose whole subtree renders bare. The welcome landing plus its +// onboarding wizard (/welcome, /welcome/create-org, ...) are full-screen and +// must not render the workflow shell behind them. +const BARE_LAYOUT_PREFIXES: readonly string[] = ["/welcome"]; + +function isBareLayoutPath(pathname: string): boolean { + if (BARE_LAYOUT_PATHS.has(pathname)) { + return true; + } + return BARE_LAYOUT_PREFIXES.some((prefix) => pathname.startsWith(prefix)); +} + export function LayoutContent({ children, }: { children: ReactNode; }): React.ReactElement { const pathname = usePathname(); - if (BARE_LAYOUT_PATHS.has(pathname)) { + if (isBareLayoutPath(pathname)) { return
{children}
; } return ( diff --git a/components/navigation-sidebar.tsx b/components/navigation-sidebar.tsx index f6a894de5e..e67574c3ed 100644 --- a/components/navigation-sidebar.tsx +++ b/components/navigation-sidebar.tsx @@ -8,7 +8,6 @@ import { ChevronLeft, ChevronRight, DollarSign, - Github, Globe, Info, Loader2, @@ -21,8 +20,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useAuthPrompt } from "@/components/auth/provider"; import { DiscordIcon } from "@/components/icons/discord-icon"; +import { GettingStartedLauncher } from "@/components/onboarding/getting-started-launcher"; import { AddressBookOverlay } from "@/components/overlays/address-book-overlay"; -import { FeedbackOverlay } from "@/components/overlays/feedback-overlay"; import { useOverlay } from "@/components/overlays/overlay-provider"; import { Tooltip, @@ -963,6 +962,8 @@ export function NavigationSidebar(): React.ReactNode { ))} + +
{( [ @@ -1007,42 +1008,6 @@ export function NavigationSidebar(): React.ReactNode { ); })} - {(() => { - // Feedback widget is disabled by default. Re-enable by setting - // NEXT_PUBLIC_FEEDBACK_ENABLED=true (the POST /api/feedback route - // gates on the same flag). - if (process.env.NEXT_PUBLIC_FEEDBACK_ENABLED !== "true") { - return null; - } - const reportButton = ( - - ); - - if (showLabels) { - return reportButton; - } - - return ( - - {reportButton} - Report an issue - - ); - })()}
{/* Resize handle */} diff --git a/components/onboarding/editor-walkthrough.tsx b/components/onboarding/editor-walkthrough.tsx new file mode 100644 index 0000000000..b5ea238b2f --- /dev/null +++ b/components/onboarding/editor-walkthrough.tsx @@ -0,0 +1,610 @@ +"use client"; + +import "driver.js/dist/driver.css"; +import type { Alignment, Driver, Side } from "driver.js"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect, useRef } from "react"; +import { toast } from "sonner"; +import { useOverlay } from "@/components/overlays/overlay-provider"; +import { api } from "@/lib/api-client"; +import { authClient, useSession } from "@/lib/auth-client"; +import { isAnonymousUser } from "@/lib/is-anonymous"; +import { runCreateWalletPath } from "@/lib/onboarding/paths/create-wallet"; +import { toursDisabled } from "@/lib/onboarding/tours-disabled"; +import { waitForAnchor } from "@/lib/onboarding/wait-for-anchor"; +import { + centerNodeAtom, + currentWorkflowIdAtom, + currentWorkflowNameAtom, + editorTourRequestedAtom, + isExecutingAtom, + nodesAtom, + propertiesPanelActiveTabAtom, + selectedNodeAtom, + updateNodeDataAtom, +} from "@/lib/workflow/store"; + +const SEEN_KEY = "keeperhub-editor-tour-seen"; +const TOUR_WORKFLOW_NAME = "Workflow Editor Tour"; +// Sonner toast id for the "preparing the tour" loader shown during the async +// startup (wallet provisioning, anchor waits, driver.js dynamic import). +const TOUR_TOAST = "kh-tour-loading"; +// The action grid stores an action's registry id (computeActionId = plugin/slug) +// in config.actionType, NOT its human label - so we match on the id here while +// the popover copy still says "Get Native Token Balance". +const BALANCE_ACTION = "web3/check-balance"; +// A funded public mainnet address, so the run is green even when the user has +// no wallet of their own. +const FALLBACK_ADDRESS = "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045"; +const WALLET_ANCHOR = '[data-testid="wallet-toolbar-address"]'; + +type StepSnapshot = { + selectedActionType: string | undefined; + selectedNetwork: string | undefined; + selectedAddress: string | undefined; + isExecuting: boolean; +}; + +// "next": manual Next button. "auto": advances on an atom signal (isComplete). +// "dom-auto": advances when a DOM condition holds (isCompleteDom), e.g. a +// collapsible group has expanded. "finish": last step. +type StepMode = "next" | "auto" | "dom-auto" | "finish"; + +type StepDef = { + id: string; + selector: string; + mode: StepMode; + isComplete?: (snap: StepSnapshot) => boolean; + isCompleteDom?: () => boolean; + popover: { title: string; description: string; side: Side; align: Alignment }; +}; + +const ORIENT_STEP: StepDef = { + id: "orient", + selector: ".react-flow__node-trigger", + mode: "next", + popover: { + title: "Workflow Editor Tour", + description: + "Let's build a workflow that checks a wallet's balance, then run it. Every workflow is a Trigger (the when) wired to an Action (the what).", + side: "right", + align: "start", + }, +}; + +const WALLET_STEP: StepDef = { + id: "wallet", + selector: WALLET_ANCHOR, + mode: "next", + popover: { + title: "Your wallet", + description: + "This is your wallet, and its address is saved in your address book. We'll check its balance in the next step.", + side: "bottom", + align: "end", + }, +}; + +const BALANCE_OPTION_SELECTOR = `[data-testid="action-option-${BALANCE_ACTION.toLowerCase()}"]`; + +// 1) Orient the user to the whole action list (all the integrations). +const ACTIONS_INTRO_STEP: StepDef = { + id: "actions-intro", + selector: '[data-testid="action-grid"]', + mode: "next", + popover: { + title: "Your connections", + description: + "These are the integrations you can act on, like Web3, Aave and Lido. Each one groups the actions it supports. Expand a group to see its actions, then click one to add it to this step.", + side: "left", + align: "start", + }, +}; + +// 2) Highlight only the Web3 group; advance once the user expands it (its +// actions, including the target, appear in the DOM). +const OPEN_WEB3_STEP: StepDef = { + id: "open-web3", + selector: '[data-testid="action-group-web3"]', + mode: "dom-auto", + isCompleteDom: () => Boolean(document.querySelector(BALANCE_OPTION_SELECTOR)), + popover: { + title: "Open the Web3 group", + description: "Click Web3 to expand it and reveal its actions.", + side: "left", + align: "start", + }, +}; + +// 3) Highlight the specific action; advance once it is added to the step. +const PICK_BALANCE_STEP: StepDef = { + id: "pick-balance", + selector: BALANCE_OPTION_SELECTOR, + mode: "auto", + isComplete: (snap) => snap.selectedActionType === BALANCE_ACTION, + popover: { + title: "Add the action", + description: + "Click Get Native Token Balance to add it to your step. It reads an on-chain balance.", + side: "left", + align: "start", + }, +}; + +// Guide the user to fill the two required fields themselves, so they learn what +// the step needs, instead of silently pre-filling them. +const SELECT_NETWORK_STEP: StepDef = { + id: "select-network", + selector: "#network", + mode: "auto", + isComplete: (snap) => Boolean(snap.selectedNetwork), + popover: { + title: "Pick a network", + description: + "Choose the chain to read the balance on. Ethereum Mainnet is a good default for this step.", + side: "left", + align: "start", + }, +}; + +const FILL_ADDRESS_STEP: StepDef = { + id: "fill-address", + selector: "#address", + mode: "auto", + isComplete: (snap) => Boolean(snap.selectedAddress), + popover: { + title: "Add a wallet address", + description: + "Paste the wallet to check. Copy your own address with the copy button next to the wallet in the top bar, then paste it here.", + side: "left", + align: "start", + }, +}; + +const RUN_STEP: StepDef = { + id: "run", + selector: '[data-tour="workflow-run"]', + mode: "auto", + isComplete: (snap) => snap.isExecuting, + popover: { + title: "Run it", + description: "Click Run to check the balance live on-chain.", + side: "bottom", + align: "end", + }, +}; + +const RESULTS_STEP: StepDef = { + id: "results", + selector: '[data-testid="properties-panel"]', + mode: "finish", + popover: { + title: "You ran your first workflow", + description: + "Green means success - the Runs tab here shows the balance and each step's output. That's the core loop: build, run, iterate.", + side: "left", + align: "start", + }, +}; + +function buildSteps(hasWallet: boolean): StepDef[] { + const steps = [ORIENT_STEP]; + if (hasWallet) { + steps.push(WALLET_STEP); + } + steps.push( + ACTIONS_INTRO_STEP, + OPEN_WEB3_STEP, + PICK_BALANCE_STEP, + SELECT_NETWORK_STEP, + FILL_ADDRESS_STEP, + RUN_STEP, + RESULTS_STEP + ); + return steps; +} + +function markSeen(): void { + try { + localStorage.setItem(SEEN_KEY, "true"); + } catch { + // Storage unavailable; nothing to persist. + } +} + +function buttonsFor(mode: StepMode): Array<"next" | "close"> { + // Steps that advance programmatically (atom or DOM signal) only offer a way + // out (Skip); manual steps also get Next. + return mode === "auto" || mode === "dom-auto" ? ["close"] : ["next", "close"]; +} + +// Resolve the address to check: the user's first saved address book entry (their +// wallet) when present, otherwise a funded public address so the run still goes +// green. Returns the address plus whether the wallet toolbar is on screen. +async function resolveTourContext( + signal: AbortSignal +): Promise<{ address: string; hasWallet: boolean }> { + const hasWallet = Boolean(document.querySelector(WALLET_ANCHOR)); + let address = FALLBACK_ADDRESS; + try { + const response = await fetch("/api/address-book", { + credentials: "same-origin", + signal, + }); + if (response.ok) { + const entries: unknown = await response.json(); + const first = Array.isArray(entries) ? entries[0] : undefined; + const entryAddress = (first as { address?: unknown } | undefined) + ?.address; + if (typeof entryAddress === "string" && entryAddress.length > 0) { + address = entryAddress; + } + } + } catch { + // Address book unreachable; keep the fallback address. + } + return { address, hasWallet }; +} + +// Only org admins/owners can provision a wallet (matches the overlay's gate). +// "organization: update" is granted to admin + owner in the access control, so +// it stands in for isAdmin without mounting a global useActiveOrganization hook +// (which would 401 for anonymous visitors). Fetched lazily inside the launch. +async function canCreateWallet(): Promise { + try { + const result = await authClient.organization.hasPermission({ + permissions: { organization: ["update"] }, + }); + const typed = result as { + data?: { success?: boolean }; + success?: boolean; + } | null; + return Boolean(typed?.data?.success ?? typed?.success); + } catch { + return false; + } +} + +/** + * Opt-in interactive walkthrough of the workflow editor. Launched by the + * "Take a tour" button (which sets editorTourRequestedAtom and creates a fresh + * default workflow). Drives driver.js step-by-step, advancing each action step + * when the user actually performs it - observed read-only via the workflow + * store, plus a few scripted writes (name the workflow, pre-fill the balance + * network/address, open the Runs tab) so the first run is guaranteed green. + * Signed-in only; anonymous users can't execute, so they get the sign-in card. + */ +export function EditorWalkthrough(): null { + const { data: session } = useSession(); + const requested = useAtomValue(editorTourRequestedAtom); + const setRequested = useSetAtom(editorTourRequestedAtom); + const nodes = useAtomValue(nodesAtom); + const selectedNodeId = useAtomValue(selectedNodeAtom); + const isExecuting = useAtomValue(isExecutingAtom); + const currentWorkflowId = useAtomValue(currentWorkflowIdAtom); + const setNodeData = useSetAtom(updateNodeDataAtom); + const setWorkflowName = useSetAtom(currentWorkflowNameAtom); + const setActiveTab = useSetAtom(propertiesPanelActiveTabAtom); + const setCenterNode = useSetAtom(centerNodeAtom); + const { closeAll } = useOverlay(); + + const driverRef = useRef(null); + // Latest overlay-close fn, read inside launch without re-triggering the + // one-shot launch effect (its deps stay narrow on purpose). + const closeAllRef = useRef(closeAll); + closeAllRef.current = closeAll; + // Aborts the in-flight launch on unmount only - never on a re-render, so the + // setRequested(false) / nodes updates during startup can't cancel the tour. + const abortRef = useRef(null); + // "idle" -> "starting" (async driver load) -> "running" + const stateRef = useRef<"idle" | "starting" | "running">("idle"); + // Latest nodes for the pre-fill write without making the advance effect + // depend on the (new-every-render) nodes array. + const nodesRef = useRef(nodes); + nodesRef.current = nodes; + // The step list and resolved address are fixed once the tour starts. + const stepsRef = useRef([]); + // Advances "dom-auto" steps (e.g. waiting for the Web3 group to expand). + const domObserverRef = useRef(null); + + const selectedNode = nodes.find((node) => node.id === selectedNodeId); + const selectedConfig = selectedNode?.data.config as + | { actionType?: unknown; network?: unknown; address?: unknown } + | undefined; + const selectedActionType = + typeof selectedConfig?.actionType === "string" + ? selectedConfig.actionType + : undefined; + const selectedNetwork = + typeof selectedConfig?.network === "string" && selectedConfig.network + ? selectedConfig.network + : undefined; + const selectedAddress = + typeof selectedConfig?.address === "string" && selectedConfig.address + ? selectedConfig.address + : undefined; + + // Start the tour once a fresh default workflow is loaded and the user is + // eligible. Consumes the request so it fires exactly once. + useEffect(() => { + if (stateRef.current !== "idle" || !requested) { + return; + } + if (toursDisabled() || isAnonymousUser(session?.user)) { + setRequested(false); + return; + } + const hasTrigger = nodes.some((node) => node.data.type === "trigger"); + const hasAction = nodes.some((node) => node.data.type === "action"); + if (!(currentWorkflowId && hasTrigger && hasAction)) { + return; + } + + setRequested(false); + stateRef.current = "starting"; + + const controller = new AbortController(); + abortRef.current = controller; + + const launch = async (): Promise => { + toast.loading("Preparing your tour", { id: TOUR_TOAST }); + try { + const context = await resolveTourContext(controller.signal); + if (controller.signal.aborted) { + stateRef.current = "idle"; + return; + } + let hasWallet = context.hasWallet; + + // No wallet yet: an admin can create one via the standalone Create Wallet + // sub-path before we continue. Non-admins keep the public fallback address + // (they can't provision a wallet), so the tour never dead-ends. + if (!hasWallet && (await canCreateWallet())) { + const result = await runCreateWalletPath({ + signal: controller.signal, + closeOverlays: () => closeAllRef.current(), + }); + if (controller.signal.aborted) { + stateRef.current = "idle"; + return; + } + if (result === "done") { + hasWallet = true; + } + } + + const steps = buildSteps(hasWallet); + stepsRef.current = steps; + + // Name the tour workflow (display + persist; best-effort). + setWorkflowName(TOUR_WORKFLOW_NAME); + api.workflow + .update(currentWorkflowId, { name: TOUR_WORKFLOW_NAME }) + .catch(() => { + // Non-fatal: the tour still works with the default name. + }); + + // Center the trigger node so the opening spotlight is well-framed. + const triggerNode = nodesRef.current.find( + (node) => node.data.type === "trigger" + ); + if (triggerNode) { + setCenterNode(triggerNode.id); + } + + const anchor = await waitForAnchor( + steps[0].selector, + controller.signal + ); + if (controller.signal.aborted || !anchor) { + stateRef.current = "idle"; + return; + } + + // Let the center animation settle so the spotlight aligns to the node. + await new Promise((resolve) => setTimeout(resolve, 600)); + if (controller.signal.aborted) { + stateRef.current = "idle"; + return; + } + + const { driver } = await import("driver.js"); + if (controller.signal.aborted) { + stateRef.current = "idle"; + return; + } + + const instance = driver({ + allowClose: true, + showProgress: false, + doneBtnText: "Finish", + nextBtnText: "Next", + onDestroyed: () => { + driverRef.current = null; + domObserverRef.current?.disconnect(); + domObserverRef.current = null; + stateRef.current = "idle"; + markSeen(); + }, + onHighlighted: () => { + // If a dom-auto step's condition already holds on entry (the group + // was already expanded), advance without waiting for a mutation. + const active = driverRef.current; + const index = active?.getActiveIndex(); + if (active && index !== undefined) { + const current = stepsRef.current[index]; + if (current?.mode === "dom-auto" && current.isCompleteDom?.()) { + setTimeout(() => driverRef.current?.moveNext(), 0); + } + } + }, + steps: steps.map((step) => ({ + element: step.selector, + popover: { + title: step.popover.title, + description: step.popover.description, + side: step.popover.side, + align: step.popover.align, + showButtons: buttonsFor(step.mode), + }, + })), + }); + + driverRef.current = instance; + stateRef.current = "running"; + instance.drive(); + + // Advance "dom-auto" steps off DOM changes (atom signals don't fire for + // a collapsible group expanding). The active step is re-read each time. + domObserverRef.current?.disconnect(); + const observer = new MutationObserver(() => { + const active = driverRef.current; + if (!active) { + return; + } + const index = active.getActiveIndex(); + if (index === undefined) { + return; + } + const current = stepsRef.current[index]; + if (current?.mode === "dom-auto" && current.isCompleteDom?.()) { + active.moveNext(); + } + }); + observer.observe(document.body, { childList: true, subtree: true }); + domObserverRef.current = observer; + } finally { + toast.dismiss(TOUR_TOAST); + } + }; + + launch().catch(() => { + stateRef.current = "idle"; + }); + // No cleanup here on purpose: the launch must survive the re-render that + // setRequested(false) triggers (and frequent nodes updates). It is torn + // down only on unmount, below. + }, [ + requested, + session, + nodes, + currentWorkflowId, + setRequested, + setWorkflowName, + setCenterNode, + ]); + + // Advance action steps when the user completes them. The successor of every + // auto step is a non-auto step, so this can't double-advance. + useEffect(() => { + const instance = driverRef.current; + if (stateRef.current !== "running" || !instance) { + return; + } + const steps = stepsRef.current; + const index = instance.getActiveIndex(); + if (index === undefined) { + return; + } + const step = steps[index]; + + // Recovery: on the pick step, adding a different action unmounts the grid + // and breaks the spotlight, leaving the tour stuck. Clear the wrong action + // so the grid returns, nudge the user to the right one, and re-anchor. + if ( + step?.id === "pick-balance" && + selectedActionType && + selectedActionType !== BALANCE_ACTION + ) { + const wrong = nodesRef.current.find( + (node) => + node.data.type === "action" && + node.data.config?.actionType === selectedActionType + ); + if (wrong) { + const config = (wrong.data.config ?? {}) as Record; + setNodeData({ + id: wrong.id, + data: { config: { ...config, actionType: "" } }, + }); + setActiveTab("properties"); + toast.message("Pick Get Native Token Balance to continue the tour.", { + id: "kh-tour-pick-hint", + }); + setTimeout(() => driverRef.current?.refresh(), 120); + } + return; + } + + if (step?.mode !== "auto" || !step.isComplete) { + return; + } + if ( + !step.isComplete({ + selectedActionType, + selectedNetwork, + selectedAddress, + isExecuting, + }) + ) { + return; + } + + const nextId = steps[index + 1]?.id; + + // Entering the network field step: open the Properties tab so the Network / + // Address fields the cards point at are on screen. The balance action is + // already selected from the pick step, so its config is what the panel + // shows. Do NOT pan the canvas here: these steps point at the right-hand + // panel, and a canvas pan drags the spotlight onto the node instead. + if (nextId === "select-network") { + setActiveTab("properties"); + } + + // Entering results: surface the run output in the Runs tab. + if (nextId === "results") { + setActiveTab("runs"); + } + + const nextStep = steps[index + 1]; + + // The next step's target can render a tick later (the config form, with the + // Network / Address fields, appears just after the Properties tab is shown). + // Advancing before it exists makes the driver float a detached popover, so + // wait for the element and only then move on -- the current card stays put + // until its successor has something real to point at. + const signal = abortRef.current?.signal; + if (nextStep && signal && !document.querySelector(nextStep.selector)) { + waitForAnchor(nextStep.selector, signal) + .then((anchor) => { + if (anchor) { + driverRef.current?.moveNext(); + } + }) + .catch(() => { + driverRef.current?.moveNext(); + }); + return; + } + + instance.moveNext(); + }, [ + selectedActionType, + selectedNetwork, + selectedAddress, + isExecuting, + setNodeData, + setActiveTab, + ]); + + // Tear down a running tour if the controller unmounts. + useEffect(() => { + return () => { + abortRef.current?.abort(); + domObserverRef.current?.disconnect(); + driverRef.current?.destroy(); + }; + }, []); + + return null; +} diff --git a/components/onboarding/getting-started-checklist.tsx b/components/onboarding/getting-started-checklist.tsx deleted file mode 100644 index 922d5b683d..0000000000 --- a/components/onboarding/getting-started-checklist.tsx +++ /dev/null @@ -1,338 +0,0 @@ -"use client"; - -import { - Check, - ChevronDown, - ChevronUp, - Info, - KeyRound, - Wallet, - Workflow, - X, -} from "lucide-react"; -import { useRef } from "react"; -import { AuthDialog } from "@/components/auth/dialog"; -import { ApiKeysOverlay } from "@/components/overlays/api-keys-overlay"; -import { useOverlay } from "@/components/overlays/overlay-provider"; -import { WalletOverlay } from "@/components/overlays/wallet-overlay"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import { useSession } from "@/lib/auth-client"; -import { useOnboardingStatus } from "@/lib/hooks/use-onboarding-status"; -import { isAnonymousUser } from "@/lib/is-anonymous"; -import { cn } from "@/lib/utils"; - -type StepConfig = { - id: string; - icon: typeof Workflow; - title: string; - extra?: React.ReactNode; - requiresAuth: boolean; - action: () => void; -}; - -type GettingStartedChecklistProps = { - onCreateWorkflow?: () => void; -}; - -function ProgressBar({ - completed, - total, -}: { - completed: number; - total: number; -}): React.ReactElement { - const percentage = total > 0 ? (completed / total) * 100 : 0; - - return ( -
-
-
- ); -} - -type StepRowProps = { - config: StepConfig; - isComplete: boolean; - onAction: () => void; -}; - -function StepRow({ - config, - isComplete, - onAction, -}: StepRowProps): React.ReactElement { - const Icon = config.icon; - - const handleClick = (e: React.MouseEvent): void => { - if ((e.target as Element).closest("[data-checklist-tooltip]")) { - return; - } - onAction(); - }; - - return ( - - ); -} - -function SkeletonRows(): React.ReactElement { - return ( -
- {Array.from({ length: 3 }, (_, i) => ( -
-
-
-
- ))} -
- ); -} - -const ICON_BTN = - "rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"; - -function DismissButton({ - onClick, -}: { - onClick: () => void; -}): React.ReactElement { - return ( - - ); -} - -export function GettingStartedChecklist({ - onCreateWorkflow, -}: GettingStartedChecklistProps): React.ReactElement | null { - const { - steps, - isLoading, - completedCount, - guideState, - collapse, - expand, - dismiss, - refetch, - } = useOnboardingStatus(); - const { open: openOverlay } = useOverlay(); - const { data: session } = useSession(); - const authTriggerRef = useRef(null); - - const isAnonymous = isAnonymousUser(session?.user); - - const docsLink = ( -

- Need help?{" "} - - View docs - -

- ); - - if (guideState === "dismissed") { - return
{docsLink}
; - } - - const promptSignIn = (): void => { - authTriggerRef.current?.click(); - }; - - const stepConfigs: StepConfig[] = [ - { - id: "create-workflow", - icon: Workflow, - title: "Create a Workflow", - requiresAuth: false, - action: () => onCreateWorkflow?.(), - }, - { - id: "generate-api-key", - icon: KeyRound, - title: "Generate API Key", - requiresAuth: true, - extra: ( - - - - - - - Connect KeeperHub to your AI tools.{" "} - - Learn more - - - - - ), - action: () => - openOverlay(ApiKeysOverlay, undefined, { onClose: refetch }), - }, - { - id: "create-wallet", - icon: Wallet, - title: "Create Wallet", - requiresAuth: true, - extra: ( - - - - - - - Non-custodial wallet for signing blockchain transactions. Choose - from multiple providers.{" "} - - Learn more - - - - - ), - action: () => openOverlay(WalletOverlay, undefined, { onClose: refetch }), - }, - ]; - - if (guideState === "collapsed") { - return ( -
-
- Setup Guide - - {completedCount}/{steps.length} - -
- - -
-
- {docsLink} -
- ); - } - - return ( -
-
- - - -
-
- -
- -
- {isLoading ? ( - - ) : ( -
- {stepConfigs.map((config) => { - const step = steps.find((s) => s.id === config.id); - const isComplete = step?.complete ?? false; - const action = - isAnonymous && config.requiresAuth - ? promptSignIn - : config.action; - - return ( - - ); - })} -
- )} -
- -
- -
- {isLoading ? ( -
- ) : ( - - )} -
-
- {docsLink} -
- ); -} diff --git a/components/onboarding/getting-started-launcher.tsx b/components/onboarding/getting-started-launcher.tsx new file mode 100644 index 0000000000..644133cee6 --- /dev/null +++ b/components/onboarding/getting-started-launcher.tsx @@ -0,0 +1,677 @@ +"use client"; + +import { useAtom, useSetAtom } from "jotai"; +import { Check, ChevronDown, Compass, Info, Sparkles, X } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { ApiKeysOverlay } from "@/components/overlays/api-keys-overlay"; +import { ConnectAgentOverlay } from "@/components/overlays/connect-agent-overlay"; +import { IntegrationsOverlay } from "@/components/overlays/integrations-overlay"; +import { useOverlay } from "@/components/overlays/overlay-provider"; +import { WalletOverlay } from "@/components/overlays/wallet-overlay"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { api } from "@/lib/api-client"; +import { + type GettingStarted, + useGettingStarted, +} from "@/lib/hooks/use-getting-started"; +import { + type Chip, + type DeepLinkTarget, + getBranches, + type Step, +} from "@/lib/onboarding/getting-started-config"; +import { refetchSidebar } from "@/lib/refetch-sidebar"; +import { cn } from "@/lib/utils"; +import { + editorTourRequestedAtom, + gettingStartedOpenAtom, + pendingAiPromptAtom, +} from "@/lib/workflow/store"; + +const SUPPRESSED_PATHS = new Set([ + "/verify-mfa", + "/enroll-mfa", + "/enforce-mfa", + "/verify-ip", +]); + +// AI workflow generation is gated by this flag and is off in prod + staging. +const AI_ENABLED = process.env.NEXT_PUBLIC_AI_PROMPT_ENABLED === "true"; + +function ProgressRing({ + done, + total, +}: { + done: number; + total: number; +}): React.ReactElement { + const r = 8; + const c = 2 * Math.PI * r; + const pct = total > 0 ? done / total : 0; + return ( + + ); +} + +function StepCheck({ complete }: { complete: boolean }): React.ReactElement { + return ( + + {complete && + ); +} + +function StepRow({ + step, + complete, + locked, + isChipCloned, + onAction, + onChip, + onTour, + onInfo, +}: { + step: Step; + complete: boolean; + locked?: boolean; + isChipCloned: (chip: Chip) => boolean; + onAction: (step: Step) => void; + onChip: (step: Step, chip: Chip) => void; + onTour: (step: Step) => void; + onInfo: (step: Step) => void; +}): React.ReactElement { + const clickable = Boolean(step.action) && !step.muted && !locked; + const body = ( +
+
+ {step.title} +
+

{step.description}

+
+ ); + + return ( +
+
+ {clickable && step.action ? ( + + ) : ( +
+ + {body} +
+ )} + +
+ {step.chips && !locked && ( +
+ {step.chips.map((chip) => { + const cloned = isChipCloned(chip); + return ( + + ); + })} +
+ )} + {step.offerTour && !locked && ( +
+ +
+ )} +
+ ); +} + +function StepInfoDialog({ + step, + creditLabel, + onAction, + onTour, + onClose, +}: { + step: Step | null; + creditLabel: string; + onAction: (step: Step) => void; + onTour: (step: Step) => void; + onClose: () => void; +}): React.ReactElement { + const action = step?.action; + const fill = (text: string): string => + text.replaceAll("{credit}", creditLabel); + return ( + !next && onClose()} open={step !== null}> + + + {step?.title} + + {step ? fill(step.info.summary) : ""} + + + {step ? ( +
+ {step.info.sections.map((section) => ( +
+

+ {section.heading} +

+
    + {section.points.map((point) => ( +
  • +
  • + ))} +
+
+ ))} +
+ ) : null} + {step && action && step.actionLabel ? ( + + {step.offerTour ? ( + + ) : null} + + + ) : null} +
+
+ ); +} + +function ExpandedCard({ + gs, + creditLabel, + onAction, + onChip, + onTour, +}: { + gs: GettingStarted; + creditLabel: string; + onAction: (step: Step) => void; + onChip: (step: Step, chip: Chip) => void; + onTour: (step: Step) => void; +}): React.ReactElement { + const [infoStep, setInfoStep] = useState(null); + const branches = getBranches({ resolvedIds: gs.recommendedIds }); + // Single linear checklist: the agent branch (Wallet ready -> Connect your + // agent -> Run your first workflow). Monitor / Yield are no longer surfaced. + const steps = (branches.find((b) => b.key === "agent") ?? branches[0]).steps; + const total = steps.length; + const done = steps.filter((s) => gs.isStepComplete(s)).length; + + return ( + // Grow in height (and scale in from the pill corner) on open; shrink height + // and width back toward the pill on close. overflow-hidden clips the rows as + // the height animates; the card's own shadow is not clipped by it. + +
+
+ Get started + + {done} of {total} + +
+
+ + +
+
+ +
+ {steps.map((step, idx) => { + const locked = steps.slice(0, idx).some((s) => !gs.isStepComplete(s)); + return ( + + gs.hasLiveStepWorkflow(`${step.key}:${chip.id}`) + } + key={step.key} + locked={locked} + onAction={onAction} + onChip={onChip} + onInfo={setInfoStep} + onTour={onTour} + step={step} + /> + ); + })} +
+ + setInfoStep(null)} + onTour={onTour} + step={infoStep} + /> +
+ ); +} + +export function GettingStartedLauncher({ + compact = false, +}: { + // Icon-only pill for a collapsed sidebar. The label + count show otherwise. + compact?: boolean; +} = {}): React.ReactElement | null { + const gs = useGettingStarted(); + const gsRef = useRef(gs); + gsRef.current = gs; + const pathname = usePathname(); + const router = useRouter(); + const { open } = useOverlay(); + const [, setPendingAiPrompt] = useAtom(pendingAiPromptAtom); + const [forceOpen, setForceOpen] = useAtom(gettingStartedOpenAtom); + const requestTour = useSetAtom(editorTourRequestedAtom); + const [creditLabel, setCreditLabel] = useState("$1"); + + // The user-menu "Getting started" entry flips this to reopen the launcher. + // gsRef avoids including the unstable `gs` object in deps. + useEffect(() => { + if (forceOpen) { + gsRef.current.setState("expanded"); + setForceOpen(false); + } + }, [forceOpen, setForceOpen]); + + // Fetch the env-driven free gas sponsorship amount for the wallet info copy. + useEffect(() => { + let cancelled = false; + fetch("/api/gas-sponsorship") + .then((r) => r.json()) + .then((data: { label?: string }) => { + if (!cancelled && data?.label) { + setCreditLabel(data.label); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, []); + + const path = pathname ?? ""; + if ( + !gs.isAuthenticated || + SUPPRESSED_PATHS.has(path) || + path.startsWith("/welcome") + ) { + return null; + } + if (gs.state === "dismissed") { + return null; + } + + const openDeepLink = (target: DeepLinkTarget): void => { + if (target === "api-keys") { + open(ApiKeysOverlay); + } else if (target === "connect-agent") { + open(ConnectAgentOverlay, undefined, { size: "2xl" }); + } else if (target === "integrations") { + open(IntegrationsOverlay); + } else { + open(WalletOverlay); + } + }; + + // AI generation is gated by NEXT_PUBLIC_AI_PROMPT_ENABLED and is OFF in prod + + // staging. When enabled, seed the prompt so the builder auto-generates; when + // not, just open a fresh builder with the prompt kept as the description so + // the user still has the context of what they set out to build. + // + // The workflow is created once per (step, prompt) and remembered: taking the + // step again reopens that same draft instead of spawning another Untitled + // Workflow. If the user deleted it, a fresh one is created. + // Open the step's draft workflow: reuse the one created for this (step, + // prompt) if it still exists, otherwise create a fresh trigger+action draft. + // In tour mode it requests the guided editor tour instead of seeding the AI + // prompt, so the tour runs on the same draft. + const startStepWorkflow = async ( + step: Step, + prompt: string, + opts?: { tour?: boolean } + ): Promise => { + const key = `${step.key}:${prompt}`; + let id = gs.getStepWorkflowId(key); + if (id) { + try { + await api.workflow.getById(id); + } catch { + id = undefined; + } + } + if (!id) { + try { + const stamp = Date.now(); + const triggerId = `trigger-${stamp}`; + const actionId = `action-${stamp}`; + const workflow = await api.workflow.create({ + name: "Untitled Workflow", + description: prompt, + nodes: [ + { + id: triggerId, + type: "trigger" as const, + position: { x: 400, y: 200 }, + data: { + label: "", + type: "trigger" as const, + config: { triggerType: "Manual" }, + status: "idle" as const, + }, + }, + { + id: actionId, + type: "action" as const, + position: { x: 672, y: 200 }, + data: { + label: "", + type: "action" as const, + config: {}, + status: "idle" as const, + }, + }, + ], + edges: [ + { + id: `edge-${stamp}`, + source: triggerId, + target: actionId, + type: "animated", + }, + ], + }); + id = workflow.id; + gs.setStepWorkflowId(key, id); + // Surface the freshly created workflow in the sidebar list immediately. + refetchSidebar(); + } catch { + toast.error("Could not start a workflow."); + return; + } + } + if (opts?.tour) { + requestTour(true); + } else if (AI_ENABLED) { + setPendingAiPrompt(prompt); + } + router.push(`/workflows/${id}`); + }; + + // Taking a step's action opens the relevant surface. Outcome steps only + // complete once the real state changes (refetched here and on focus); the + // informational "open your wallet" steps complete on open via markStepActioned. + const onAction = (step: Step): void => { + gs.markStepActioned(step); + const { action } = step; + if (action?.kind === "deeplink") { + openDeepLink(action.target); + } else if (action?.kind === "ai-prompt") { + void startStepWorkflow(step, action.prompt); + } + gs.refetch(); + }; + + // Clone a curated public HUB workflow into the user's org, ONCE per chip. + // If this chip was already cloned and that copy still exists, reopen it + // instead of cloning again (otherwise every click spawns another copy). The + // clone is the step's completion -- the user configures it after in the builder. + const cloneStarterWorkflow = async ( + step: Step, + chip: Chip + ): Promise => { + if (!chip.workflowId) { + return; + } + const key = `${step.key}:${chip.id}`; + let id = gs.getStepWorkflowId(key); + if (id) { + try { + await api.workflow.getById(id); + } catch { + // The earlier clone was deleted; fall through and re-clone. + id = undefined; + } + } + if (!id) { + try { + const workflow = await api.workflow.duplicate(chip.workflowId); + id = workflow.id; + gs.setStepWorkflowId(key, id); + // The new clone won't appear in the sidebar list until it refetches. + refetchSidebar(); + } catch (error) { + console.error( + `[GettingStarted] Failed to clone starter workflow ${chip.workflowId}`, + error + ); + toast.error( + error instanceof Error + ? error.message + : "Could not add that workflow." + ); + return; + } + } + // The step/chip mark themselves complete by deriving from this live clone + // (see hasLiveStepWorkflow / isStepComplete) -- no separate latch to clobber. + router.push(`/workflows/${id}`); + }; + + // Chips with a configured starter workflow clone it; otherwise fall back to + // seeding the AI builder with the chip's preset prompt. + const onChip = (step: Step, chip: Chip): void => { + gs.markStepActioned(step); + if (chip.workflowId) { + void cloneStarterWorkflow(step, chip); + } else { + void startStepWorkflow(step, chip.prompt); + } + }; + + // "Take a guided tour": open the step's draft and launch the editor tour, + // which walks the user through building and running the workflow. + const onTour = (step: Step): void => { + gs.markStepActioned(step); + const prompt = step.action?.kind === "ai-prompt" ? step.action.prompt : ""; + void startStepWorkflow(step, prompt, { tour: true }); + }; + + // Lives in the sidebar, just above the Discord/Documentation footer. The pill + // sits in-flow; the expanded card floats up from it (bottom-full) and bleeds + // to the right over the canvas, which the sidebar does not clip. + const expanded = gs.state === "expanded"; + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ +
+ ); +} + +function launcherTotal(gs: GettingStarted): number { + const branch = getBranches({ resolvedIds: gs.recommendedIds }).find( + (b) => b.key === "agent" + ); + return branch?.steps.length ?? 0; +} + +function launcherDone(gs: GettingStarted): number { + const branch = getBranches({ resolvedIds: gs.recommendedIds }).find( + (b) => b.key === "agent" + ); + if (!branch) { + return 0; + } + return branch.steps.filter((s) => gs.isStepComplete(s)).length; +} diff --git a/components/onboarding/signin-tour-driver.tsx b/components/onboarding/signin-tour-driver.tsx new file mode 100644 index 0000000000..749b2f5e28 --- /dev/null +++ b/components/onboarding/signin-tour-driver.tsx @@ -0,0 +1,96 @@ +"use client"; + +import "driver.js/dist/driver.css"; +import { useEffect, useRef } from "react"; +import { useSession } from "@/lib/auth-client"; +import { isNewUserSession } from "@/lib/is-anonymous"; +import { toursDisabled } from "@/lib/onboarding/tours-disabled"; +import { waitForAnchor } from "@/lib/onboarding/wait-for-anchor"; + +const SEEN_KEY = "keeperhub-signin-tour-driver-seen"; +const ANCHOR_SELECTOR = '[data-tour="signin-button"]'; + +function hasSeenTour(): boolean { + try { + return localStorage.getItem(SEEN_KEY) === "true"; + } catch { + // Storage blocked (Safari private mode); treat as not seen. + return false; + } +} + +function markSeen(): void { + try { + localStorage.setItem(SEEN_KEY, "true"); + } catch { + // Storage unavailable; the tour may simply reappear next visit. + } +} + +/** + * One-card sign-in tour (driver.js, MIT). Shows once per browser for new + * (anonymous/unverified) visitors, anchored on the Sign In button. + */ +export function SignInTourDriver(): null { + const { data: session, isPending } = useSession(); + const startedRef = useRef(false); + const tourRef = useRef<{ destroy: () => void } | null>(null); + + useEffect(() => { + if (isPending || startedRef.current) { + return; + } + if (toursDisabled() || !isNewUserSession(session) || hasSeenTour()) { + return; + } + + startedRef.current = true; + const controller = new AbortController(); + + const startTour = async (): Promise => { + const anchor = await waitForAnchor(ANCHOR_SELECTOR, controller.signal); + if (!anchor || controller.signal.aborted) { + return; + } + + const { driver } = await import("driver.js"); + if (controller.signal.aborted) { + return; + } + + const tour = driver({ + showProgress: false, + showButtons: ["next"], + doneBtnText: "Got it", + allowClose: true, + onDestroyed: () => markSeen(), + steps: [ + { + element: anchor, + popover: { + title: "Sign in to get started", + description: + "Create an account to save your workflows and unlock the full hub.", + side: "bottom", + align: "end", + }, + }, + ], + }); + tourRef.current = tour; + tour.drive(); + }; + + startTour().catch(() => { + // driver.js failed to load or start; skip the tour silently. + }); + + return () => { + controller.abort(); + tourRef.current?.destroy(); + tourRef.current = null; + }; + }, [session, isPending]); + + return null; +} diff --git a/components/organization/invite-modal.tsx b/components/organization/invite-modal.tsx index 207f96c349..d09b394e99 100644 --- a/components/organization/invite-modal.tsx +++ b/components/organization/invite-modal.tsx @@ -1,8 +1,9 @@ "use client"; -import { Copy, Mail, UserPlus } from "lucide-react"; +import { Copy, Mail, UserPlus, Wallet } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; +import { isAddress } from "viem"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -23,19 +24,67 @@ import { SelectValue, } from "@/components/ui/select"; import { authClient } from "@/lib/auth-client"; +import { useOrganization } from "@/lib/hooks/use-organization"; + +type InviteMode = "email" | "wallet"; export function InviteModal() { const [open, setOpen] = useState(false); + const [mode, setMode] = useState("email"); const [email, setEmail] = useState(""); + const [address, setAddress] = useState(""); const [role, setRole] = useState<"member" | "admin">("member"); const [loading, setLoading] = useState(false); const [inviteId, setInviteId] = useState(null); + const { organization } = useOrganization(); + + // Wallet invitees have no inbox; resolve the sign-in address to that account's + // synthetic email, then invite by email so the existing flow is reused. + const resolveWalletEmail = async (): Promise => { + if (!organization?.id) { + toast.error("Select an organization first."); + return null; + } + if (!isAddress(address.trim())) { + toast.error("Enter a valid wallet address."); + return null; + } + const res = await fetch( + `/api/organizations/${organization.id}/wallet-lookup?address=${address.trim()}`, + { cache: "no-store" } + ); + const data = (await res.json().catch(() => ({}))) as { + found?: boolean; + email?: string; + alreadyMember?: boolean; + error?: string; + }; + if (!res.ok) { + toast.error(data.error ?? "Lookup failed."); + return null; + } + if (!(data.found && data.email)) { + toast.error("No KeeperHub account signs in with that wallet yet."); + return null; + } + if (data.alreadyMember) { + toast.error("That wallet is already a member of this organization."); + return null; + } + return data.email; + }; const handleInvite = async () => { setLoading(true); try { + const targetEmail = + mode === "wallet" ? await resolveWalletEmail() : email.trim(); + if (!targetEmail) { + return; + } + const { data, error } = await authClient.organization.inviteMember({ - email, + email: targetEmail, role, }); @@ -52,8 +101,13 @@ export function InviteModal() { const invitationId = invitationData?.id || invitationData?.invitation?.id; if (invitationId) { setInviteId(invitationId); - toast.success(`Invitation sent to ${email}`); + toast.success( + mode === "wallet" + ? "Invitation created. Share the link so they can sign to join." + : `Invitation sent to ${targetEmail}` + ); setEmail(""); + setAddress(""); } else { toast.error("Invitation created but ID not returned"); } @@ -97,17 +151,54 @@ export function InviteModal() {
-
- - setEmail(e.target.value)} - placeholder="colleague@example.com" - type="email" - value={email} - /> +
+ +
+ {mode === "email" ? ( +
+ + setEmail(e.target.value)} + placeholder="colleague@example.com" + type="email" + value={email} + /> +
+ ) : ( +
+ + setAddress(e.target.value)} + placeholder="0x..." + value={address} + /> +

+ The address they sign in with. They will sign a challenge to + join. +

+
+ )}
{ - setInviteEmail(e.target.value); - if (inviteId) { - setInviteId(null); - } - }} - placeholder="colleague@example.com" - type="email" - value={inviteEmail} - /> + + +
+
+ {inviteMode === "email" ? ( + { + setInviteEmail(e.target.value); + if (inviteId) { + setInviteId(null); + } + }} + placeholder="colleague@example.com" + type="email" + value={inviteEmail} + /> + ) : ( + { + setInviteAddress(e.target.value); + if (inviteId) { + setInviteId(null); + } + }} + placeholder="0x... (their sign-in wallet)" + value={inviteAddress} + /> + )} +
+ ); + } + + return ( +
+
+
+ +

+ Members must carry a second factor while this organization is their + active context. Wallet members are prompted to enroll before they + can continue. +

+
+ +
+ + {enforce && ( +
+

+ Required factors (members must add every one selected): +

+ {FACTOR_OPTIONS.map((option) => ( +
+
+ +

{option.hint}

+
+ + toggleFactor(option.factor, checked) + } + /> +
+ ))} +
+ )} + + {canEdit ? ( +
+ +
+ ) : ( +

+ Only the organization owner can change this setting. +

+ )} +
+ ); +} diff --git a/components/overlays/api-keys-overlay.tsx b/components/overlays/api-keys-overlay.tsx index e8c13d3b9a..77ee50883a 100644 --- a/components/overlays/api-keys-overlay.tsx +++ b/components/overlays/api-keys-overlay.tsx @@ -11,8 +11,13 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { isWalletEmail } from "@/lib/auth/wallet-constants"; import { useSession } from "@/lib/auth-client"; import { handleGuardError } from "@/lib/client/handle-guard-error"; +import { + runWalletStepUp, + signStepUpChallenge, +} from "@/lib/wallet/step-up-client"; import { usePaginatedResource } from "@/lib/hooks/use-paginated-resource"; import { useActiveMember } from "@/lib/hooks/use-organization"; import type { Page, PageMeta } from "@/lib/pagination"; @@ -22,6 +27,7 @@ import { ConfirmOverlay } from "./confirm-overlay"; import { KeyActivityOverlay } from "./key-activity-overlay"; import { Overlay } from "./overlay"; import { useOverlay } from "./overlay-provider"; +import type { OverlayComponentProps } from "./types"; type ApiKey = { id: string; @@ -36,13 +42,15 @@ type ApiKey = { key?: string; }; -type ApiKeysOverlayProps = { - overlayId: string; +type ApiKeysOverlayProps = OverlayComponentProps<{ // Highlight + scroll to this key (e.g. opened from the activity feed). The // resource type selects which tab opens: org keys vs personal/user keys. highlightId?: string; highlightType?: "api_key" | "org_api_key"; -}; + // Fires once, with the full secret, right after a key is created. Lets a + // caller (e.g. the onboarding connect-agent step) reflect the new key. + onKeyCreated?: (key: string, type: "webhook" | "organisation") => void; +}>; const SCOPE_LABELS: Record = { "mcp:read": "Read your workflows, executions, and plugin schemas", @@ -66,6 +74,8 @@ function CreateApiKeyOverlay({ keyType: "webhook" | "organisation"; }): React.ReactElement { const { pop } = useOverlay(); + const session = useSession(); + const isWallet = isWalletEmail(session.data?.user?.email); const [keyName, setKeyName] = useState(""); const [phase, setPhase] = useState<"label" | "codes">("label"); const dual = useDualFactorState(); @@ -96,6 +106,36 @@ function CreateApiKeyOverlay({ body: JSON.stringify({ name: keyName.trim() || null, scopes: activeScopes }), }); + // Wallet users confirm by signing instead of entering TOTP + email codes. + const handleWalletCreate = async (): Promise => { + setCreating(true); + try { + const response = await runWalletStepUp((extra) => + fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: keyName.trim() || null, ...extra }), + }) + ); + if (!response.ok) { + const data = (await response.json().catch(() => ({}))) as { + error?: string; + }; + toast.error(data.error || "Failed to create API key"); + return; + } + onCreated(await response.json()); + toast.success("API key created successfully"); + pop(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to create API key" + ); + } finally { + setCreating(false); + } + }; + const handleCreate = async (): Promise => { setCreating(true); try { @@ -174,11 +214,17 @@ function CreateApiKeyOverlay({ return ( setPhase("codes"), - disabled: !keyName.trim() || !hasScopeSelected, - }, + isWallet + ? { + label: "Create API key", + onClick: handleWalletCreate, + disabled: !keyName.trim() || creating || !hasScopeSelected, + } + : { + label: "Continue", + onClick: () => setPhase("codes"), + disabled: !keyName.trim() || !hasScopeSelected, + }, ]} overlayId={overlayId} title="Create API Key" @@ -248,14 +294,42 @@ function DeleteApiKeyOverlay({ onDelete: ( keyId: string, code: string, - emailOtp: string - ) => Promise<{ ok: true } | { ok: false; code: string }>; + emailOtp: string, + signature?: string + ) => Promise<{ ok: true } | { ok: false; code: string; challenge?: string }>; deleteEndpoint: (id: string) => string; }): React.ReactElement { const { pop } = useOverlay(); + const session = useSession(); + const isWallet = isWalletEmail(session.data?.user?.email); const dual = useDualFactorState(); const [submitting, setSubmitting] = useState(false); + // Wallet users revoke by signing the step-up challenge. + const handleWalletRevoke = async (): Promise => { + setSubmitting(true); + try { + const first = await onDelete(keyId, "", ""); + if (first.ok) { + pop(); + return; + } + if (first.code === "signature_required" && first.challenge) { + const signature = await signStepUpChallenge(first.challenge); + const second = await onDelete(keyId, "", "", signature); + if (second.ok || second.code === "guarded" || second.code === "unknown") { + pop(); + } + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to revoke key" + ); + } finally { + setSubmitting(false); + } + }; + const emptyCodesFetch = (): Promise => fetch(deleteEndpoint(keyId), { method: "DELETE", @@ -287,11 +361,34 @@ function DeleteApiKeyOverlay({ } }; + if (isWallet) { + return ( + +

+ Any integrations using this key will stop working immediately. Sign + with your wallet to confirm. +

+
+ ); + } + return (

- Any integrations using this key will stop working immediately. - Confirm with both factors below. + Any integrations using this key will stop working immediately. Confirm + with both factors below.

=> { + emailOtp: string, + signature?: string + ): Promise< + { ok: true } | { ok: false; code: string; challenge?: string } + > => { setDeleting(keyId); try { const response = await fetch(deleteEndpoint(keyId), { method: "DELETE", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ code, emailOtp: emailOtp || undefined }), + body: JSON.stringify({ + code, + emailOtp: emailOtp || undefined, + signature, + }), }); if (!response.ok) { @@ -538,13 +642,15 @@ function useApiKeys( const data = (await response.json().catch(() => ({}))) as { error?: string; code?: string; + challenge?: string; }; if ( data.code === "factors_required" || data.code === "mfa_code_invalid" || - data.code === "email_code_invalid" + data.code === "email_code_invalid" || + data.code === "signature_required" ) { - return { ok: false, code: data.code }; + return { ok: false, code: data.code, challenge: data.challenge }; } toast.error(data.error || "Failed to delete API key"); return { ok: false, code: "unknown" }; @@ -678,6 +784,7 @@ export function ApiKeysOverlay({ overlayId, highlightId, highlightType, + onKeyCreated, }: ApiKeysOverlayProps) { const { push, closeAll } = useOverlay(); const { isAdmin, role } = useActiveMember(); @@ -723,7 +830,15 @@ export function ApiKeysOverlay({ disabled: activeTab === "organisation" && !canManageOrgKeys, onClick: () => push(CreateApiKeyOverlay, { - onCreated: currentKeys.handleKeyCreated, + onCreated: (key: ApiKey) => { + currentKeys.handleKeyCreated(key); + if (key.key) { + onKeyCreated?.( + key.key, + activeTab as "webhook" | "organisation" + ); + } + }, endpoint: createEndpoint, keyType: activeTab as "webhook" | "organisation", }), diff --git a/components/overlays/connect-agent-overlay.tsx b/components/overlays/connect-agent-overlay.tsx new file mode 100644 index 0000000000..22e54f4dee --- /dev/null +++ b/components/overlays/connect-agent-overlay.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { Boxes, Sparkles } from "lucide-react"; +import { type ComponentType, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { + ClaudeCodeIcon, + ClaudeIcon, + EveIcon, + GeminiIcon, + GooseIcon, + HermesIcon, + OpenAIIcon, + OpenClawIcon, +} from "@/components/icons/agent-icons"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { CopyBlock } from "@/components/welcome/copy-block"; +import { + type AgentFramework, + getAgentFrameworks, +} from "@/lib/agent-connect-commands"; +import { Overlay } from "./overlay"; +import type { OverlayComponentProps } from "./types"; + +const TAB_ICONS: Record> = { + "claude-code": ClaudeCodeIcon, + "claude-desktop": ClaudeIcon, + "claude-plugin": ClaudeIcon, + openclaw: OpenClawIcon, + codex: OpenAIIcon, + "gemini-cli": GeminiIcon, + goose: GooseIcon, + hermes: HermesIcon, + eve: EveIcon, + other: Boxes, +}; + +const SUGGESTED_PROMPTS = [ + "Create a workflow that checks my stETH rewards every hour and alerts me on Discord when they are ready to redeem", + "Ping me on Telegram when a withdrawal over 10 ETH leaves my treasury", + "Watch my Aave v3 health factor and message me if it drops below 1.5", +]; + +// One labeled tab group (heading + tabs + per-framework setup snippet). +function FrameworkGroup({ + label, + frameworks, +}: { + label: string; + frameworks: AgentFramework[]; +}): React.ReactElement { + return ( +
+

{label}

+ + + {frameworks.map((framework) => { + const Icon = TAB_ICONS[framework.id] ?? Boxes; + return ( + + + {framework.label} + + ); + })} + + {frameworks.map((framework) => ( + + {framework.snippets.map((snippet) => ( + + ))} + {framework.note ? ( +

{framework.note}

+ ) : null} +
+ ))} +
+
+ ); +} + +/** + * In-app "Connect your agent" modal: the MCP endpoint, per-framework setup + * commands (grouped into Agents and Plugins), and a few starter prompts. Every + * client signs in through the browser (OAuth), so no API key is minted here. + */ +export function ConnectAgentOverlay({ + overlayId, +}: OverlayComponentProps): React.ReactElement { + const [mcpUrl, setMcpUrl] = useState( + process.env.NEXT_PUBLIC_APP_URL + ? `${process.env.NEXT_PUBLIC_APP_URL}/mcp` + : "" + ); + + useEffect(() => { + if (!mcpUrl) { + setMcpUrl(`${window.location.origin}/mcp`); + } + }, [mcpUrl]); + + const frameworks = getAgentFrameworks(mcpUrl || "/mcp"); + const agents = frameworks.filter((f) => f.group === "mcp"); + const plugins = frameworks.filter((f) => f.group === "plugin"); + + const copyPrompt = (prompt: string): void => { + navigator.clipboard + .writeText(prompt) + .then(() => toast.success("Copied to clipboard")) + .catch(() => undefined); + }; + + return ( + +
+
+

MCP endpoint

+ +
+ + + + +
+

+ + Try a prompt +

+ {SUGGESTED_PROMPTS.map((prompt) => ( + + ))} +
+
+
+ ); +} diff --git a/components/overlays/settings-overlay.tsx b/components/overlays/settings-overlay.tsx index f307c6b2f9..fe5674bac9 100644 --- a/components/overlays/settings-overlay.tsx +++ b/components/overlays/settings-overlay.tsx @@ -7,6 +7,7 @@ import { ActiveSessionsSection } from "@/components/settings/active-sessions-sec import { ChangePasswordSection } from "@/components/settings/change-password-section"; import { DeactivateAccountSection } from "@/components/settings/delete-account-section"; import { TwoFactorSection } from "@/components/settings/two-factor-section"; +import { WalletSecuritySection } from "@/components/settings/wallet-security-section"; import { Spinner } from "@/components/ui/spinner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { api } from "@/lib/api-client"; @@ -168,8 +169,14 @@ export function SettingsOverlay({ - - + {providerId === "siwe" ? ( + + ) : ( + <> + + + + )} diff --git a/components/overlays/wallet/no-wallet-section.tsx b/components/overlays/wallet/no-wallet-section.tsx index e104d910a6..a500f817d8 100644 --- a/components/overlays/wallet/no-wallet-section.tsx +++ b/components/overlays/wallet/no-wallet-section.tsx @@ -36,7 +36,7 @@ function CreateWalletForm({ }; return ( -
+

This wallet will be shared by all members of your organization. Only admins and owners can manage it. diff --git a/components/overlays/withdraw-modal.tsx b/components/overlays/withdraw-modal.tsx index 91c31f3721..0fbf9ddbd0 100644 --- a/components/overlays/withdraw-modal.tsx +++ b/components/overlays/withdraw-modal.tsx @@ -9,9 +9,11 @@ import { DualFactorSteps } from "@/components/auth/dual-factor-steps"; import { Overlay } from "@/components/overlays/overlay"; import { useOverlay } from "@/components/overlays/overlay-provider"; import { SettingsOverlay } from "@/components/overlays/settings-overlay"; +import { isWalletEmail } from "@/lib/auth/wallet-constants"; import { useSession } from "@/lib/auth-client"; import { handleGuardError } from "@/lib/client/handle-guard-error"; import { useDualFactorState } from "@/lib/mfa/use-dual-factor-state"; +import { runWalletStepUp } from "@/lib/wallet/step-up-client"; import { useActiveMember } from "@/lib/hooks/use-organization"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -89,6 +91,8 @@ export function WithdrawModal({ | undefined; const isOwner = role === "owner"; const mfaEnrolled = sessionUser?.twoFactorEnabled === true; + // Wallet accounts confirm by signing; they don't enroll TOTP. + const isWallet = isWalletEmail(session.data?.user?.email); const [selectedAssetIndex, setSelectedAssetIndex] = useState(initialAssetIndex); @@ -304,7 +308,7 @@ export function WithdrawModal({ } return; } - if (!mfaEnrolled) { + if (!(mfaEnrolled || isWallet)) { setState("needs-mfa"); return; } @@ -321,11 +325,11 @@ export function WithdrawModal({ } return; } - if (dual.totpCode.trim().length !== 6) { + if (!isWallet && dual.totpCode.trim().length !== 6) { toast.error("Enter the 6-digit code from your authenticator"); return; } - if (dual.awaitingEmailOtp && dual.emailOtp.trim().length !== 6) { + if (!isWallet && dual.awaitingEmailOtp && dual.emailOtp.trim().length !== 6) { toast.error("Enter the 6-digit code we emailed to you"); return; } @@ -342,20 +346,29 @@ export function WithdrawModal({ maxReserveApplied && selectedAsset.type === "native"; try { - const response = await fetch("/api/user/wallet/withdraw", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - chainId: selectedAsset.chainId, - tokenAddress: selectedAsset.tokenAddress, - amount: useServerMax ? undefined : amount, - recipient, - fromMax: useServerMax, - safeId: source.kind === "safe" ? source.safeId : undefined, - code: dual.totpCode.trim(), - emailOtp: dual.emailOtp.trim() || undefined, - }), - }); + const baseBody = { + chainId: selectedAsset.chainId, + tokenAddress: selectedAsset.tokenAddress, + amount: useServerMax ? undefined : amount, + recipient, + fromMax: useServerMax, + safeId: source.kind === "safe" ? source.safeId : undefined, + }; + const withdrawFetch = ( + extra: Record + ): Promise => + fetch("/api/user/wallet/withdraw", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...baseBody, ...extra }), + }); + // Wallet users sign the step-up challenge; everyone else submits codes. + const response = isWallet + ? await runWalletStepUp(withdrawFetch) + : await withdrawFetch({ + code: dual.totpCode.trim(), + emailOtp: dual.emailOtp.trim() || undefined, + }); if (!response.ok) { const guarded = await handleGuardError(response, { @@ -535,6 +548,38 @@ export function WithdrawModal({ safeId: source.kind === "safe" ? source.safeId : undefined, }), }); + if (isWallet) { + return ( + setState("input"), + variant: "outline", + }, + { + label: "Sign to withdraw", + onClick: handleSubmit, + variant: "destructive", + }, + ]} + overlayId={overlayId} + title="Confirm withdrawal" + > +

+ Confirm sending{" "} + + {amount} {selectedAsset?.symbol} + {" "} + to{" "} + + {truncateAddress(recipient)} + + . Sign with your wallet to continue. +

+ + ); + } return ( (null); const dual = useDualFactorState(); + const session = useSession(); + const isWallet = isWalletEmail(session.data?.user?.email); useEffect(() => { if (!copiedRowId) { @@ -141,6 +146,37 @@ export function ActiveSessionsSection(): React.ReactElement { body: JSON.stringify({}), }); + // Wallet users confirm the revoke by signing the step-up challenge. + const handleWalletRevoke = async (): Promise => { + if (!revokeTarget || busy) { + return; + } + setBusy(true); + try { + const res = await runWalletStepUp((extra) => + fetch(`/api/user/sessions/${revokeTarget.id}/revoke`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(extra), + }) + ); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + toast.error(data.error ?? "Failed to revoke session"); + return; + } + toast.success("Session revoked"); + closeDialog(); + await load(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to revoke session" + ); + } finally { + setBusy(false); + } + }; + return ( @@ -148,8 +184,10 @@ export function ActiveSessionsSection(): React.ReactElement {

Active sessions

Every device signed in to this account. Revoke any session you - don't recognise. Revoking requires a code from your email and - your authenticator. + don't recognise.{" "} + {isWallet + ? "Revoking requires a signature from your wallet." + : "Revoking requires a code from your email and your authenticator."}

@@ -241,11 +279,26 @@ export function ActiveSessionsSection(): React.ReactElement { Revoke this session {revokeTarget - ? `Sign out ${describeUserAgent(revokeTarget.userAgent).label} (${revokeTarget.ipAddress ?? "unknown IP"}). Confirm with both factors to continue.` + ? `Sign out ${describeUserAgent(revokeTarget.userAgent).label} (${revokeTarget.ipAddress ?? "unknown IP"}). ${isWallet ? "Sign with your wallet to continue." : "Confirm with both factors to continue."}` : null} - {revokeTarget && ( + {revokeTarget && isWallet && ( +
+ + +
+ )} + {revokeTarget && !isWallet && ( => diff --git a/components/settings/totp-setup-dialog.tsx b/components/settings/totp-setup-dialog.tsx index 2025461cfb..1081b52980 100644 --- a/components/settings/totp-setup-dialog.tsx +++ b/components/settings/totp-setup-dialog.tsx @@ -2,6 +2,7 @@ import { Copy } from "lucide-react"; import { useEffect, useState } from "react"; +import { flushSync } from "react-dom"; import { toast } from "sonner"; import { TotpBackupCodesPanel } from "@/components/settings/totp-backup-codes-panel"; import { TotpQr } from "@/components/settings/totp-qr"; @@ -104,6 +105,7 @@ export function TotpSetupDialog({ const [backupCodes, setBackupCodes] = useState(null); const [busy, setBusy] = useState(false); const [keyJustCopied, setKeyJustCopied] = useState(false); + const [didEnroll, setDidEnroll] = useState(false); useEffect(() => { if (!open || setupData) { @@ -151,6 +153,7 @@ export function TotpSetupDialog({ setCode(""); setBackupCodes(null); setBusy(false); + setDidEnroll(false); }; const closeAndReset = (): void => { @@ -176,7 +179,7 @@ export function TotpSetupDialog({ const data = (await response.json()) as EnrollResponse; setBackupCodes(data.backupCodes); setPhase("codes"); - onEnrolled(); + setDidEnroll(true); } finally { setBusy(false); } @@ -205,6 +208,13 @@ export function TotpSetupDialog({ const handleDone = (): void => { toast.success("Two-factor authentication is enabled"); + // flushSync ensures the parent's setEnrolled(true) is applied before + // closeAndReset calls onOpenChange(false). Without it, React batches the + // update and the parent's handleOpenChange sees enrolled=false (stale + // closure) and refuses to close the dialog. + flushSync(() => { + onEnrolled(); + }); closeAndReset(); }; @@ -212,6 +222,13 @@ export function TotpSetupDialog({ { if (!next) { + // If the user enrolled but closed via X instead of the Done button, + // still fire onEnrolled so the parent updates its state. + if (didEnroll) { + flushSync(() => { + onEnrolled(); + }); + } closeAndReset(); return; } @@ -316,7 +333,7 @@ export function TotpSetupDialog({ className="bg-keeperhub-green text-foreground hover:bg-keeperhub-green-dark dark:text-background" onClick={handleDone} > - Skip + Done
diff --git a/components/settings/wallet-security-section.tsx b/components/settings/wallet-security-section.tsx new file mode 100644 index 0000000000..fdc385018a --- /dev/null +++ b/components/settings/wallet-security-section.tsx @@ -0,0 +1,316 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { toast } from "sonner"; +import { TotpSetupDialog } from "@/components/settings/totp-setup-dialog"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { Switch } from "@/components/ui/switch"; +import { + runWalletStepUp, + type StepUpExtra, +} from "@/lib/wallet/step-up-client"; + +type Enrolled = { wallet: boolean; totp: boolean; email: boolean }; + +type EnrollmentResponse = { + walletUser: boolean; + enrolled: Enrolled; +}; + +async function readError(response: Response): Promise { + const data = (await response.json().catch(() => ({}))) as { error?: string }; + return data.error ?? "Something went wrong."; +} + +function AddEmailDialog({ + open, + onOpenChange, + onAdded, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onAdded: () => void; +}): React.ReactElement { + const [email, setEmail] = useState(""); + const [code, setCode] = useState(""); + const [phase, setPhase] = useState<"email" | "code">("email"); + const [loading, setLoading] = useState(false); + + const handleOpenChange = (next: boolean): void => { + if (!next) { + setEmail(""); + setCode(""); + setPhase("email"); + } + onOpenChange(next); + }; + + const send = async (body: Record): Promise => + fetch("/api/user/step-up/email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const requestCode = async (): Promise => { + setLoading(true); + try { + const res = await send({ email: email.trim() }); + if (!res.ok) { + toast.error(await readError(res)); + return; + } + setPhase("code"); + toast.success("Verification code sent."); + } finally { + setLoading(false); + } + }; + + const verify = async (): Promise => { + setLoading(true); + try { + const res = await send({ email: email.trim(), code: code.trim() }); + if (!res.ok) { + toast.error(await readError(res)); + return; + } + toast.success("Email added."); + onAdded(); + handleOpenChange(false); + } finally { + setLoading(false); + } + }; + + return ( + + + + Add a step-up email + + {phase === "email" + ? "We'll send a code to confirm this inbox." + : `Enter the code we sent to ${email}.`} + + + {phase === "email" ? ( +
{ + e.preventDefault(); + requestCode(); + }} + > + setEmail(e.target.value)} + placeholder="you@example.com" + required + type="email" + value={email} + /> + +
+ ) : ( +
{ + e.preventDefault(); + verify(); + }} + > + setCode(e.target.value)} + placeholder="123456" + value={code} + /> + +
+ )} +
+
+ ); +} + +export function WalletSecuritySection(): React.ReactElement { + const [data, setData] = useState(null); + const [loadError, setLoadError] = useState(false); + const [busy, setBusy] = useState(false); + const [setupOpen, setSetupOpen] = useState(false); + const [addEmailOpen, setAddEmailOpen] = useState(false); + + const load = useCallback(async () => { + setLoadError(false); + try { + const res = await fetch("/api/user/step-up/policy"); + if (res.ok) { + setData((await res.json()) as EnrollmentResponse); + } else { + setLoadError(true); + } + } catch { + setLoadError(true); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const disableEmail = async (): Promise => { + setBusy(true); + try { + const res = await runWalletStepUp((extra: StepUpExtra) => + fetch("/api/user/step-up/email", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(extra), + }) + ); + if (!res.ok) { + toast.error(await readError(res)); + return; + } + toast.success("Step-up email removed."); + await load(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Action failed"); + } finally { + setBusy(false); + } + }; + + const disableTotp = async (): Promise => { + setBusy(true); + try { + const res = await runWalletStepUp((extra: StepUpExtra) => + fetch("/api/user/totp/disable", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(extra), + }) + ); + if (!res.ok) { + toast.error(await readError(res)); + return; + } + toast.success("Authenticator disabled."); + await load(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Action failed"); + } finally { + setBusy(false); + } + }; + + if (!data) { + if (loadError) { + return ( +
+ Failed to load security settings.{" "} + +
+ ); + } + return ( +
+ +
+ ); + } + + return ( +
+
+

Extra confirmation factors

+

+ Your wallet signature confirms every sensitive action. Add an + authenticator or email for extra protection -- once enabled, we ask + for it on every sensitive action. +

+
+ + + +
+

Authenticator (TOTP)

+

+ {data.enrolled.totp ? "Enrolled" : "Not set up"} +

+
+ { + if (next) { + setSetupOpen(true); + } else { + disableTotp(); + } + }} + /> +
+
+ + + +
+

Email code

+

+ {data.enrolled.email ? "Verified email on file" : "Not added"} +

+
+ { + if (next) { + setAddEmailOpen(true); + } else { + disableEmail(); + } + }} + /> +
+
+ + { + setSetupOpen(next); + if (!next) { + load(); + } + }} + open={setupOpen} + /> + +
+ ); +} diff --git a/components/support/contact-support-dialog.tsx b/components/support/contact-support-dialog.tsx new file mode 100644 index 0000000000..8050c0a17e --- /dev/null +++ b/components/support/contact-support-dialog.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { ExternalLink } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Overlay } from "@/components/overlays/overlay"; +import { useOverlay } from "@/components/overlays/overlay-provider"; +import type { OverlayComponentProps } from "@/components/overlays/types"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { SOCIAL_LINKS, SUPPORT_EMAIL } from "@/lib/social-links"; + +const CATEGORIES = [ + "Getting started", + "Wallet", + "Workflows", + "Billing", + "Other", +] as const; + +const LINK_CLASS = + "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"; + +/** + * Contact-support surface (KEEP-869): a short message form that posts to the + * existing `/api/feedback` route plus the community channels, so a stuck user + * can always reach a human. Opened as an overlay from the user menu and the + * getting-started launcher. + */ +export function ContactSupportDialog({ + overlayId, +}: OverlayComponentProps): React.ReactElement { + const { closeAll } = useOverlay(); + const [message, setMessage] = useState(""); + const [category, setCategory] = useState(""); + const [sending, setSending] = useState(false); + + const submit = async (): Promise => { + const trimmed = message.trim(); + if (!trimmed) { + toast.error("Please enter a message."); + return; + } + setSending(true); + try { + const formData = new FormData(); + formData.append("message", trimmed); + if (category) { + formData.append("categories", category); + } + const res = await fetch("/api/feedback", { + method: "POST", + body: formData, + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + toast.error( + data.error ?? "Could not send your message. Try a channel below." + ); + return; + } + toast.success("Message sent. We'll get back to you."); + closeAll(); + } finally { + setSending(false); + } + }; + + return ( + +
+
+
+ +