@ateam-ai-mobile/ui 0.1.5 → 0.1.8

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.5",
3
+ "version": "0.1.8",
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.5",
13
- "@ateam-ai-mobile/plugin-runtime": "^0.1.5",
12
+ "@ateam-ai-mobile/core": "0.1.8",
13
+ "@ateam-ai-mobile/plugin-runtime": "0.1.8",
14
14
  "zustand": "^5.0.0"
15
15
  },
16
16
  "peerDependencies": {
@@ -0,0 +1,71 @@
1
+ // A persistent banner for a non-active workspace (expired trial, suspended…).
2
+ //
3
+ // It renders the SERVER's `workspace.message` VERBATIM — the client never
4
+ // composes copy, so a new plan changes the wording without a mobile release.
5
+ // Persistent, not a toast: the condition persists, so the banner stays until
6
+ // the workspace state changes. No local rules, no reason→copy mapping.
7
+ //
8
+ // Mount once near the app root (above the chat). Renders nothing when the
9
+ // workspace is active, unknown, or unloaded — so pre-promotion (no /api/session
10
+ // yet) and healthy workspaces show no banner at all.
11
+
12
+ import { View, Text, StyleSheet } from 'react-native';
13
+ import { ada, fontSize, spacing, useWorkspaceStore } from '@ateam-ai-mobile/core';
14
+
15
+ // Warm amber for a "heads up, reduced" state — matches JobProgressBar's warm
16
+ // vocabulary rather than the SDK's cool default palette.
17
+ const WARM_AMBER = '#D49A4E';
18
+ const WARM_AMBER_SOFT = 'rgba(212, 154, 78, 0.16)';
19
+ const WARM_AMBER_BORDER = 'rgba(212, 154, 78, 0.4)';
20
+
21
+ export interface WorkspaceBannerProps {
22
+ /** Extra top inset (e.g. safe-area) so the banner clears the status bar. */
23
+ topInset?: number;
24
+ }
25
+
26
+ export default function WorkspaceBanner({ topInset = 0 }: WorkspaceBannerProps) {
27
+ const snapshot = useWorkspaceStore((s) => s.snapshot);
28
+
29
+ // Show only when the server gave us a real snapshot that is NOT active and
30
+ // carries a message to render. Everything else → nothing.
31
+ if (!snapshot) return null;
32
+ if (snapshot.state === 'active' || snapshot.state === 'unknown') return null;
33
+ if (!snapshot.message) return null;
34
+
35
+ return (
36
+ <View
37
+ style={[styles.container, { paddingTop: spacing.sm + topInset }]}
38
+ accessibilityRole="alert"
39
+ accessibilityLabel={snapshot.message}
40
+ >
41
+ <View style={styles.dot} />
42
+ <Text style={styles.message}>{snapshot.message}</Text>
43
+ </View>
44
+ );
45
+ }
46
+
47
+ const styles = StyleSheet.create({
48
+ container: {
49
+ flexDirection: 'row',
50
+ alignItems: 'flex-start',
51
+ gap: spacing.sm,
52
+ paddingHorizontal: spacing.md,
53
+ paddingBottom: spacing.sm,
54
+ backgroundColor: WARM_AMBER_SOFT,
55
+ borderBottomWidth: StyleSheet.hairlineWidth,
56
+ borderBottomColor: WARM_AMBER_BORDER,
57
+ },
58
+ dot: {
59
+ width: 8,
60
+ height: 8,
61
+ borderRadius: 4,
62
+ marginTop: 5,
63
+ backgroundColor: WARM_AMBER,
64
+ },
65
+ message: {
66
+ flex: 1,
67
+ color: ada.ink,
68
+ fontSize: fontSize.sm,
69
+ lineHeight: fontSize.sm * 1.35,
70
+ },
71
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * HandoffScreen (R9B — relocated from the app) — web→app sign-in handoff.
3
+ *
4
+ * Shown pre-auth when a one-time handoff code is pending (the host captured it
5
+ * from a deep link, e.g. ateam://handoff?handoff=<code>). We trade the code for
6
+ * a normal session JWT and sign in — no OAuth, no passkey. This is the R9B PILOT:
7
+ * fully generic, needs no OAuthProvider, and carries the security-sensitive
8
+ * error mapper + the exchange-once guard that must move WITH the screen.
9
+ *
10
+ * Everything host-specific is injected:
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
+ * - `onSignedIn` session adoption (host: authStore.login → core session +
15
+ * the shell-only tenantConfirmed / phase gate)
16
+ * - `onDone` clear the consumed single-use code (host store) after success
17
+ * - `onCancel` back to sign in (host toggles the pre-auth phase)
18
+ *
19
+ * The host wraps this in its own <SafeAreaView> — this package stays native-free
20
+ * (no react-native-safe-area-context, no router). Styling is core's ada tokens,
21
+ * matching every other ui component.
22
+ */
23
+ import { useEffect, useRef, useState } from 'react';
24
+ import { View, Text, Pressable, StyleSheet, Platform, ActivityIndicator } from 'react-native';
25
+ import { exchangeHandoff, ada } from '@ateam-ai-mobile/core';
26
+ import { AdaOrb } from '@ateam-ai-mobile/plugin-runtime';
27
+ import { runHandoffExchange, handoffErrorMessage } from './handoffExchange';
28
+
29
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
30
+
31
+ export interface HandoffScreenProps {
32
+ /** The one-time handoff code the host captured from the deep link (null = nothing pending). */
33
+ 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
+ /** Adopt the session JWT (host wires its authStore.login → core session + tenantConfirmed). */
41
+ onSignedIn: (jwt: string) => void | Promise<void>;
42
+ /** Clear the consumed single-use code so a relaunch never retries it. Called after success. */
43
+ onDone: () => void;
44
+ /** Back to sign in — the host toggles the pre-auth phase (not a router). */
45
+ onCancel: () => void;
46
+ }
47
+
48
+ export default function HandoffScreen({ code, onAdoptTenant, onSignedIn, onDone, onCancel }: HandoffScreenProps) {
49
+ const [err, setErr] = useState<string | null>(null);
50
+ // Exchange each code AT MOST ONCE. A single-use code exchanged twice
51
+ // (StrictMode/fast-refresh double-invoke) burns itself: the first call wins and
52
+ // the second gets "already used", which would otherwise surface as a spurious
53
+ // error over a successful sign-in. Keying on the code (not a bool) still lets a
54
+ // genuinely NEW code re-trigger the exchange.
55
+ const startedForRef = useRef<string | null>(null);
56
+
57
+ useEffect(() => {
58
+ if (!code || startedForRef.current === code) return;
59
+ startedForRef.current = code;
60
+ runHandoffExchange(code, { exchange: exchangeHandoff, onAdoptTenant, onSignedIn, onDone })
61
+ .catch((e: any) => setErr(handoffErrorMessage(e?.message)));
62
+ }, [code, onAdoptTenant, onSignedIn, onDone]);
63
+
64
+ return (
65
+ <View style={styles.container}>
66
+ <View style={styles.center}>
67
+ <AdaOrb size={72} state={err ? 'idle' : 'thinking'} />
68
+ {!err ? (
69
+ <>
70
+ <Text style={styles.title}>Signing you in…</Text>
71
+ <ActivityIndicator color={ada.ink} style={{ marginTop: 6 }} />
72
+ </>
73
+ ) : (
74
+ <>
75
+ <Text style={styles.title}>Couldn’t finish sign-in</Text>
76
+ <Text style={styles.sub}>{err}</Text>
77
+ <Pressable onPress={onCancel} hitSlop={8} style={styles.back}>
78
+ <Text style={styles.backText}>Back to sign in</Text>
79
+ </Pressable>
80
+ </>
81
+ )}
82
+ </View>
83
+ </View>
84
+ );
85
+ }
86
+
87
+ const styles = StyleSheet.create({
88
+ container: { flex: 1, backgroundColor: ada.paper },
89
+ center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 14, paddingHorizontal: 32 },
90
+ title: { fontSize: 20, fontWeight: '600', color: ada.ink, fontFamily: fontFam, marginTop: 8 },
91
+ sub: { fontSize: 14, lineHeight: 20, color: ada.inkSoft, textAlign: 'center', fontFamily: fontFam },
92
+ back: { marginTop: 10, paddingVertical: 10 },
93
+ backText: { fontSize: 15, fontWeight: '600', color: ada.orangeDeep, fontFamily: fontFam },
94
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * JoinScreen (R9B — relocated from the app) — tenant-join via invite deep link.
3
+ *
4
+ * Flow: preview the invite (GET) → "Join <Org> as <role>?" → native Google/Apple
5
+ * → POST { invite, id_token } → tenant-scoped JWT → sign in. The invite code is
6
+ * the ONLY tenant proof; no slug is ever sent.
7
+ *
8
+ * Native id_token + the Apple HIG button come from the host-wired OAuthProvider
9
+ * (getOAuthProvider) with NO cfg, so the provider uses the host's bundled Google
10
+ * id (the invite flow carries no tenant config). The host injects the code (from
11
+ * the deep link), onSignedIn, onDone (clear the consumed invite), onCancel, and
12
+ * wraps the screen in its own SafeAreaView.
13
+ */
14
+ import { useEffect, useMemo, useState } from 'react';
15
+ import { View, Text, Pressable, StyleSheet, ActivityIndicator, Platform } from 'react-native';
16
+ import {
17
+ previewInvite,
18
+ joinWithGoogle,
19
+ joinWithApple,
20
+ ada,
21
+ spacing,
22
+ type InvitePreview,
23
+ type ConsumerSignInResult,
24
+ } from '@ateam-ai-mobile/core';
25
+ import { AdaOrb } from '@ateam-ai-mobile/plugin-runtime';
26
+ import { getOAuthProvider, type OAuthProvider } from '../providers';
27
+ import { completeInviteJoin, inviteHeadline } from './inviteJoin';
28
+
29
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
30
+
31
+ export interface JoinScreenProps {
32
+ /** The invite code the host captured from the deep link. */
33
+ code: string | null;
34
+ /** Adopt the session JWT (host: authStore.login → core session + tenantConfirmed). */
35
+ onSignedIn: (jwt: string) => void | Promise<void>;
36
+ /** Clear the consumed single-use invite so a relaunch never retries it. */
37
+ onDone: () => void;
38
+ /** Dismiss on a bad invite — the host toggles the pre-auth phase (not a router). */
39
+ onCancel: () => void;
40
+ }
41
+
42
+ export default function JoinScreen({ code, onSignedIn, onDone, onCancel }: JoinScreenProps) {
43
+ const [preview, setPreview] = useState<InvitePreview | null>(null);
44
+ const [loadErr, setLoadErr] = useState<string | null>(null);
45
+ const [busy, setBusy] = useState(false);
46
+ const [err, setErr] = useState<string | null>(null);
47
+ const [appleAvailable, setAppleAvailable] = useState(false);
48
+
49
+ // Preview the invite for the header copy.
50
+ useEffect(() => {
51
+ if (!code) return;
52
+ setPreview(null);
53
+ setLoadErr(null);
54
+ previewInvite(code).then(setPreview).catch(e => setLoadErr(e?.message || 'Could not load invite'));
55
+ }, [code]);
56
+
57
+ useEffect(() => {
58
+ getOAuthProvider()?.isAppleAvailable().then(setAppleAvailable).catch(() => setAppleAvailable(false));
59
+ }, []);
60
+
61
+ const orgLine = useMemo(() => inviteHeadline(preview), [preview]);
62
+
63
+ async function run(
64
+ acquire: (oauth: OAuthProvider) => Promise<string | null>,
65
+ join: (code: string, idToken: string) => Promise<ConsumerSignInResult>,
66
+ ) {
67
+ if (!code) return;
68
+ const oauth = getOAuthProvider();
69
+ if (!oauth) { setErr('Sign-in is unavailable on this device.'); return; }
70
+ setErr(null); setBusy(true);
71
+ try {
72
+ const idToken = await acquire(oauth);
73
+ await completeInviteJoin(code, idToken, { join, onDone, onSignedIn });
74
+ } catch (e: any) {
75
+ setErr(e?.message || 'Sign-in failed');
76
+ } finally {
77
+ setBusy(false);
78
+ }
79
+ }
80
+
81
+ const handleGoogle = () => run(oauth => oauth.signInWithGoogle(), joinWithGoogle);
82
+ const handleApple = () => run(oauth => oauth.signInWithApple(), joinWithApple);
83
+
84
+ return (
85
+ <View style={styles.container}>
86
+ <View style={styles.hero}>
87
+ <AdaOrb size={120} />
88
+ <Text style={styles.wordmark}>ada</Text>
89
+ {loadErr ? (
90
+ <Text style={styles.err}>{loadErr}</Text>
91
+ ) : preview ? (
92
+ <Text style={styles.tagline}>{orgLine}</Text>
93
+ ) : (
94
+ <ActivityIndicator color={ada.ink} style={{ marginTop: 12 }} />
95
+ )}
96
+ </View>
97
+
98
+ <View style={styles.buttons}>
99
+ {/* Only offer sign-in once the invite previewed OK. */}
100
+ {preview && !loadErr && (
101
+ <>
102
+ <Pressable
103
+ style={[styles.googleBtn, busy && styles.btnDisabled]}
104
+ onPress={handleGoogle}
105
+ disabled={busy}
106
+ >
107
+ <Text style={styles.googleGlyph}>G</Text>
108
+ <Text style={styles.googleText}>Continue with Google</Text>
109
+ </Pressable>
110
+ {appleAvailable &&
111
+ getOAuthProvider()?.renderAppleButton({ onPress: handleApple, disabled: busy, style: styles.appleBtn })}
112
+ </>
113
+ )}
114
+ {loadErr && (
115
+ <Pressable style={styles.dismiss} onPress={onCancel} hitSlop={8}>
116
+ <Text style={styles.dismissText}>Back to sign in</Text>
117
+ </Pressable>
118
+ )}
119
+ {!!err && <Text style={styles.err}>{err}</Text>}
120
+ {busy && <ActivityIndicator color={ada.ink} style={{ marginTop: 12 }} />}
121
+ <Text style={styles.disclaimer}>By continuing you agree to Ada's Terms and Privacy Policy.</Text>
122
+ </View>
123
+ </View>
124
+ );
125
+ }
126
+
127
+ const styles = StyleSheet.create({
128
+ container: { flex: 1, backgroundColor: ada.paper, justifyContent: 'space-between' },
129
+ hero: { alignItems: 'center', marginTop: spacing.xxl * 1.4 },
130
+ wordmark: { fontSize: 34, fontWeight: '800', color: ada.ink, letterSpacing: 4, marginTop: 12, fontFamily: fontFam },
131
+ tagline: { fontSize: 17, color: ada.inkSoft, marginTop: 10, textAlign: 'center', paddingHorizontal: 24, fontFamily: fontFam },
132
+ buttons: { paddingHorizontal: 24, paddingBottom: 40, gap: 12 },
133
+ googleBtn: {
134
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10,
135
+ backgroundColor: ada.ink, paddingVertical: 15, borderRadius: 999,
136
+ },
137
+ btnDisabled: { opacity: 0.5 },
138
+ googleGlyph: { color: '#fff', fontSize: 18, fontWeight: '800' },
139
+ googleText: { color: '#fff', fontSize: 16, fontWeight: '600', fontFamily: fontFam },
140
+ appleBtn: { height: 52, width: '100%' },
141
+ dismiss: { alignItems: 'center', paddingVertical: 10 },
142
+ dismissText: { color: ada.inkMuted, fontSize: 15, fontWeight: '500' },
143
+ err: { color: '#C15545', fontSize: 13, textAlign: 'center', paddingHorizontal: 16, marginTop: 8 },
144
+ disclaimer: { fontSize: 12, color: ada.inkMuted, textAlign: 'center', marginTop: 8 },
145
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * LoginScreen (R9B — relocated from the app) — consumer login via native Google
3
+ * + native Apple sign-in.
4
+ *
5
+ * Both providers return an OIDC id_token; we POST it to consumer-auth, which
6
+ * verifies it server-side and returns a tenant-scoped JWT for sign-in. The
7
+ * webClientId is fetched at runtime from the tenant's consumer config, so no
8
+ * client ids are baked into the SDK.
9
+ *
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
14
+ * - onSignedIn session adoption (host: authStore.login)
15
+ * - onJoinTeam? open the host's "join a team by name" (OpenJoin) sub-view
16
+ *
17
+ * Native id_token acquisition + the Apple HIG button come from the host-wired
18
+ * OAuthProvider (getOAuthProvider); this screen passes the TENANT's client ids
19
+ * to signInWithGoogle. The host wraps this in its own SafeAreaView.
20
+ */
21
+ import { useEffect, useMemo, useState } from 'react';
22
+ import { View, Text, Pressable, StyleSheet, Platform, ActivityIndicator } from 'react-native';
23
+ import { fetchConsumerConfig, signInWithGoogle, signInWithApple, ada, type ConsumerCapabilityConfig } from '@ateam-ai-mobile/core';
24
+ import { AdaOrb } from '@ateam-ai-mobile/plugin-runtime';
25
+ import { getOAuthProvider } from '../providers';
26
+ import { googleClientIds, shouldRecoverToDefaultTenant, completeProviderSignIn } from './login';
27
+
28
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
29
+
30
+ export interface LoginScreenProps {
31
+ /** The reactive pre-auth tenant pick (host: useSession().tenant). Re-fetches config on change. */
32
+ 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. */
36
+ onConsumerDisabled: () => void | Promise<void>;
37
+ /** Adopt the session JWT (host: authStore.login → core session + tenantConfirmed). */
38
+ onSignedIn: (jwt: string) => void | Promise<void>;
39
+ /** Open the host's "join a team by name" (OpenJoin) sub-view. Link hidden when omitted. */
40
+ onJoinTeam?: () => void;
41
+ }
42
+
43
+ export default function LoginScreen({ activeTenant, defaultTenant, onConsumerDisabled, onSignedIn, onJoinTeam }: LoginScreenProps) {
44
+ const [config, setConfig] = useState<ConsumerCapabilityConfig | null>(null);
45
+ const [error, setError] = useState<string | null>(null);
46
+
47
+ useEffect(() => {
48
+ setConfig(null);
49
+ setError(null);
50
+ fetchConsumerConfig()
51
+ .then(setConfig)
52
+ .catch(async err => {
53
+ const msg = err?.message || 'Failed to load sign-in config';
54
+ // 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)) {
58
+ await onConsumerDisabled();
59
+ return;
60
+ }
61
+ setError(msg);
62
+ });
63
+ }, [activeTenant, defaultTenant, onConsumerDisabled]);
64
+
65
+ return (
66
+ <View style={styles.container}>
67
+ <View style={styles.hero}>
68
+ <AdaOrb size={140} />
69
+ <Text style={styles.wordmark}>ada</Text>
70
+ <Text style={styles.tagline}>Your private personal AI-Team</Text>
71
+ </View>
72
+
73
+ <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} />}
77
+ {onJoinTeam && (
78
+ <Pressable onPress={onJoinTeam} hitSlop={8} style={{ paddingVertical: 8 }}>
79
+ <Text style={styles.joinLink}>Join a team by name</Text>
80
+ </Pressable>
81
+ )}
82
+ <Text style={styles.disclaimer}>By continuing you agree to Ada's Terms and Privacy Policy.</Text>
83
+ </View>
84
+ </View>
85
+ );
86
+ }
87
+
88
+ function SignInButtons({ config, onSignedIn }: { config: ConsumerCapabilityConfig; onSignedIn: (jwt: string) => void | Promise<void> }) {
89
+ const [busy, setBusy] = useState(false);
90
+ const [error, setError] = useState<string | null>(null);
91
+ const [appleAvailable, setAppleAvailable] = useState(false);
92
+
93
+ const { webClientId, iosClientId } = useMemo(() => googleClientIds(config), [config]);
94
+
95
+ // Apple Sign-In is iOS 13+ only. Hide the button if the device can't.
96
+ useEffect(() => {
97
+ getOAuthProvider()?.isAppleAvailable().then(setAppleAvailable).catch(() => setAppleAvailable(false));
98
+ }, []);
99
+
100
+ const handleGoogleLogin = async () => {
101
+ setError(null);
102
+ if (!webClientId) {
103
+ setError('Google sign-in is not configured for this tenant');
104
+ return;
105
+ }
106
+ const oauth = getOAuthProvider();
107
+ if (!oauth) { setError('Sign-in is unavailable on this device.'); return; }
108
+ setBusy(true);
109
+ try {
110
+ const idToken = await oauth.signInWithGoogle({ webClientId, iosClientId });
111
+ await completeProviderSignIn(idToken, { exchange: signInWithGoogle, onSignedIn });
112
+ } catch (err: any) {
113
+ setError(err?.message || 'Sign-in failed');
114
+ } finally {
115
+ setBusy(false);
116
+ }
117
+ };
118
+
119
+ const handleAppleLogin = async () => {
120
+ setError(null);
121
+ const oauth = getOAuthProvider();
122
+ if (!oauth) { setError('Sign-in is unavailable on this device.'); return; }
123
+ setBusy(true);
124
+ try {
125
+ const idToken = await oauth.signInWithApple();
126
+ await completeProviderSignIn(idToken, { exchange: signInWithApple, onSignedIn });
127
+ } catch (err: any) {
128
+ setError(err?.message || 'Apple sign-in failed');
129
+ } finally {
130
+ setBusy(false);
131
+ }
132
+ };
133
+
134
+ const googleEnabled = !!webClientId && !busy;
135
+ const appleEnabled = appleAvailable && !!config.providers.apple?.service_id && !busy;
136
+
137
+ return (
138
+ <>
139
+ {!!config.providers.google && (
140
+ <Pressable
141
+ style={[styles.signInBtn, !googleEnabled && styles.signInBtnDisabled]}
142
+ onPress={handleGoogleLogin}
143
+ disabled={!googleEnabled}
144
+ >
145
+ <Text style={styles.signInGlyph}>G</Text>
146
+ <Text style={styles.signInText}>Continue with Google</Text>
147
+ </Pressable>
148
+ )}
149
+ {appleEnabled &&
150
+ getOAuthProvider()?.renderAppleButton({ onPress: handleAppleLogin, disabled: busy, style: styles.appleBtn })}
151
+ {!!error && <Text style={styles.errorText}>{error}</Text>}
152
+ {busy && <ActivityIndicator color={ada.ink} />}
153
+ </>
154
+ );
155
+ }
156
+
157
+ const styles = StyleSheet.create({
158
+ container: {
159
+ flex: 1,
160
+ backgroundColor: ada.paper,
161
+ justifyContent: 'space-between',
162
+ paddingHorizontal: 32,
163
+ paddingBottom: 24,
164
+ },
165
+ hero: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 18 },
166
+ wordmark: { fontSize: 32, fontWeight: '600', color: ada.ink, letterSpacing: 4, fontFamily: fontFam, marginTop: 8 },
167
+ tagline: { fontSize: 16, fontWeight: '500', color: ada.inkSoft, letterSpacing: -0.1, fontFamily: fontFam },
168
+ buttonSection: { alignItems: 'center', gap: 12 },
169
+ signInBtn: {
170
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center',
171
+ backgroundColor: ada.ink, paddingVertical: 16, paddingHorizontal: 24,
172
+ borderRadius: 999, width: '100%', gap: 12,
173
+ },
174
+ signInBtnDisabled: { opacity: 0.5 },
175
+ signInGlyph: { fontSize: 18, fontWeight: '700', color: ada.paper, fontFamily: fontFam },
176
+ signInText: { fontSize: 16, fontWeight: '600', color: ada.paper, letterSpacing: -0.2, fontFamily: fontFam },
177
+ appleBtn: { width: '100%', height: 52 },
178
+ errorText: { fontSize: 13, color: '#c43d3d', textAlign: 'center', paddingHorizontal: 16 },
179
+ disclaimer: { fontSize: 12, color: ada.inkMuted, textAlign: 'center', marginTop: 8, lineHeight: 18, fontFamily: fontFam },
180
+ joinLink: { fontSize: 14, fontWeight: '600', color: ada.orangeDeep, textAlign: 'center', fontFamily: fontFam },
181
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * OpenJoinScreen (R9B — relocated from the app) — open self-signup (no invite).
3
+ *
4
+ * The user types a team handle (tenant slug); we run native Google/Apple and
5
+ * POST { tenant, id_token } with NO invite. The server accepts only if that
6
+ * tenant is free-to-join, else 403 — surfaced as a message. (Invite-only tenants
7
+ * must use an invite link.)
8
+ *
9
+ * Native id_token acquisition + the Apple HIG button come from the host-wired
10
+ * OAuthProvider (setOAuthProvider). With no cfg passed to signInWithGoogle, the
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.
14
+ */
15
+ import { useEffect, useState } from 'react';
16
+ import { View, Text, Pressable, StyleSheet, ActivityIndicator, Platform, TextInput } from 'react-native';
17
+ import { openJoinWithGoogle, openJoinWithApple, ada, spacing, type ConsumerSignInResult } from '@ateam-ai-mobile/core';
18
+ import { AdaOrb } from '@ateam-ai-mobile/plugin-runtime';
19
+ import { getOAuthProvider, type OAuthProvider } from '../providers';
20
+ import { completeOpenJoin, openJoinErrorMessage } from './openJoin';
21
+
22
+ const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
23
+
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
+ /** Adopt the session JWT (host: authStore.login → core session + tenantConfirmed). */
28
+ onSignedIn: (jwt: string) => void | Promise<void>;
29
+ /** Back to sign in — the host toggles the pre-auth phase (not a router). */
30
+ onBack: () => void;
31
+ }
32
+
33
+ export default function OpenJoinScreen({ onAdoptTenant, onSignedIn, onBack }: OpenJoinScreenProps) {
34
+ const [team, setTeam] = useState('');
35
+ const [busy, setBusy] = useState(false);
36
+ const [err, setErr] = useState<string | null>(null);
37
+ const [appleAvailable, setAppleAvailable] = useState(false);
38
+
39
+ useEffect(() => {
40
+ getOAuthProvider()?.isAppleAvailable().then(setAppleAvailable).catch(() => setAppleAvailable(false));
41
+ }, []);
42
+
43
+ // Normalize what the user typed into a slug-ish handle.
44
+ const slug = team.trim().toLowerCase();
45
+ const canSubmit = slug.length > 0 && !busy;
46
+
47
+ async function run(
48
+ acquire: (oauth: OAuthProvider) => Promise<string | null>,
49
+ join: (slug: string, idToken: string) => Promise<ConsumerSignInResult>,
50
+ ) {
51
+ if (!slug) return;
52
+ const oauth = getOAuthProvider();
53
+ if (!oauth) { setErr('Sign-in is unavailable on this device.'); return; }
54
+ setErr(null); setBusy(true);
55
+ try {
56
+ const idToken = await acquire(oauth);
57
+ await completeOpenJoin(slug, idToken, { join, onAdoptTenant, onSignedIn });
58
+ } catch (e: any) {
59
+ setErr(openJoinErrorMessage(e?.message));
60
+ } finally {
61
+ setBusy(false);
62
+ }
63
+ }
64
+
65
+ const handleGoogle = () => run(oauth => oauth.signInWithGoogle(), openJoinWithGoogle);
66
+ const handleApple = () => run(oauth => oauth.signInWithApple(), openJoinWithApple);
67
+
68
+ return (
69
+ <View style={styles.container}>
70
+ <View style={styles.hero}>
71
+ <AdaOrb size={110} />
72
+ <Text style={styles.wordmark}>Join a team</Text>
73
+ <Text style={styles.tagline}>Enter the team's handle. If it's open to join, you're in.</Text>
74
+ </View>
75
+
76
+ <View style={styles.form}>
77
+ <TextInput
78
+ style={styles.input}
79
+ value={team}
80
+ onChangeText={setTeam}
81
+ placeholder="team handle (e.g. acme)"
82
+ placeholderTextColor={ada.inkMuted}
83
+ autoCapitalize="none"
84
+ autoCorrect={false}
85
+ returnKeyType="done"
86
+ />
87
+ <Pressable
88
+ style={[styles.googleBtn, !canSubmit && styles.btnDisabled]}
89
+ onPress={handleGoogle}
90
+ disabled={!canSubmit}
91
+ >
92
+ <Text style={styles.googleGlyph}>G</Text>
93
+ <Text style={styles.googleText}>Continue with Google</Text>
94
+ </Pressable>
95
+ {appleAvailable && (
96
+ <View style={{ opacity: canSubmit ? 1 : 0.5 }} pointerEvents={canSubmit ? 'auto' : 'none'}>
97
+ {getOAuthProvider()?.renderAppleButton({ onPress: handleApple, disabled: !canSubmit, style: styles.appleBtn })}
98
+ </View>
99
+ )}
100
+ {busy && <ActivityIndicator color={ada.ink} style={{ marginTop: 10 }} />}
101
+ {!!err && <Text style={styles.err}>{err}</Text>}
102
+ <Pressable onPress={onBack} hitSlop={8} style={styles.back}>
103
+ <Text style={styles.backText}>Back to sign in</Text>
104
+ </Pressable>
105
+ </View>
106
+ </View>
107
+ );
108
+ }
109
+
110
+ const styles = StyleSheet.create({
111
+ container: { flex: 1, backgroundColor: ada.paper, justifyContent: 'space-between' },
112
+ hero: { alignItems: 'center', marginTop: spacing.xxl },
113
+ wordmark: { fontSize: 26, fontWeight: '800', color: ada.ink, marginTop: 14, fontFamily: fontFam },
114
+ tagline: { fontSize: 15, color: ada.inkSoft, marginTop: 8, textAlign: 'center', paddingHorizontal: 28, fontFamily: fontFam },
115
+ form: { paddingHorizontal: 24, paddingBottom: 40, gap: 12 },
116
+ input: {
117
+ backgroundColor: ada.paperSoft, borderWidth: 1, borderColor: ada.border, borderRadius: 14,
118
+ paddingHorizontal: 16, paddingVertical: 14, fontSize: 16, color: ada.ink,
119
+ },
120
+ googleBtn: {
121
+ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 10,
122
+ backgroundColor: ada.ink, paddingVertical: 15, borderRadius: 999,
123
+ },
124
+ btnDisabled: { opacity: 0.5 },
125
+ googleGlyph: { color: '#fff', fontSize: 18, fontWeight: '800' },
126
+ googleText: { color: '#fff', fontSize: 16, fontWeight: '600', fontFamily: fontFam },
127
+ appleBtn: { height: 52, width: '100%' },
128
+ err: { color: '#C15545', fontSize: 13, textAlign: 'center', paddingHorizontal: 16, marginTop: 4 },
129
+ back: { alignItems: 'center', paddingVertical: 10 },
130
+ backText: { color: ada.inkMuted, fontSize: 15, fontWeight: '500' },
131
+ });