Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
be1b79b
feat(auth): share provider sign-in interactions and credential bindings
juliusmarminge Sep 21, 2026
f30c688
fix(auth): gate shared credential access and render interactive login…
juliusmarminge Sep 21, 2026
85299b1
fix(auth): isolate sign-in drafts and drain shared credential processes
juliusmarminge Sep 21, 2026
5a5e1b5
test(auth): block provider sessions during shared credential changes
juliusmarminge Sep 21, 2026
d0eede3
perf(auth): compile provider setup error guards once
juliusmarminge Sep 21, 2026
64bb7a7
fix(auth): align provider setup layout and account actions
juliusmarminge Sep 22, 2026
6adf82f
fix(web): use settings rows for provider API keys
juliusmarminge Sep 22, 2026
45b1f06
fix(auth): simplify browser sign-in status and controls
juliusmarminge Sep 22, 2026
a6a88e6
fix(auth): mask account emails until explicitly revealed
juliusmarminge Sep 22, 2026
7148728
fix(web): hide untouched disabled provider defaults from settings
juliusmarminge Sep 22, 2026
08087e7
fix(web): remove coming-soon providers from setup picker
juliusmarminge Sep 22, 2026
567b0a1
fix(web): label ACP registry as early access
juliusmarminge Sep 22, 2026
c8787d8
fix(web): place early access providers last in setup picker
juliusmarminge Sep 22, 2026
2343c54
fix(web): prefill unique identities in provider setup
juliusmarminge Sep 22, 2026
1541243
fix(web): simplify ACP registry discovery rows
juliusmarminge Sep 22, 2026
0242612
fix(web): use settings layout in provider setup forms
juliusmarminge Sep 22, 2026
da81d4a
fix(web): widen provider wizard for horizontal settings rows
juliusmarminge Sep 22, 2026
f07efef
fix(web): open newly added providers for sign-in
juliusmarminge Sep 22, 2026
5158925
feat(web): add ACP sign-in to the provider wizard
juliusmarminge Sep 22, 2026
1630618
fix(web): combine provider selection with ACP registry search
juliusmarminge Sep 22, 2026
c553af2
fix(web): simplify ACP provider picker spacing
juliusmarminge Sep 22, 2026
e28dc63
fix(web): move manual ACP entry beside search
juliusmarminge Sep 22, 2026
d54b2f0
fix(web): give ACP registry results more room
juliusmarminge Sep 22, 2026
5a215f2
fix(web): remove unsupported sign-out explanation
juliusmarminge Sep 22, 2026
be85f77
fix(web): align native sessions with settings layout
juliusmarminge Sep 22, 2026
8bd3aa5
feat(web): search ACP registry as users type
juliusmarminge Sep 22, 2026
0e5db35
fix(auth): stabilize setup discovery and offer provider docs
juliusmarminge Sep 22, 2026
ca0beb4
fix(web): omit instance IDs from provider sidebar
juliusmarminge Sep 22, 2026
589a8f5
fix(web): make provider accents visibly opt-in
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