Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
d9359a1
feat(auth): share provider sign-in interactions and credential bindings
juliusmarminge Sep 21, 2026
02b88fd
fix(auth): gate shared credential access and render interactive login…
juliusmarminge Sep 21, 2026
f783e34
fix(auth): isolate sign-in drafts and drain shared credential processes
juliusmarminge Sep 21, 2026
d7d2629
test(auth): block provider sessions during shared credential changes
juliusmarminge Sep 21, 2026
f7489c9
perf(auth): compile provider setup error guards once
juliusmarminge Sep 21, 2026
c46c546
fix(auth): align provider setup layout and account actions
juliusmarminge Sep 22, 2026
de1b888
fix(web): use settings rows for provider API keys
juliusmarminge Sep 22, 2026
e39910f
fix(auth): simplify browser sign-in status and controls
juliusmarminge Sep 22, 2026
0d86fe9
fix(auth): mask account emails until explicitly revealed
juliusmarminge Sep 22, 2026
5eddcb7
fix(web): hide untouched disabled provider defaults from settings
juliusmarminge Sep 22, 2026
2c0eee6
fix(web): remove coming-soon providers from setup picker
juliusmarminge Sep 22, 2026
dfa2e42
fix(web): label ACP registry as early access
juliusmarminge Sep 22, 2026
c98418a
fix(web): place early access providers last in setup picker
juliusmarminge Sep 22, 2026
b2fcbb0
fix(web): prefill unique identities in provider setup
juliusmarminge Sep 22, 2026
5f73564
fix(web): simplify ACP registry discovery rows
juliusmarminge Sep 22, 2026
3ac110a
fix(web): use settings layout in provider setup forms
juliusmarminge Sep 22, 2026
8a6f1cf
fix(web): widen provider wizard for horizontal settings rows
juliusmarminge Sep 22, 2026
b2f7221
fix(web): open newly added providers for sign-in
juliusmarminge Sep 22, 2026
5ef29ab
feat(web): add ACP sign-in to the provider wizard
juliusmarminge Sep 22, 2026
029d5f3
fix(web): combine provider selection with ACP registry search
juliusmarminge Sep 22, 2026
fc80d6a
fix(web): simplify ACP provider picker spacing
juliusmarminge Sep 22, 2026
b6f5b71
fix(web): move manual ACP entry beside search
juliusmarminge Sep 22, 2026
395e89c
fix(web): give ACP registry results more room
juliusmarminge Sep 22, 2026
133fb98
fix(web): remove unsupported sign-out explanation
juliusmarminge Sep 22, 2026
e97c777
fix(web): align native sessions with settings layout
juliusmarminge Sep 22, 2026
c10592b
feat(web): search ACP registry as users type
juliusmarminge Sep 22, 2026
982a0f1
fix(auth): stabilize setup discovery and offer provider docs
juliusmarminge Sep 22, 2026
9862712
fix(web): omit instance IDs from provider sidebar
juliusmarminge Sep 22, 2026
18fec4a
fix(web): make provider accents visibly opt-in
juliusmarminge Sep 22, 2026
49c4a2b
fix(auth): port shared flow and account coordination fixes to v2
juliusmarminge Sep 22, 2026
d495868
fix(web): remove icons for unavailable setup providers
juliusmarminge Sep 22, 2026
49acdf0
fix(provider-auth): keep peer sessions and methods across rebuilds
juliusmarminge Sep 22, 2026
9922edc
fix(provider-auth): copy sign-in links within the click; namespace se…
juliusmarminge Sep 22, 2026
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
6 changes: 6 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import { NewTaskRouteScreen } from "./features/threads/NewTaskRouteScreen";
import { SettingsAppearanceRouteScreen } from "./features/settings/SettingsAppearanceRouteScreen";
import { SettingsClientStorageRouteScreen } from "./features/settings/SettingsClientStorageRouteScreen";
import { SettingsDiagnosticsRouteScreen } from "./features/diagnostics/SettingsDiagnosticsRouteScreen";
import { SettingsProviderAccountsRouteScreen } from "./features/settings/SettingsProviderAccountsRouteScreen";
import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteScreen";
import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen";
import { SettingsFollowUpRouteScreen } from "./features/settings/SettingsFollowUpRouteScreen";
Expand Down Expand Up @@ -216,6 +217,11 @@ const SettingsContentStack = createNativeStackNavigator({
linking: "agent-behavior",
options: { title: "Agent behavior" },
}),
SettingsProviderAccounts: createNativeStackScreen({
screen: SettingsProviderAccountsRouteScreen,
linking: "provider-accounts",
options: { title: "Provider accounts" },
}),
SettingsEnvironmentMaintenance: createNativeStackScreen({
screen: SettingsEnvironmentMaintenanceRouteScreen,
linking: "maintenance",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
type AtomCommandResult,
} from "@t3tools/client-runtime/state/runtime";
import type { ProviderAuthResponse, ServerProvider } from "@t3tools/contracts";
import { useRef, useState } from "react";
import { Alert, Linking, Pressable, ScrollView, TextInput, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { AppText as Text } from "../../components/AppText";
import { ScreenScrollView } from "../../components/ScreenScrollView";
import { useEnvironmentQuery } from "../../state/query";
import { serverEnvironment } from "../../state/server";
import { useAtomCommand } from "../../state/use-atom-command";
import { SettingsActionRow } from "./components/SettingsActionRow";
import {
AndroidSettingsEnvironmentFilter,
SettingsEnvironmentFilterHeader,
} from "./components/SettingsEnvironmentFilterHeader";
import { SettingsScreen } from "./components/SettingsScreen";
import { SettingsSection } from "./components/SettingsSection";
import { useSettingsEnvironmentFilter, type SettingsTarget } from "./settings-environment-filter";

export function SettingsProviderAccountsRouteScreen() {
const { selectedTargets } = useSettingsEnvironmentFilter();
const insets = useSafeAreaInsets();
return (
<>
<SettingsEnvironmentFilterHeader />
<SettingsScreen title="Provider accounts" trailing={<AndroidSettingsEnvironmentFilter />}>
<ScreenScrollView
className="flex-1"
contentInsetAdjustmentBehavior="automatic"
contentContainerClassName="gap-6 px-5 pt-4"
contentContainerStyle={{ paddingBottom: Math.max(insets.bottom, 18) + 18 }}
>
{selectedTargets.length === 0 ? (
<Text className="text-foreground-muted">Select a connected environment.</Text>
) : (
selectedTargets.map((environment) => (
<SettingsSection key={environment.environmentId} title={environment.label}>
{environment.serverConfig.providers
.filter((provider) => provider.setup?.canAuthenticate)
.map((provider) => (
<ProviderAccount
key={provider.instanceId}
environment={environment}
provider={provider}
/>
))}
{!environment.serverConfig.providers.some(
(provider) => provider.setup?.canAuthenticate,
) ? (
<Text className="p-4 text-foreground-muted">
Configure a provider with in-app sign-in in web or desktop Settings.
</Text>
) : null}
</SettingsSection>
))
)}
</ScreenScrollView>
</SettingsScreen>
</>
);
}

function ProviderAccount({
environment,
provider,
}: {
readonly environment: SettingsTarget;
readonly provider: ServerProvider;
}) {
const environmentId = environment.environmentId;
const instanceId = provider.instanceId;
const target = { environmentId, input: { instanceId } };
const auth = useEnvironmentQuery(serverEnvironment.providerAuthState(target));
const options = { reportFailure: false, reportDefect: false };
const start = useAtomCommand(serverEnvironment.startProviderAuth, options);
const respond = useAtomCommand(serverEnvironment.respondProviderAuth, options);
const complete = useAtomCommand(serverEnvironment.completeProviderAuth, options);
const cancel = useAtomCommand(serverEnvironment.cancelProviderAuth, options);
const logout = useAtomCommand(serverEnvironment.logoutProviderAuth, options);
const [pending, setPending] = useState(false);
const [choosingMethod, setChoosingMethod] = useState(false);
const pendingRef = useRef(false);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState({ id: "", values: {} as Record<string, string> });
const state = auth.data;
const interaction = state?.interaction;
const draftId = `${state?.flowId ?? ""}:${interaction?.id ?? ""}`;
const values = draft.id === draftId ? draft.values : {};
const active =
state?.phase === "starting" || state?.phase === "waiting" || state?.phase === "verifying";
const signedIn =
provider.auth.status === "authenticated" ||
(provider.auth.status === "unknown" && state?.phase === "succeeded");
const url =
interaction?.type === "browser" || interaction?.type === "deviceCode"
? interaction.url
: state?.authorizationUrl;
const disabled = pending || auth.error !== null;
async function run(command: () => Promise<AtomCommandResult<unknown, unknown>>) {
if (pendingRef.current) return false;
pendingRef.current = true;
setPending(true);
setError(null);
let succeeded = false;
try {
const result = await command();
if (result._tag === "Success") succeeded = true;
else if (!isAtomCommandInterrupted(result)) {
const failure = squashAtomCommandFailure(result);
setError(failure instanceof Error ? failure.message : "Could not update provider sign-in.");
}
} catch {
setError("Could not update provider sign-in.");
}
pendingRef.current = false;
setPending(false);
return succeeded;
}
function send(response: ProviderAuthResponse) {
if (!state?.flowId || !interaction) return Promise.resolve(false);
return run(() =>
respond({
environmentId,
input: { instanceId, flowId: state.flowId!, interactionId: interaction.id, response },
}),
);
}
function field(name: string, label: string, secret: boolean) {
return (
<TextInput
accessibilityLabel={label}
className="rounded-lg border border-border-subtle px-3 py-2 text-base text-foreground"
placeholderTextColorClassName="accent-foreground-muted"
placeholder={label}
secureTextEntry={secret}
autoCapitalize="none"
autoCorrect={false}
editable={!disabled}
maxLength={secret && interaction?.type === "terminal" ? 4_095 : 16_384}
value={values[name] ?? ""}
onChangeText={(value) => setDraft({ id: draftId, values: { ...values, [name]: value } })}
/>
);
}
function chooseMethod() {
const methods = state?.methods ?? [];
if (methods.length <= 1) {
void run(() => start(target));
return;
}
setChoosingMethod(true);
}
return (
<View className="border-b border-border-subtle">
<View className="gap-2 p-4">
<Text className="text-lg font-semibold text-foreground">
{provider.displayName ?? provider.driver}
</Text>
<Text accessibilityLiveRegion="polite" className="text-sm text-foreground-muted">
{active || state?.phase === "failed" || state?.phase === "cancelled"
? state.message
: signedIn
? "Signed in."
: "Connect this provider."}
</Text>
{signedIn && !active && provider.auth.email?.trim() ? (
<ProviderAccountEmail key={provider.auth.email} email={provider.auth.email} />
) : null}
{interaction?.type === "deviceCode" ? (
<Text selectable className="text-foreground">
Enter code {interaction.userCode} on the sign-in page.
</Text>
) : null}
{interaction?.type === "terminal" ? (
<>
<ScrollView className="max-h-64" nestedScrollEnabled>
<Text selectable className="font-mono text-sm text-foreground">
{interaction.output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")}
</Text>
</ScrollView>
{field("input", "Terminal response", true)}
<SettingsActionRow
icon="arrow.up"
label="Send response"
disabled={disabled}
onPress={() => {
void send({ type: "terminal", data: `${values.input ?? ""}\r` }).then((sent) => {
if (sent) setDraft({ id: "", values: {} });
});
}}
/>
</>
) : null}
{interaction?.type === "credentials" ? (
<>
{interaction.fields.map((entry) => (
<View key={entry.name}>{field(entry.name, entry.label, entry.secret)}</View>
))}
<SettingsActionRow
icon="person.crop.circle"
label="Connect"
disabled={disabled}
onPress={() => {
void send({ type: "credentials", values }).then((sent) => {
if (sent) setDraft({ id: "", values: {} });
});
}}
/>
</>
) : null}
{url && (interaction?.type === "browser" ? interaction.acceptsCallback : !interaction) ? (
<>
{field("callback", "Final localhost URL", false)}
<SettingsActionRow
icon="arrow.right"
label="Continue"
disabled={disabled || !values.callback?.trim()}
onPress={() => {
if (!state?.flowId) return;
void run(() =>
complete({
environmentId,
input: { instanceId, flowId: state.flowId!, callbackUrl: values.callback! },
}),
).then((sent) => {
if (sent) setDraft({ id: "", values: {} });
});
}}
/>
</>
) : null}
{error || auth.error ? (
<Text accessibilityRole="alert" className="text-danger-foreground">
{error ?? auth.error}
</Text>
) : null}
</View>
{choosingMethod && !active ? (
<View>
{state?.methods?.map((method) => (
<SettingsActionRow
key={method.id}
icon="person.crop.circle"
label={method.name}
disabled={disabled}
onPress={() => {
setChoosingMethod(false);
void run(() =>
start({ environmentId, input: { instanceId, methodId: method.id } }),
);
}}
/>
))}
<SettingsActionRow icon="xmark" label="Cancel" onPress={() => setChoosingMethod(false)} />
</View>
) : null}
{url ? (
<SettingsActionRow
icon="globe"
label="Open sign-in page"
disabled={disabled}
onPress={() => {
void (async () => {
if (
interaction?.type === "browser" &&
interaction.requiresConsent &&
!(await send({ type: "browser", action: "accept" }))
)
return;
await Linking.openURL(url);
})().catch(() => setError("Could not open the sign-in page."));
}}
/>
) : null}
{active && state?.flowId ? (
<SettingsActionRow
icon="xmark"
label="Cancel sign-in"
disabled={disabled}
onPress={() => {
void run(() => cancel({ environmentId, input: { instanceId, flowId: state.flowId! } }));
}}
/>
) : !active ? (
<SettingsActionRow
icon="person.crop.circle"
label={signedIn ? "Change account" : "Sign in"}
disabled={disabled || !provider.enabled || !provider.installed || state === null}
loading={pending}
onPress={chooseMethod}
/>
) : null}
{!active && signedIn && (provider.auth.canLogout ?? provider.setup?.canAuthenticate) ? (
<SettingsActionRow
icon="person.crop.circle"
label="Sign out"
tone="danger"
disabled={disabled || state === null}
onPress={() =>
Alert.alert(
"Sign out?",
`Running threads sharing this sign-in on ${environment.label} will stop. Thread history is kept.`,
[
{ text: "Cancel", style: "cancel" },
{
text: "Sign out",
style: "destructive",
onPress: () => {
void run(() => logout(target));
},
},
],
)
}
/>
) : null}
</View>
);
}

function ProviderAccountEmail({ email }: { readonly email: string }) {
const [revealed, setRevealed] = useState(false);
return (
<Pressable
accessibilityRole="button"
accessibilityLabel={revealed ? "Hide account email" : "Reveal account email"}
onPress={() => setRevealed((value) => !value)}
className="min-h-[44px] justify-center"
>
<Text className="text-sm text-foreground-muted">{revealed ? email : "••••••@••••••"}</Text>
</Pressable>
);
}
6 changes: 6 additions & 0 deletions apps/mobile/src/features/settings/SettingsRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,12 @@ function SettingsIndexSections() {
</SettingsSection>

<SettingsSection title="Server settings">
<SettingsRow
icon="person.crop.circle"
label="Provider accounts"
target="SettingsProviderAccounts"
disabled={noServerTargets}
/>
<SettingsRow
icon="text.bubble"
label="New threads"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export type SettingsSheetTarget =
| "SettingsEnvironmentSourceControl"
| "SettingsEnvironmentAgentBehavior"
| "SettingsEnvironmentMaintenance"
| "SettingsProviderAccounts"
| "SettingsKeyboard"
| "SettingsFollowUp"
| "SettingsScheduledTasks"
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.providerAuthStart]: AuthOrchestrationOperateScope,
[WS_METHODS.providerConsumeResetCredit]: AuthOrchestrationOperateScope,
[WS_METHODS.providerAuthComplete]: AuthOrchestrationOperateScope,
[WS_METHODS.providerAuthRespond]: AuthOrchestrationOperateScope,
[WS_METHODS.providerAuthCancel]: AuthOrchestrationOperateScope,
[WS_METHODS.providerAuthLogout]: AuthOrchestrationOperateScope,
[WS_METHODS.providerAuthSubscribe]: AuthOrchestrationOperateScope,
Expand Down
Loading
Loading