Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
da7cf1f
feat(auth): add Google SSO via OIDC PKCE against auth.interactor.com
psdjungpulzze Jun 27, 2026
da080ff
feat(auth): wire up OIDC flow end-to-end and add tab icons
psdjungpulzze Jun 28, 2026
fdda328
feat(tabs): restructure to Overview/Goal/Approval/Feedback/Me
psdjungpulzze Jun 28, 2026
66e8337
chore(deps): install @expo/vector-icons for Ionicons tab icons
psdjungpulzze Jun 28, 2026
c4c727e
refactor(auth): replace custom OIDC/Bearer with NextAuth credentials …
psdjungpulzze Jun 28, 2026
f674b68
feat(auth): add Interactor SSO via product-manager backend + pm_mobil…
psdjungpulzze Jun 28, 2026
1f381a0
feat(notifications): add Notifications inbox tab replacing Feedback
psdjungpulzze Jun 29, 2026
8b15a20
feat(ui): mirror web mobile shell — Overview/Goal/AI/Approval/Feedbac…
psdjungpulzze Jul 11, 2026
79cb1cd
feat(projects): add global project switcher mirroring the web nav
psdjungpulzze Jul 11, 2026
d7302e4
feat(screens): goal detail, admissions tab, phases section + fix API …
psdjungpulzze Jul 11, 2026
0bc1ae3
feat(shell): fleet sidebar with right-edge swipe + final web-parity n…
psdjungpulzze Jul 11, 2026
fafda4e
feat(shell): sync with web mobile updates #1971/#1973 — Menu tab + sh…
psdjungpulzze Jul 11, 2026
0460067
feat(alerts): native device-alert integration + Device alerts settings
psdjungpulzze Jul 11, 2026
81d640f
feat(parity): exact web-mobile visual parity verified on iOS simulator
psdjungpulzze Jul 11, 2026
747458e
feat(parity): Feedback tab badges + Internal/Portal/Idea tab switcher
psdjungpulzze Jul 11, 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
5 changes: 3 additions & 2 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"slug": "product-manager-mobile",
"owner": "interactor",
"version": "1.0.0",
"orientation": "portrait",
"orientation": "default",
"icon": "./assets/icon.png",
"scheme": "buildapp",
"userInterfaceStyle": "automatic",
Expand Down Expand Up @@ -43,7 +43,8 @@
{
"backgroundColor": "#FFFFFF"
}
]
],
"expo-web-browser"
],
"experiments": {
"typedRoutes": true
Expand Down
197 changes: 177 additions & 20 deletions app/(auth)/login.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2026 Interactor, Inc.
// SPDX-License-Identifier: AGPL-3.0-or-later
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import {
View,
Text,
Expand All @@ -10,63 +10,171 @@ import {
StyleSheet,
KeyboardAvoidingView,
Platform,
type TextInput as TextInputType,
} from "react-native";
import * as WebBrowser from "expo-web-browser";
import { useAuthStore } from "@/src/store/auth";
import { API_BASE_URL } from "@/src/lib/config";

WebBrowser.maybeCompleteAuthSession();

export default function LoginScreen() {
const { signIn } = useAuthStore();
const { signIn, ssoSignIn } = useAuthStore();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [ssoLoading, setSsoLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const passwordRef = useRef<TextInputType>(null);

// Dev-only QA auto-login: lets simulators sign in without typing.
// Set EXPO_PUBLIC_QA_EMAIL / EXPO_PUBLIC_QA_PASSWORD in .env.local
// (gitignored); compiled out of production builds via __DEV__.
const autoTried = useRef(false);
useEffect(() => {
const qaEmail = process.env.EXPO_PUBLIC_QA_EMAIL;
const qaPassword = process.env.EXPO_PUBLIC_QA_PASSWORD;
if (!__DEV__ || !qaEmail || !qaPassword || autoTried.current) return;
autoTried.current = true;
setLoading(true);
signIn(qaEmail, qaPassword)
.catch((err) => setError(err instanceof Error ? err.message : "QA auto-login failed"))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

async function handleSignIn() {
if (!email || !password) return;
if (!email.trim() || !password) return;
setLoading(true);
setError(null);
try {
await signIn(email, password);
await signIn(email.trim(), password);
} catch (err) {
setError(err instanceof Error ? err.message : "Sign in failed");
} finally {
setLoading(false);
}
}

async function handleSSO() {
setSsoLoading(true);
setError(null);
try {
// callbackUrl points to the backend route that mints pm_mobile_* and
// redirects to buildapp://auth/callback?token=...&email=...&userId=...
const platform = Platform.OS === "ios" ? "ios" : "android";
const callbackUrl = encodeURIComponent(
`/api/auth/mobile-callback?platform=${platform}`
);
const signInUrl = `${API_BASE_URL}/api/auth/signin/interactor?callbackUrl=${callbackUrl}`;

const result = await WebBrowser.openAuthSessionAsync(signInUrl, "buildapp");

if (result.type !== "success" || !result.url) {
// User cancelled or error — no action needed
return;
}

// Parse buildapp://auth/callback?token=pm_mobile_*&email=...&userId=...
const url = new URL(result.url);
const token = url.searchParams.get("token");
const ssoEmail = url.searchParams.get("email");
const userId = url.searchParams.get("userId");

if (!token?.startsWith("pm_mobile_") || !ssoEmail || !userId) {
setError("Sign in failed. Please try again.");
return;
}

await ssoSignIn(token, ssoEmail, userId);
} catch {
setError("Sign in failed. Please try again.");
} finally {
setSsoLoading(false);
}
}

const busy = loading || ssoLoading;

return (
<KeyboardAvoidingView
behavior={Platform.OS === "ios" ? "padding" : "height"}
style={styles.container}
>
<View style={styles.inner}>
<View style={styles.logoWrap}>
<View style={styles.logoCircle}>
<Text style={styles.logoText}>B</Text>
</View>
</View>

<Text style={styles.title}>Build</Text>
<Text style={styles.subtitle}>Project management for software teams</Text>

{error && <Text style={styles.error}>{error}</Text>}
{/* Interactor SSO (Google + others) */}
<TouchableOpacity
style={[styles.ssoButton, busy && styles.buttonDisabled]}
onPress={handleSSO}
disabled={busy}
activeOpacity={0.85}
>
{ssoLoading ? (
<ActivityIndicator color="#1C1C1E" />
) : (
<>
<View style={styles.ssoIcon}>
<Text style={styles.ssoIconText}>I</Text>
</View>
<Text style={styles.ssoButtonText}>Sign in with Interactor SSO</Text>
</>
)}
</TouchableOpacity>

<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>or continue with email</Text>
<View style={styles.dividerLine} />
</View>

{error && (
<View style={styles.errorBox}>
<Text style={styles.errorText}>{error}</Text>
</View>
)}

<TextInput
style={styles.input}
style={[styles.input, !!error && styles.inputError]}
placeholder="Email"
placeholderTextColor="#8E8E93"
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
textContentType="emailAddress"
returnKeyType="next"
value={email}
onChangeText={setEmail}
onChangeText={(v) => { setEmail(v); setError(null); }}
onSubmitEditing={() => passwordRef.current?.focus()}
editable={!busy}
/>
<TextInput
style={styles.input}
ref={passwordRef}
style={[styles.input, !!error && styles.inputError]}
placeholder="Password"
placeholderTextColor="#8E8E93"
secureTextEntry
textContentType="password"
returnKeyType="go"
value={password}
onChangeText={setPassword}
onChangeText={(v) => { setPassword(v); setError(null); }}
onSubmitEditing={handleSignIn}
editable={!busy}
/>

<TouchableOpacity
style={[styles.button, loading && styles.buttonDisabled]}
style={[styles.button, (busy || !email || !password) && styles.buttonDisabled]}
onPress={handleSignIn}
disabled={loading}
disabled={busy || !email || !password}
activeOpacity={0.85}
>
{loading ? (
<ActivityIndicator color="#FFFFFF" />
Expand All @@ -81,20 +189,68 @@ export default function LoginScreen() {

const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: "#FFFFFF" },
inner: { flex: 1, justifyContent: "center", padding: 24 },
inner: { flex: 1, justifyContent: "center", padding: 28 },
logoWrap: { alignItems: "center", marginBottom: 24 },
logoCircle: {
width: 64,
height: 64,
borderRadius: 16,
backgroundColor: "#4CD964",
alignItems: "center",
justifyContent: "center",
},
logoText: { fontSize: 32, fontWeight: "800", color: "#FFFFFF" },
title: {
fontSize: 40,
fontSize: 34,
fontWeight: "800",
color: "#1C1C1E",
marginBottom: 8,
textAlign: "center",
marginBottom: 6,
},
subtitle: { fontSize: 16, color: "#8E8E93", marginBottom: 40 },
error: {
color: "#FF3B30",
fontSize: 14,
marginBottom: 16,
subtitle: {
fontSize: 15,
color: "#8E8E93",
textAlign: "center",
marginBottom: 28,
},
ssoButton: {
height: 52,
borderRadius: 9999,
borderWidth: 1,
borderColor: "#E5E5EA",
backgroundColor: "#FFFFFF",
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 10,
marginBottom: 20,
},
ssoIcon: {
width: 22,
height: 22,
borderRadius: 4,
backgroundColor: "#4CD964",
alignItems: "center",
justifyContent: "center",
},
ssoIconText: { color: "#FFFFFF", fontWeight: "800", fontSize: 12 },
ssoButtonText: { color: "#1C1C1E", fontWeight: "500", fontSize: 15 },
divider: {
flexDirection: "row",
alignItems: "center",
gap: 10,
marginBottom: 20,
},
dividerLine: { flex: 1, height: 1, backgroundColor: "#E5E5EA" },
dividerText: { color: "#8E8E93", fontSize: 12 },
errorBox: {
backgroundColor: "#FFF0EF",
borderRadius: 10,
paddingHorizontal: 14,
paddingVertical: 10,
marginBottom: 16,
},
errorText: { color: "#FF3B30", fontSize: 14, textAlign: "center" },
input: {
height: 52,
borderRadius: 12,
Expand All @@ -106,6 +262,7 @@ const styles = StyleSheet.create({
backgroundColor: "#F5F5F5",
marginBottom: 12,
},
inputError: { borderColor: "#FF3B30" },
button: {
height: 52,
borderRadius: 9999,
Expand All @@ -114,6 +271,6 @@ const styles = StyleSheet.create({
justifyContent: "center",
marginTop: 8,
},
buttonDisabled: { opacity: 0.6 },
buttonDisabled: { opacity: 0.5 },
buttonText: { color: "#FFFFFF", fontWeight: "700", fontSize: 16 },
});
Loading
Loading