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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* @vitest-environment jsdom */

import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { SignInCard } from "./AccountPage";

const beginLogin = vi.fn(async () => undefined);

vi.mock("../../lib/accountLogin", () => ({
useAccountLogin: () => ({
phase: "idle",
error: null,
beginLogin,
cancel: vi.fn(),
}),
}));

describe("AccountPage signed-out card", () => {
afterEach(() => {
cleanup();
beginLogin.mockClear();
});

it("presents one truthful browser action with combined account language", () => {
render(<SignInCard configured onSignedIn={vi.fn()} />);

expect(screen.getByText("Continue to ADE")).toBeTruthy();
expect(screen.getByText(/account is created automatically/i)).toBeTruthy();
expect(screen.getByText(/browser page offers the sign-in methods enabled for ADE/i)).toBeTruthy();
expect(screen.queryByRole("button", { name: /Continue with/i })).toBeNull();

fireEvent.click(screen.getByRole("button", { name: "Continue in browser" }));
expect(beginLogin).toHaveBeenCalledTimes(1);
});

it("keeps local pairing explicitly available without an account", () => {
render(<SignInCard configured={false} onSignedIn={vi.fn()} />);

expect(screen.getByText(/Account access isn't set up on this machine yet/i)).toBeTruthy();
expect(screen.getByText("Local pairing works without an account.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Continue in browser" }).hasAttribute("disabled")).toBe(true);
});
});
51 changes: 9 additions & 42 deletions apps/desktop/src/renderer/components/account/AccountPage.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
AppleLogo,
ArrowRight,
CircleNotch,
DesktopTower,
DeviceMobile,
EnvelopeSimple,
GithubLogo,
GoogleLogo,
Laptop,
ShieldCheck,
SignOut,
Expand Down Expand Up @@ -98,7 +95,7 @@ const sectionLabelStyle: CSSProperties = {
// Signed-out: the rich sign-in card.
// ---------------------------------------------------------------------------

function SignInCard({
export function SignInCard({
configured,
onSignedIn,
}: {
Expand All @@ -110,21 +107,15 @@ function SignInCard({
});
const busy = phase === "starting" || phase === "awaiting";

const secondary: Array<{ label: string; icon: JSX.Element }> = [
{ label: "Email", icon: <EnvelopeSimple size={15} weight="regular" /> },
{ label: "Apple", icon: <AppleLogo size={15} weight="fill" /> },
{ label: "Google", icon: <GoogleLogo size={15} weight="bold" /> },
];

return (
<div style={cardStyle({ padding: 28, maxWidth: 440, width: "100%" })}>
<div style={{ display: "flex", flexDirection: "column", gap: 6, textAlign: "center", alignItems: "center" }}>
<img src="./logo.png" alt="ADE" style={{ height: 26, opacity: 0.95 }} draggable={false} />
<div style={{ fontFamily: SANS_FONT, fontSize: 19, fontWeight: 700, color: COLORS.textPrimary, marginTop: 6 }}>
Sign in to ADE
Continue to ADE
</div>
<div style={{ fontFamily: SANS_FONT, fontSize: 13, lineHeight: 1.5, color: COLORS.textSecondary, maxWidth: 320 }}>
One identity for your machines, mobile, and web sessions — so they find each other wherever you are.
Choose a sign-in method in your browser. If you're new, your ADE account is created automatically.
</div>
</div>

Expand All @@ -147,7 +138,7 @@ function SignInCard({
>
<WarningCircle size={16} weight="fill" color={COLORS.warning} style={{ flexShrink: 0, marginTop: 1 }} />
<span>
Account sign-in isn't set up on this machine yet. You can still pair machines, phones, and web
Account access isn't set up on this machine yet. You can still pair machines, phones, and web
clients from <strong style={{ color: COLORS.textPrimary }}>Connections</strong>.
</span>
</div>
Expand All @@ -170,35 +161,11 @@ function SignInCard({
}),
}}
>
{busy ? <CircleNotch size={16} weight="bold" className="animate-spin" /> : <GithubLogo size={17} weight="fill" />}
Continue with GitHub
{busy ? <CircleNotch size={16} weight="bold" className="animate-spin" /> : <ArrowRight size={17} weight="bold" />}
Continue in browser
</button>

<div style={{ display: "flex", alignItems: "center", gap: 10, color: COLORS.textDim }}>
<div style={{ flex: 1, height: 1, background: COLORS.borderMuted }} />
<span style={{ fontFamily: SANS_FONT, fontSize: 11 }}>or continue with</span>
<div style={{ flex: 1, height: 1, background: COLORS.borderMuted }} />
</div>

<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
{secondary.map((option) => (
<button
key={option.label}
type="button"
disabled={busy || !configured}
onClick={() => void beginLogin()}
style={outlineButton({
height: 38,
gap: 6,
fontSize: 12,
opacity: busy || !configured ? 0.55 : 1,
cursor: busy || !configured ? "not-allowed" : "pointer",
})}
>
{option.icon}
{option.label}
</button>
))}
<div style={{ fontFamily: SANS_FONT, fontSize: 12, lineHeight: 1.5, textAlign: "center", color: COLORS.textMuted }}>
The browser page offers the sign-in methods enabled for ADE.
</div>
</div>

Expand All @@ -218,7 +185,7 @@ function SignInCard({
>
<span style={{ display: "flex", alignItems: "center", gap: 8, fontFamily: SANS_FONT, fontSize: 12, color: COLORS.textSecondary }}>
<CircleNotch size={14} weight="bold" className="animate-spin" />
Finish signing in in your browser…
Finish in your browser…
</span>
<button
type="button"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/components/app/TabNav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ describe("TabNav", () => {
</MemoryRouter>,
);

expect(screen.getByRole("link", { name: "Sign in to ADE" }).getAttribute("href")).toBe(
expect(screen.getByRole("link", { name: "Continue to ADE" }).getAttribute("href")).toBe(
"/account",
);
});
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/components/app/TabNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null })
accountStatus.name?.trim() ||
accountStatus.email?.trim() ||
githubLogin ||
(accountStatus.signedIn ? "Account" : "Sign in");
(accountStatus.signedIn ? "Account" : "Continue");
const accountRouteActive =
location.pathname === "/account" || location.pathname.startsWith("/account/");

Expand Down Expand Up @@ -333,15 +333,15 @@ export function TabNav({ githubStatus }: { githubStatus?: GitHubStatus | null })
</SmartTooltip>

{/* Account avatar — provider-aware image → monogram, routes to /account.
Always present so sign-in stays discoverable from the sidebar. */}
Always present so account access stays discoverable from the sidebar. */}
<NavLink
to="/account"
data-active={accountRouteActive ? "true" : undefined}
className="ade-shell-sidebar-item group relative flex w-full cursor-pointer items-center transition-colors duration-100"
aria-label={
accountStatus.signedIn
? `ADE account — ${accountLabel}`
: "Sign in to ADE"
: "Continue to ADE"
}
title={accountLabel}
>
Expand Down
8 changes: 8 additions & 0 deletions apps/ios/ADE.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@
AD0000000000000000000B02 /* AccountDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0000000000000000000A02 /* AccountDirectory.swift */; };
AD0000000000000000000B03 /* AccountSignInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0000000000000000000A03 /* AccountSignInView.swift */; };
AD0000000000000000000B04 /* AccountConnectionsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0000000000000000000A04 /* AccountConnectionsSection.swift */; };
AD0000000000000000000B05 /* AccountEmailAuthFlow.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0000000000000000000A05 /* AccountEmailAuthFlow.swift */; };
AD0000000000000000000B06 /* AccountEmailAuthFlowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD0000000000000000000A06 /* AccountEmailAuthFlowTests.swift */; };
F10000000000000000000004 /* SettingsPinSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000004 /* SettingsPinSheet.swift */; };
F10000000000000000000005 /* SettingsAppearanceSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000005 /* SettingsAppearanceSection.swift */; };
F10000000000000000000006 /* SettingsDiagnosticsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = F20000000000000000000006 /* SettingsDiagnosticsSection.swift */; };
Expand Down Expand Up @@ -452,6 +454,8 @@
AD0000000000000000000A02 /* AccountDirectory.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccountDirectory.swift; path = ADE/Services/AccountDirectory.swift; sourceTree = "<group>"; };
AD0000000000000000000A03 /* AccountSignInView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccountSignInView.swift; path = ADE/Views/Account/AccountSignInView.swift; sourceTree = "<group>"; };
AD0000000000000000000A04 /* AccountConnectionsSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccountConnectionsSection.swift; path = ADE/Views/Account/AccountConnectionsSection.swift; sourceTree = "<group>"; };
AD0000000000000000000A05 /* AccountEmailAuthFlow.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccountEmailAuthFlow.swift; path = ADE/Services/AccountEmailAuthFlow.swift; sourceTree = "<group>"; };
AD0000000000000000000A06 /* AccountEmailAuthFlowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AccountEmailAuthFlowTests.swift; path = ADETests/AccountEmailAuthFlowTests.swift; sourceTree = "<group>"; };
F20000000000000000000004 /* SettingsPinSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SettingsPinSheet.swift; path = ADE/Views/Settings/SettingsPinSheet.swift; sourceTree = "<group>"; };
F20000000000000000000005 /* SettingsAppearanceSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SettingsAppearanceSection.swift; path = ADE/Views/Settings/SettingsAppearanceSection.swift; sourceTree = "<group>"; };
F20000000000000000000006 /* SettingsDiagnosticsSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SettingsDiagnosticsSection.swift; path = ADE/Views/Settings/SettingsDiagnosticsSection.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -833,6 +837,7 @@
AF00000000000000000000A2 /* PairingQrPayload.swift */,
AC1000000000000000000006 /* ClipPairingHandoff.swift */,
AD0000000000000000000A01 /* AccountService.swift */,
AD0000000000000000000A05 /* AccountEmailAuthFlow.swift */,
AD0000000000000000000A02 /* AccountDirectory.swift */,
B34C6C526E3BBB1E720C8D7C /* Dictation */,
);
Expand Down Expand Up @@ -981,6 +986,7 @@
isa = PBXGroup;
children = (
14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */,
AD0000000000000000000A06 /* AccountEmailAuthFlowTests.swift */,
B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */,
AF00000000000000000000A4 /* PairingAndDpopTests.swift */,
AC1000000000000000000008 /* ClipPairingHandoffTests.swift */,
Expand Down Expand Up @@ -1340,6 +1346,7 @@
F10000000000000000000002 /* SettingsConnectionHeader.swift in Sources */,
F10000000000000000000003 /* SettingsPairingSection.swift in Sources */,
AD0000000000000000000B01 /* AccountService.swift in Sources */,
AD0000000000000000000B05 /* AccountEmailAuthFlow.swift in Sources */,
AD0000000000000000000B02 /* AccountDirectory.swift in Sources */,
AD0000000000000000000B03 /* AccountSignInView.swift in Sources */,
AD0000000000000000000B04 /* AccountConnectionsSection.swift in Sources */,
Expand Down Expand Up @@ -1454,6 +1461,7 @@
buildActionMask = 2147483647;
files = (
7B70BE6839672E5D2D006B28 /* ADETests.swift in Sources */,
AD0000000000000000000B06 /* AccountEmailAuthFlowTests.swift in Sources */,
B70000000000000000000004 /* SyncRecoveryPolicyTests.swift in Sources */,
AF00000000000000000000C4 /* PairingAndDpopTests.swift in Sources */,
AC1100000000000000000008 /* ClipPairingHandoffTests.swift in Sources */,
Expand Down
66 changes: 66 additions & 0 deletions apps/ios/ADE/Services/AccountEmailAuthFlow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import ClerkKit
import Foundation

/// Identifies which Clerk flow owns the pending email verification code.
enum AccountEmailVerificationKind: Equatable {
case signIn
case signUp
}

/// Clerk operations used to start and finish either email authentication path.
struct AccountEmailAuthActions {
var startSignIn: @MainActor (String) async throws -> Void
var startSignUp: @MainActor (String) async throws -> Void
var verifySignIn: @MainActor (String) async throws -> Void
var verifySignUp: @MainActor (String) async throws -> Void
}

/// Routes email authentication between returning-user sign-in and precise new-user fallback.
@MainActor
enum AccountEmailAuthFlow {
private static let accountNotFoundCodes: Set<String> = [
"form_identifier_not_found",
"invitation_account_not_exists",
]

/// Returns true only for Clerk errors that explicitly mean the identifier has no account.
static func shouldBeginSignUp(after error: Error) -> Bool {
guard let clerkError = error as? ClerkAPIError else { return false }
return isAccountNotFoundCode(clerkError.code)
}

/// Classifies Clerk's exact account-not-found error codes without broadening the fallback.
static func isAccountNotFoundCode(_ code: String) -> Bool {
accountNotFoundCodes.contains(code)
}

/// Starts sign-in first, falling back to sign-up only when the supplied classifier allows it.
static func sendCode(
to email: String,
actions: AccountEmailAuthActions,
shouldBeginSignUp: @MainActor (Error) -> Bool = AccountEmailAuthFlow.shouldBeginSignUp(after:)
) async throws -> AccountEmailVerificationKind {
do {
try await actions.startSignIn(email)
return .signIn
} catch {
guard shouldBeginSignUp(error) else { throw error }
try await actions.startSignUp(email)
return .signUp
}
}

/// Verifies the code against the same Clerk flow that issued it.
static func verifyCode(
_ code: String,
kind: AccountEmailVerificationKind,
actions: AccountEmailAuthActions
) async throws {
switch kind {
case .signIn:
try await actions.verifySignIn(code)
case .signUp:
try await actions.verifySignUp(code)
}
}
}
Loading
Loading