@ateam-ai-mobile/ui 0.1.9 → 0.1.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ateam-ai-mobile/ui",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Prebuilt ADAS chat experience for React Native — full-screen chat, chips, widget views. Built on @ateam-ai-mobile/core + @ateam-ai-mobile/plugin-runtime.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -9,8 +9,8 @@
9
9
  "build": "tsup"
10
10
  },
11
11
  "dependencies": {
12
- "@ateam-ai-mobile/core": "0.1.9",
13
- "@ateam-ai-mobile/plugin-runtime": "0.1.9",
12
+ "@ateam-ai-mobile/core": "0.1.10",
13
+ "@ateam-ai-mobile/plugin-runtime": "0.1.10",
14
14
  "zustand": "^5.0.0"
15
15
  },
16
16
  "peerDependencies": {
@@ -9,8 +9,6 @@
9
9
  *
10
10
  * Everything host-specific is injected:
11
11
  * - `code` the deep-link code (capture + parsing stay host-side)
12
- * - `onAdoptTenant` the host's SINGLE tenant writer, AWAITED before onSignedIn
13
- * so ACTIVE_TENANT can't drift on a cross-tenant handoff
14
12
  * - `onSignedIn` session adoption (host: authStore.login → core session +
15
13
  * the shell-only tenantConfirmed / phase gate)
16
14
  * - `onDone` clear the consumed single-use code (host store) after success
@@ -31,12 +29,6 @@ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
31
29
  export interface HandoffScreenProps {
32
30
  /** The one-time handoff code the host captured from the deep link (null = nothing pending). */
33
31
  code: string | null;
34
- /**
35
- * Adopt the handed-off tenant. AWAITED before onSignedIn so the host's active
36
- * tenant is set before the session reads it (the exchange result carries the
37
- * tenant the code was bound to). No-op is fine when the result has no tenant.
38
- */
39
- onAdoptTenant: (tenant: string) => void | Promise<void>;
40
32
  /** Adopt the session JWT (host wires its authStore.login → core session + tenantConfirmed). */
41
33
  onSignedIn: (jwt: string) => void | Promise<void>;
42
34
  /** Clear the consumed single-use code so a relaunch never retries it. Called after success. */
@@ -45,7 +37,7 @@ export interface HandoffScreenProps {
45
37
  onCancel: () => void;
46
38
  }
47
39
 
48
- export default function HandoffScreen({ code, onAdoptTenant, onSignedIn, onDone, onCancel }: HandoffScreenProps) {
40
+ export default function HandoffScreen({ code, onSignedIn, onDone, onCancel }: HandoffScreenProps) {
49
41
  const [err, setErr] = useState<string | null>(null);
50
42
  // Exchange each code AT MOST ONCE. A single-use code exchanged twice
51
43
  // (StrictMode/fast-refresh double-invoke) burns itself: the first call wins and
@@ -57,9 +49,9 @@ export default function HandoffScreen({ code, onAdoptTenant, onSignedIn, onDone,
57
49
  useEffect(() => {
58
50
  if (!code || startedForRef.current === code) return;
59
51
  startedForRef.current = code;
60
- runHandoffExchange(code, { exchange: exchangeHandoff, onAdoptTenant, onSignedIn, onDone })
52
+ runHandoffExchange(code, { exchange: exchangeHandoff, onSignedIn, onDone })
61
53
  .catch((e: any) => setErr(handoffErrorMessage(e?.message)));
62
- }, [code, onAdoptTenant, onSignedIn, onDone]);
54
+ }, [code, onSignedIn, onDone]);
63
55
 
64
56
  return (
65
57
  <View style={styles.container}>
@@ -8,9 +8,12 @@
8
8
  * client ids are baked into the SDK.
9
9
  *
10
10
  * Host-injected seams:
11
- * - activeTenant the reactive pre-auth tenant pick (re-fetches the config)
12
- * - defaultTenant the fallback for the consumer-disabled self-heal guard
13
- * - onConsumerDisabled reset to the default tenant + persist + re-trigger
11
+ * - activeTenant the reactive pre-auth team (re-fetches the config);
12
+ * '' → the screen asks for the team
13
+ * - onPickTenant the user named a team ("Use a different team", or no
14
+ * team yet): the host stores it as its signed-out pick
15
+ * - onConsumerDisabled the team has no consumer sign-in: the host clears its
16
+ * pick; the screen asks for another team
14
17
  * - onSignedIn session adoption (host: authStore.login)
15
18
  * - onJoinTeam? open the host's "join a team by name" (OpenJoin) sub-view
16
19
  *
@@ -19,20 +22,21 @@
19
22
  * to signInWithGoogle. The host wraps this in its own SafeAreaView.
20
23
  */
21
24
  import { useEffect, useMemo, useState } from 'react';
22
- import { View, Text, Pressable, StyleSheet, Platform, ActivityIndicator } from 'react-native';
25
+ import { View, Text, Pressable, StyleSheet, Platform, ActivityIndicator, TextInput } from 'react-native';
23
26
  import { fetchConsumerConfig, signInWithGoogle, signInWithApple, ada, type ConsumerCapabilityConfig } from '@ateam-ai-mobile/core';
24
27
  import { AdaOrb } from '@ateam-ai-mobile/plugin-runtime';
25
28
  import { getOAuthProvider } from '../providers';
26
- import { googleClientIds, shouldRecoverToDefaultTenant, completeProviderSignIn } from './login';
29
+ import { googleClientIds, isConsumerDisabled, completeProviderSignIn } from './login';
27
30
 
28
31
  const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
29
32
 
30
33
  export interface LoginScreenProps {
31
- /** The reactive pre-auth tenant pick (host: useSession().tenant). Re-fetches config on change. */
34
+ /** The reactive pre-auth team (host: useSession().tenant). Re-fetches config on change.
35
+ * '' → the screen asks for the team. */
32
36
  activeTenant: string;
33
- /** The fallback tenant for the consumer-disabled self-heal guard (host: DEFAULT_TENANT). */
34
- defaultTenant: string;
35
- /** Reset to the default tenant + persist + bump activeTenant so this screen refetches. */
37
+ /** The user named a team to sign in to: the host stores it as its signed-out pick. */
38
+ onPickTenant: (tenant: string) => void | Promise<void>;
39
+ /** The team has no consumer sign-in: the host clears its pick (the screen asks for another). */
36
40
  onConsumerDisabled: () => void | Promise<void>;
37
41
  /** Adopt the session JWT (host: authStore.login → core session + tenantConfirmed). */
38
42
  onSignedIn: (jwt: string) => void | Promise<void>;
@@ -40,27 +44,37 @@ export interface LoginScreenProps {
40
44
  onJoinTeam?: () => void;
41
45
  }
42
46
 
43
- export default function LoginScreen({ activeTenant, defaultTenant, onConsumerDisabled, onSignedIn, onJoinTeam }: LoginScreenProps) {
47
+ export default function LoginScreen({ activeTenant, onPickTenant, onConsumerDisabled, onSignedIn, onJoinTeam }: LoginScreenProps) {
44
48
  const [config, setConfig] = useState<ConsumerCapabilityConfig | null>(null);
45
49
  const [error, setError] = useState<string | null>(null);
50
+ // The team whose config said "no consumer sign-in", kept across the host
51
+ // clearing its pick so the ask-for-team view can say why it is back.
52
+ const [disabledTeam, setDisabledTeam] = useState<string | null>(null);
53
+ // "Use a different team" — or the current team can't sign in.
54
+ const [askTeam, setAskTeam] = useState(false);
55
+ const askingTeam = !activeTenant || askTeam;
56
+ const pick = async (tenant: string) => { setAskTeam(false); setDisabledTeam(null); await onPickTenant(tenant); };
46
57
 
47
58
  useEffect(() => {
48
59
  setConfig(null);
49
60
  setError(null);
61
+ if (!activeTenant) return;
50
62
  fetchConsumerConfig()
51
63
  .then(setConfig)
52
64
  .catch(async err => {
53
65
  const msg = err?.message || 'Failed to load sign-in config';
54
66
  // A consumer-disabled tenant 404s with "capability not enabled". Don't
55
- // dead-end on red text — the host resets to the default tenant and bumps
56
- // activeTenant, which re-runs this effect (the login screen self-heals).
57
- if (shouldRecoverToDefaultTenant(msg, activeTenant, defaultTenant)) {
67
+ // dead-end on red text — ask for another team (and the host clears its
68
+ // pick). Asking here, not re-fetching, so a disabled fallback can't loop.
69
+ if (isConsumerDisabled(msg)) {
70
+ setDisabledTeam(activeTenant);
71
+ setAskTeam(true);
58
72
  await onConsumerDisabled();
59
73
  return;
60
74
  }
61
75
  setError(msg);
62
76
  });
63
- }, [activeTenant, defaultTenant, onConsumerDisabled]);
77
+ }, [activeTenant, onConsumerDisabled]);
64
78
 
65
79
  return (
66
80
  <View style={styles.container}>
@@ -71,9 +85,15 @@ export default function LoginScreen({ activeTenant, defaultTenant, onConsumerDis
71
85
  </View>
72
86
 
73
87
  <View style={styles.buttonSection}>
74
- {!config && !error && <ActivityIndicator color={ada.ink} />}
75
- {!!error && <Text style={styles.errorText}>{error}</Text>}
76
- {!!config && <SignInButtons config={config} onSignedIn={onSignedIn} />}
88
+ {askingTeam && <TeamField notice={disabledTeam} onPick={pick} />}
89
+ {!askingTeam && !config && !error && <ActivityIndicator color={ada.ink} />}
90
+ {!askingTeam && !!error && <Text style={styles.errorText}>{error}</Text>}
91
+ {!askingTeam && !!config && <SignInButtons config={config} onSignedIn={onSignedIn} />}
92
+ {!askingTeam && (
93
+ <Pressable onPress={() => setAskTeam(true)} hitSlop={8} style={{ paddingVertical: 4 }}>
94
+ <Text style={styles.joinLink}>Use a different team</Text>
95
+ </Pressable>
96
+ )}
77
97
  {onJoinTeam && (
78
98
  <Pressable onPress={onJoinTeam} hitSlop={8} style={{ paddingVertical: 8 }}>
79
99
  <Text style={styles.joinLink}>Join a team by name</Text>
@@ -85,6 +105,33 @@ export default function LoginScreen({ activeTenant, defaultTenant, onConsumerDis
85
105
  );
86
106
  }
87
107
 
108
+ /** Ask which team to sign in to. Signing in needs the team's own consumer
109
+ * config (its client ids). */
110
+ function TeamField({ notice, onPick }: { notice: string | null; onPick: (tenant: string) => void | Promise<void> }) {
111
+ const [team, setTeam] = useState('');
112
+ const slug = team.trim().toLowerCase();
113
+ const submit = () => { if (slug) void onPick(slug); };
114
+ return (
115
+ <>
116
+ {!!notice && <Text style={styles.errorText}>“{notice}” doesn't have app sign-in. Try another team.</Text>}
117
+ <TextInput
118
+ style={styles.teamInput}
119
+ value={team}
120
+ onChangeText={setTeam}
121
+ placeholder="your team (e.g. acme)"
122
+ placeholderTextColor={ada.inkMuted}
123
+ autoCapitalize="none"
124
+ autoCorrect={false}
125
+ returnKeyType="go"
126
+ onSubmitEditing={submit}
127
+ />
128
+ <Pressable style={[styles.signInBtn, !slug && styles.signInBtnDisabled]} onPress={submit} disabled={!slug}>
129
+ <Text style={styles.signInText}>Continue</Text>
130
+ </Pressable>
131
+ </>
132
+ );
133
+ }
134
+
88
135
  function SignInButtons({ config, onSignedIn }: { config: ConsumerCapabilityConfig; onSignedIn: (jwt: string) => void | Promise<void> }) {
89
136
  const [busy, setBusy] = useState(false);
90
137
  const [error, setError] = useState<string | null>(null);
@@ -175,6 +222,10 @@ const styles = StyleSheet.create({
175
222
  signInGlyph: { fontSize: 18, fontWeight: '700', color: ada.paper, fontFamily: fontFam },
176
223
  signInText: { fontSize: 16, fontWeight: '600', color: ada.paper, letterSpacing: -0.2, fontFamily: fontFam },
177
224
  appleBtn: { width: '100%', height: 52 },
225
+ teamInput: {
226
+ width: '100%', backgroundColor: ada.paperSoft, borderWidth: 1, borderColor: ada.border, borderRadius: 14,
227
+ paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: ada.ink,
228
+ },
178
229
  errorText: { fontSize: 13, color: '#c43d3d', textAlign: 'center', paddingHorizontal: 16 },
179
230
  disclaimer: { fontSize: 12, color: ada.inkMuted, textAlign: 'center', marginTop: 8, lineHeight: 18, fontFamily: fontFam },
180
231
  joinLink: { fontSize: 14, fontWeight: '600', color: ada.orangeDeep, textAlign: 'center', fontFamily: fontFam },
@@ -9,8 +9,8 @@
9
9
  * Native id_token acquisition + the Apple HIG button come from the host-wired
10
10
  * OAuthProvider (setOAuthProvider). With no cfg passed to signInWithGoogle, the
11
11
  * provider uses the host's BUNDLED Google client id (this screen carries no
12
- * tenant config). Everything host-specific — session, the single ACTIVE_TENANT
13
- * writer, the back transition, the SafeAreaView — is injected.
12
+ * tenant config). Everything host-specific — session, the back transition, the
13
+ * SafeAreaView — is injected. The joined tenant is the JWT's own claim.
14
14
  */
15
15
  import { useEffect, useState } from 'react';
16
16
  import { View, Text, Pressable, StyleSheet, ActivityIndicator, Platform, TextInput } from 'react-native';
@@ -22,15 +22,13 @@ import { completeOpenJoin, openJoinErrorMessage } from './openJoin';
22
22
  const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
23
23
 
24
24
  export interface OpenJoinScreenProps {
25
- /** Adopt the joined tenant (host's single ACTIVE_TENANT writer), awaited before sign-in. */
26
- onAdoptTenant: (tenant: string) => void | Promise<void>;
27
25
  /** Adopt the session JWT (host: authStore.login → core session + tenantConfirmed). */
28
26
  onSignedIn: (jwt: string) => void | Promise<void>;
29
27
  /** Back to sign in — the host toggles the pre-auth phase (not a router). */
30
28
  onBack: () => void;
31
29
  }
32
30
 
33
- export default function OpenJoinScreen({ onAdoptTenant, onSignedIn, onBack }: OpenJoinScreenProps) {
31
+ export default function OpenJoinScreen({ onSignedIn, onBack }: OpenJoinScreenProps) {
34
32
  const [team, setTeam] = useState('');
35
33
  const [busy, setBusy] = useState(false);
36
34
  const [err, setErr] = useState<string | null>(null);
@@ -54,7 +52,7 @@ export default function OpenJoinScreen({ onAdoptTenant, onSignedIn, onBack }: Op
54
52
  setErr(null); setBusy(true);
55
53
  try {
56
54
  const idToken = await acquire(oauth);
57
- await completeOpenJoin(slug, idToken, { join, onAdoptTenant, onSignedIn });
55
+ await completeOpenJoin(slug, idToken, { join, onSignedIn });
58
56
  } catch (e: any) {
59
57
  setErr(openJoinErrorMessage(e?.message));
60
58
  } finally {
@@ -2,7 +2,7 @@
2
2
  * Handoff exchange — the pure, testable half of HandoffScreen (R9B).
3
3
  *
4
4
  * Kept JSX-free in its own module (like R6's runLogoutTeardown) so the
5
- * security-sensitive error mapper and the adopt-before-sign-in ORDERING can be
5
+ * security-sensitive error mapper and the sign-in-before-clear ORDERING can be
6
6
  * node-tested directly, without rendering the RN component.
7
7
  */
8
8
  import type { HandoffExchangeResult } from '@ateam-ai-mobile/core';
@@ -10,8 +10,6 @@ import type { HandoffExchangeResult } from '@ateam-ai-mobile/core';
10
10
  export interface HandoffExchangeDeps {
11
11
  /** Trade the one-time code for a session JWT (host injects core.exchangeHandoff). */
12
12
  exchange: (code: string) => Promise<HandoffExchangeResult>;
13
- /** Adopt the handed-off tenant (host's single tenant writer). */
14
- onAdoptTenant: (tenant: string) => void | Promise<void>;
15
13
  /** Adopt the session JWT. */
16
14
  onSignedIn: (jwt: string) => void | Promise<void>;
17
15
  /** Clear the consumed single-use code. */
@@ -19,15 +17,14 @@ export interface HandoffExchangeDeps {
19
17
  }
20
18
 
21
19
  /**
22
- * Run the handoff: exchange → (adopt tenant) → sign in → clear the code, in that
23
- * exact order. The tenant adoption is AWAITED BEFORE onSignedIn so the host's
24
- * active tenant is set before the session reads it — a cross-tenant handoff must
25
- * never sign in under the wrong tenant. Throws on any failure (the caller maps it
26
- * to a user-facing message); onSignedIn / onDone never run on the error path.
20
+ * Run the handoff: exchange → sign in → clear the code, in that exact order. The
21
+ * handed-off tenant is the JWT's own `tenant` claim (getTenant() reads it), so a
22
+ * cross-tenant handoff is scoped correctly with nothing for the host to store.
23
+ * Throws on any failure (the caller maps it to a user-facing message);
24
+ * onSignedIn / onDone never run on the error path.
27
25
  */
28
26
  export async function runHandoffExchange(code: string, deps: HandoffExchangeDeps): Promise<void> {
29
27
  const res = await deps.exchange(code);
30
- if (res.tenant) await deps.onAdoptTenant(res.tenant);
31
28
  await deps.onSignedIn(res.jwt);
32
29
  deps.onDone();
33
30
  }
package/src/auth/login.ts CHANGED
@@ -21,17 +21,12 @@ export function googleClientIds(config: ConsumerCapabilityConfig): { webClientId
21
21
  }
22
22
 
23
23
  /**
24
- * Whether a failed config fetch should self-heal by falling back to the default
25
- * tenant. A consumer-DISABLED tenant 404s with "capability not enabled"; rather
26
- * than dead-end the user on red text we coerce back to the default and refetch.
27
- * Guarded by currentTenant !== defaultTenant so we never loop on the default.
24
+ * Whether a failed config fetch means the picked tenant has no consumer sign-in
25
+ * ("capability not enabled" 404). The host then clears its pick and shows its
26
+ * team picker — it never falls back to a default tenant.
28
27
  */
29
- export function shouldRecoverToDefaultTenant(
30
- errorMsg: string | undefined,
31
- currentTenant: string,
32
- defaultTenant: string,
33
- ): boolean {
34
- return /capability not enabled/i.test(errorMsg || '') && currentTenant !== defaultTenant;
28
+ export function isConsumerDisabled(errorMsg: string | undefined): boolean {
29
+ return /capability not enabled/i.test(errorMsg || '');
35
30
  }
36
31
 
37
32
  export interface ProviderSignInDeps {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Open self-signup — the pure, testable half of OpenJoinScreen (R9B).
3
3
  *
4
- * JSX-free so the join ORDERING (join → adopt tenant → sign in) and the error
4
+ * JSX-free so the join ORDERING (join → sign in) and the error
5
5
  * mapper are node-testable without rendering. The native id_token acquisition
6
6
  * happens in the component via getOAuthProvider(); the acquired token is passed
7
7
  * in here, so this module stays pure and provider-free.
@@ -11,8 +11,6 @@ import type { ConsumerSignInResult } from '@ateam-ai-mobile/core';
11
11
  export interface OpenJoinDeps {
12
12
  /** Exchange { tenant, id_token } for a session JWT (host injects core.openJoinWith*). */
13
13
  join: (slug: string, idToken: string) => Promise<ConsumerSignInResult>;
14
- /** Adopt the joined tenant (host's single ACTIVE_TENANT writer). */
15
- onAdoptTenant: (tenant: string) => void | Promise<void>;
16
14
  /** Adopt the session JWT. */
17
15
  onSignedIn: (jwt: string) => void | Promise<void>;
18
16
  }
@@ -22,10 +20,10 @@ export interface OpenJoinDeps {
22
20
  *
23
21
  * Returns false WITHOUT side effects when idToken is null — the caller acquired
24
22
  * no token (the user cancelled the native sheet, or it returned none), which is
25
- * a silent no-op, never an error. Otherwise: join → adopt the joined tenant →
26
- * sign in, in that order (the tenant is adopted only AFTER the server confirms
27
- * the join, and BEFORE sign-in so the session reads the right tenant). Throws on
28
- * a join failure (the caller maps it to a message); sign-in never runs then.
23
+ * a silent no-op, never an error. Otherwise: join → sign in. The joined tenant
24
+ * is the returned JWT's own `tenant` claim (getTenant() reads it), so there is
25
+ * nothing for the host to store. Throws on a join failure (the caller maps it to
26
+ * a message); sign-in never runs then.
29
27
  */
30
28
  export async function completeOpenJoin(
31
29
  slug: string,
@@ -34,7 +32,6 @@ export async function completeOpenJoin(
34
32
  ): Promise<boolean> {
35
33
  if (!idToken) return false;
36
34
  const consumer = await deps.join(slug, idToken);
37
- await deps.onAdoptTenant(slug);
38
35
  await deps.onSignedIn(consumer.jwt);
39
36
  return true;
40
37
  }
package/src/index.ts CHANGED
@@ -60,7 +60,7 @@ export { completeInviteJoin, inviteHeadline } from './auth/inviteJoin';
60
60
  export type { InviteJoinDeps } from './auth/inviteJoin';
61
61
  export { default as LoginScreen } from './auth/LoginScreen';
62
62
  export type { LoginScreenProps } from './auth/LoginScreen';
63
- export { googleClientIds, shouldRecoverToDefaultTenant, completeProviderSignIn } from './auth/login';
63
+ export { googleClientIds, isConsumerDisabled, completeProviderSignIn } from './auth/login';
64
64
  export type { ProviderSignInDeps } from './auth/login';
65
65
 
66
66
  // Voice (requires `voiceUrl` in the initAdas() config)
@@ -1,4 +1,4 @@
1
- import { useState, useMemo, useCallback } from 'react';
1
+ import { useState, useMemo, useCallback, useEffect } from 'react';
2
2
  import { View, Text, ActivityIndicator, StyleSheet, Platform } from 'react-native';
3
3
  import { WebView } from 'react-native-webview';
4
4
  import { getConfig, getCoreApiUrl, getVoiceApiUrl, getTenant, getToken, ada } from '@ateam-ai-mobile/core';
@@ -28,6 +28,9 @@ export default function EmbeddedPluginWebView({ pluginId, manifest, commandBuffe
28
28
  const { webViewRef, sendInit, handleMessage } = usePluginBridge(pluginId, manifest._connectorId, commandBufferRef);
29
29
 
30
30
  const url = resolvePluginUrl(manifest, tenant || undefined);
31
+ // An error belongs to the URL that produced it: a new URL (a fresh signature,
32
+ // a corrected tenant) gets a fresh load instead of the old error forever.
33
+ useEffect(() => { setError(null); }, [url]);
31
34
  const bridgeJS = buildPluginBridgeJS(token || '', tenant || '');
32
35
 
33
36
  // Hosts the plugin may navigate to — derived from the SDK config plus any