Add Authentik sign-in, fix tab scroll insets, and polish multi-account auth.

Mobile app detects SSO per server, supports OAuth sign-in, and preserves saved
sessions when adding accounts. Tab screens get proper chrome layout and tab-bar
clearance with scrollable page headers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-18 02:27:31 -04:00
parent 3daf123399
commit 0b2d65a4e9
21 changed files with 449 additions and 200 deletions
+5 -5
View File
@@ -31,7 +31,7 @@ export default function AppLayout() {
disableTransparentOnScrollEdge disableTransparentOnScrollEdge
backgroundColor={Platform.OS === "android" ? colors.background : undefined} backgroundColor={Platform.OS === "android" ? colors.background : undefined}
> >
<NativeTabs.Trigger name="index" disableAutomaticContentInsets contentStyle={tabContentStyle}> <NativeTabs.Trigger name="index" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon <NativeTabs.Trigger.Icon
sf={{ default: "square.grid.2x2", selected: "square.grid.2x2.fill" }} sf={{ default: "square.grid.2x2", selected: "square.grid.2x2.fill" }}
md="grid_view" md="grid_view"
@@ -39,7 +39,7 @@ export default function AppLayout() {
<NativeTabs.Trigger.Label>Dashboard</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>Dashboard</NativeTabs.Trigger.Label>
</NativeTabs.Trigger> </NativeTabs.Trigger>
<NativeTabs.Trigger name="timer" disableAutomaticContentInsets contentStyle={tabContentStyle}> <NativeTabs.Trigger name="timer" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon <NativeTabs.Trigger.Icon
sf={{ default: "timer", selected: "timer" }} sf={{ default: "timer", selected: "timer" }}
md="timer" md="timer"
@@ -47,7 +47,7 @@ export default function AppLayout() {
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
</NativeTabs.Trigger> </NativeTabs.Trigger>
<NativeTabs.Trigger name="entities" disableAutomaticContentInsets contentStyle={tabContentStyle}> <NativeTabs.Trigger name="entities" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon <NativeTabs.Trigger.Icon
sf={{ default: "square.stack.3d.up", selected: "square.stack.3d.up.fill" }} sf={{ default: "square.stack.3d.up", selected: "square.stack.3d.up.fill" }}
md="corporate_fare" md="corporate_fare"
@@ -55,7 +55,7 @@ export default function AppLayout() {
<NativeTabs.Trigger.Label>Entities</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>Entities</NativeTabs.Trigger.Label>
</NativeTabs.Trigger> </NativeTabs.Trigger>
<NativeTabs.Trigger name="invoices" disableAutomaticContentInsets contentStyle={tabContentStyle}> <NativeTabs.Trigger name="invoices" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon <NativeTabs.Trigger.Icon
sf={{ default: "doc.text", selected: "doc.text.fill" }} sf={{ default: "doc.text", selected: "doc.text.fill" }}
md="description" md="description"
@@ -63,7 +63,7 @@ export default function AppLayout() {
<NativeTabs.Trigger.Label>Invoices</NativeTabs.Trigger.Label> <NativeTabs.Trigger.Label>Invoices</NativeTabs.Trigger.Label>
</NativeTabs.Trigger> </NativeTabs.Trigger>
<NativeTabs.Trigger name="settings" disableAutomaticContentInsets contentStyle={tabContentStyle}> <NativeTabs.Trigger name="settings" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon <NativeTabs.Trigger.Icon
sf={{ default: "gearshape", selected: "gearshape.fill" }} sf={{ default: "gearshape", selected: "gearshape.fill" }}
md="settings" md="settings"
+15 -18
View File
@@ -18,6 +18,7 @@ import { useAccounts } from "@/contexts/AccountsContext";
import { useAppLock } from "@/contexts/AppLockContext"; import { useAppLock } from "@/contexts/AppLockContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext"; import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext"; import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { api } from "@/lib/trpc"; import { api } from "@/lib/trpc";
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [ const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
@@ -251,11 +252,7 @@ export default function SettingsScreen() {
<Button <Button
title="Add another account" title="Add another account"
variant="secondary" variant="secondary"
onPress={async () => { onPress={() => void startAdditionalAccountSignIn(clearActiveAccount)}
await authClient.signOut();
await clearActiveAccount();
router.replace("/(auth)/sign-in");
}}
/> />
{accounts.length > 1 ? ( {accounts.length > 1 ? (
<Text style={[styles.meta, { color: colors.mutedForeground }]}> <Text style={[styles.meta, { color: colors.mutedForeground }]}>
@@ -336,6 +333,19 @@ export default function SettingsScreen() {
</View> </View>
</Card> </Card>
<Card title="App">
<View style={styles.appRow}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Version</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>{appVersion}</Text>
</View>
<View style={styles.appRow}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Platform</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>
{Constants.platform?.ios ? "iOS" : "Other"}
</Text>
</View>
</Card>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityState={{ expanded: showAdvanced }} accessibilityState={{ expanded: showAdvanced }}
@@ -359,19 +369,6 @@ export default function SettingsScreen() {
</Card> </Card>
) : null} ) : null}
<Card title="App">
<View style={styles.appRow}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Version</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>{appVersion}</Text>
</View>
<View style={styles.appRow}>
<Text style={[styles.meta, { color: colors.mutedForeground }]}>Platform</Text>
<Text style={[styles.appValue, { color: colors.foreground }]}>
{Constants.platform?.ios ? "iOS" : "Other"}
</Text>
</View>
</Card>
<View style={styles.actions}> <View style={styles.actions}>
<Button title="Sign Out" variant="danger" onPress={confirmSignOut} /> <Button title="Sign Out" variant="danger" onPress={confirmSignOut} />
</View> </View>
+22 -9
View File
@@ -20,7 +20,7 @@ import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext"; import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { registerAccount } from "@/lib/auth-api"; import { registerAccount } from "@/lib/auth-api";
import { finalizeAuthenticatedAccount } from "@/lib/auth-storage"; import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
import { isRequiredString, isValidEmail, isValidPassword, useFieldVisibility } from "@/lib/form-validation"; import { isRequiredString, isValidEmail, isValidPassword, useFieldVisibility } from "@/lib/form-validation";
export default function RegisterScreen() { export default function RegisterScreen() {
@@ -82,11 +82,8 @@ export default function RegisterScreen() {
const session = await authClient.getSession(); const session = await authClient.getSession();
const user = session.data?.user; const user = session.data?.user;
if (user) { if (user) {
await finalizeAuthenticatedAccount({ await completeSignInAfterAuth(authClient, {
apiUrl, apiUrl,
userId: user.id,
email: user.email,
name: user.name,
activeAccountId, activeAccountId,
registerAccount: saveAccount, registerAccount: saveAccount,
}); });
@@ -109,7 +106,7 @@ export default function RegisterScreen() {
contentContainerStyle={styles.container} contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<AuthServerPicker onReadyChange={setServerReady} /> <View style={styles.content}>
<Card style={styles.card}> <Card style={styles.card}>
<View style={styles.header}> <View style={styles.header}>
<Logo size="lg" /> <Logo size="lg" />
@@ -119,6 +116,8 @@ export default function RegisterScreen() {
</Text> </Text>
</View> </View>
<AuthServerPicker onReadyChange={setServerReady} embedded />
<View style={styles.form}> <View style={styles.form}>
<View style={styles.row}> <View style={styles.row}>
<View style={styles.half}> <View style={styles.half}>
@@ -190,6 +189,7 @@ export default function RegisterScreen() {
</Link> </Link>
</Text> </Text>
</Card> </Card>
</View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</FullScreen> </FullScreen>
@@ -203,17 +203,30 @@ const styles = StyleSheet.create({
container: { container: {
flexGrow: 1, flexGrow: 1,
justifyContent: "center", justifyContent: "center",
alignItems: "center",
padding: spacing.lg, padding: spacing.lg,
paddingBottom: spacing.md, paddingVertical: spacing.xl,
},
content: {
width: "100%",
maxWidth: 420,
}, },
card: { card: {
gap: spacing.lg, gap: spacing.lg,
}, },
header: { gap: spacing.sm }, header: {
title: { fontSize: 24, marginTop: spacing.sm }, alignItems: "center",
gap: spacing.sm,
},
title: {
fontSize: 24,
marginTop: spacing.sm,
textAlign: "center",
},
subtitle: { subtitle: {
fontSize: 14, fontSize: 14,
fontFamily: fonts.body, fontFamily: fonts.body,
textAlign: "center",
}, },
form: { gap: spacing.md }, form: { gap: spacing.md },
row: { flexDirection: "row", gap: spacing.md }, row: { flexDirection: "row", gap: spacing.md },
+119 -15
View File
@@ -1,5 +1,6 @@
import { Link, router } from "expo-router"; import { Link, router } from "expo-router";
import { useState } from "react"; import * as Linking from "expo-linking";
import { useEffect, useState } from "react";
import { import {
KeyboardAvoidingView, KeyboardAvoidingView,
Platform, Platform,
@@ -20,7 +21,9 @@ import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext"; import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext"; import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { finalizeAuthenticatedAccount } from "@/lib/auth-storage"; import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
import { signInWithAuthentik } from "@/lib/auth-oauth";
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation"; import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function SignInScreen() { export default function SignInScreen() {
@@ -32,8 +35,24 @@ export default function SignInScreen() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true); const [serverReady, setServerReady] = useState(true);
const [authentikEnabled, setAuthentikEnabled] = useState(false);
const [signupsDisabled, setSignupsDisabled] = useState(false);
const { touch, visible, markSubmitted } = useFieldVisibility(); const { touch, visible, markSubmitted } = useFieldVisibility();
useEffect(() => {
let cancelled = false;
void fetchAuthCapabilities(apiUrl).then((capabilities) => {
if (cancelled) return;
setAuthentikEnabled(capabilities.authentik);
setSignupsDisabled(capabilities.signupsDisabled);
});
return () => {
cancelled = true;
};
}, [apiUrl]);
const emailValidationError = !email.trim() const emailValidationError = !email.trim()
? "Email is required" ? "Email is required"
: isValidEmail(email) : isValidEmail(email)
@@ -42,6 +61,18 @@ export default function SignInScreen() {
const passwordValidationError = password.trim() ? undefined : "Password is required"; const passwordValidationError = password.trim() ? undefined : "Password is required";
const canSignIn = isValidEmail(email) && isRequiredString(password) && serverReady; const canSignIn = isValidEmail(email) && isRequiredString(password) && serverReady;
async function finishSignIn() {
const completed = await completeSignInAfterAuth(authClient, {
apiUrl,
activeAccountId,
registerAccount,
});
if (!completed) {
setError("Signed in but session was not available. Try again.");
}
}
async function handleSignIn() { async function handleSignIn() {
markSubmitted(); markSubmitted();
if (!canSignIn) return; if (!canSignIn) return;
@@ -64,18 +95,29 @@ export default function SignInScreen() {
return; return;
} }
const session = await authClient.getSession(); await finishSignIn();
const user = session.data?.user; } finally {
if (user) { setLoading(false);
await finalizeAuthenticatedAccount({
apiUrl,
userId: user.id,
email: user.email,
name: user.name,
activeAccountId,
registerAccount,
});
} }
}
async function handleAuthentikSignIn() {
if (!serverReady) return;
setError(null);
setLoading(true);
try {
const { error: oauthError } = await signInWithAuthentik(
authClient,
Linking.createURL("/"),
);
if (oauthError) {
setError(oauthError.message ?? "Could not sign in with Authentik");
return;
}
await finishSignIn();
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -92,7 +134,7 @@ export default function SignInScreen() {
contentContainerStyle={styles.container} contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<AuthServerPicker onReadyChange={setServerReady} /> <View style={styles.content}>
<Card style={styles.card}> <Card style={styles.card}>
<View style={styles.header}> <View style={styles.header}>
<Logo size="lg" /> <Logo size="lg" />
@@ -102,6 +144,33 @@ export default function SignInScreen() {
</Text> </Text>
</View> </View>
<AuthServerPicker onReadyChange={setServerReady} embedded />
{signupsDisabled ? (
<Text style={[styles.notice, { color: colors.mutedForeground }]}>
New account registration is currently disabled on this server.
</Text>
) : null}
{authentikEnabled ? (
<View style={styles.ssoSection}>
<Button
title="Sign in with Authentik"
variant="secondary"
loading={loading}
disabled={!serverReady}
onPress={() => void handleAuthentikSignIn()}
/>
<View style={styles.dividerRow}>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
<Text style={[styles.dividerLabel, { color: colors.mutedForeground }]}>
or
</Text>
<View style={[styles.dividerLine, { backgroundColor: colors.border }]} />
</View>
</View>
) : null}
<View style={styles.form}> <View style={styles.form}>
<Input <Input
label="Email" label="Email"
@@ -145,13 +214,16 @@ export default function SignInScreen() {
/> />
</View> </View>
{!signupsDisabled ? (
<Text style={[styles.footer, { color: colors.mutedForeground }]}> <Text style={[styles.footer, { color: colors.mutedForeground }]}>
Don&apos;t have an account?{" "} Don&apos;t have an account?{" "}
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}> <Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
Create one Create one
</Link> </Link>
</Text> </Text>
) : null}
</Card> </Card>
</View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</FullScreen> </FullScreen>
@@ -169,22 +241,54 @@ const styles = StyleSheet.create({
container: { container: {
flexGrow: 1, flexGrow: 1,
justifyContent: "center", justifyContent: "center",
alignItems: "center",
padding: spacing.lg, padding: spacing.lg,
paddingBottom: spacing.md, paddingVertical: spacing.xl,
},
content: {
width: "100%",
maxWidth: 420,
}, },
card: { card: {
gap: spacing.lg, gap: spacing.lg,
}, },
header: { header: {
alignItems: "center",
gap: spacing.sm, gap: spacing.sm,
}, },
title: { title: {
fontSize: 24, fontSize: 24,
marginTop: spacing.sm, marginTop: spacing.sm,
textAlign: "center",
}, },
subtitle: { subtitle: {
fontSize: 14, fontSize: 14,
fontFamily: fonts.body, fontFamily: fonts.body,
textAlign: "center",
},
notice: {
fontSize: 13,
fontFamily: fonts.body,
textAlign: "center",
lineHeight: 18,
},
ssoSection: {
gap: spacing.md,
},
dividerRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
dividerLine: {
flex: 1,
height: StyleSheet.hairlineWidth,
},
dividerLabel: {
fontSize: 12,
fontFamily: fonts.bodyMedium,
textTransform: "uppercase",
letterSpacing: 0.6,
}, },
form: { form: {
gap: spacing.md, gap: spacing.md,
+3 -6
View File
@@ -1,5 +1,4 @@
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useState } from "react"; import { useState } from "react";
import { import {
Modal, Modal,
@@ -12,7 +11,8 @@ import {
import { fonts, radii, spacing } from "@/constants/theme"; import { fonts, radii, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext"; import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext"; import { useSession } from "@/contexts/AuthContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { formatServerHost } from "@/lib/server-mode"; import { formatServerHost } from "@/lib/server-mode";
@@ -34,7 +34,6 @@ function displayName(name: string, email: string) {
/** Header control to switch signed-in accounts or add another. */ /** Header control to switch signed-in accounts or add another. */
export function AccountSwitcher() { export function AccountSwitcher() {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const authClient = useAuthClient();
const { data: session } = useSession(); const { data: session } = useSession();
const { const {
accounts, accounts,
@@ -56,9 +55,7 @@ export function AccountSwitcher() {
async function handleAddAccount() { async function handleAddAccount() {
setOpen(false); setOpen(false);
await authClient.signOut(); await startAdditionalAccountSignIn(clearActiveAccount);
await clearActiveAccount();
router.replace("/(auth)/sign-in");
} }
async function handleSwitch(accountId: string) { async function handleSwitch(accountId: string) {
+7 -2
View File
@@ -18,6 +18,8 @@ import {
type AuthServerPickerProps = { type AuthServerPickerProps = {
onReadyChange?: (ready: boolean) => void; onReadyChange?: (ready: boolean) => void;
/** When true, picker sits inside the auth card with no outer margin. */
embedded?: boolean;
}; };
function modeSummary(mode: ServerMode, selfHostedUrl: string) { function modeSummary(mode: ServerMode, selfHostedUrl: string) {
@@ -26,7 +28,7 @@ function modeSummary(mode: ServerMode, selfHostedUrl: string) {
return host || "Self-hosted"; return host || "Self-hosted";
} }
export function AuthServerPicker({ onReadyChange }: AuthServerPickerProps) { export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServerPickerProps) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const { apiUrl, setInstanceUrl } = useAccounts(); const { apiUrl, setInstanceUrl } = useAccounts();
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@@ -93,7 +95,7 @@ export function AuthServerPicker({ onReadyChange }: AuthServerPickerProps) {
} }
return ( return (
<View style={styles.wrapper}> <View style={[styles.wrapper, embedded && styles.wrapperEmbedded]}>
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityState={{ expanded }} accessibilityState={{ expanded }}
@@ -182,6 +184,9 @@ const styles = StyleSheet.create({
gap: spacing.sm, gap: spacing.sm,
marginBottom: spacing.md, marginBottom: spacing.md,
}, },
wrapperEmbedded: {
marginBottom: 0,
},
trigger: { trigger: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
+3 -5
View File
@@ -1,22 +1,20 @@
import { StyleSheet, Text, View } from "react-native"; import { StyleSheet, Text, View } from "react-native";
import { fonts, spacing } from "@/constants/theme"; import { fonts } from "@/constants/theme";
import { tabLayout } from "@/lib/tab-layout"; import { tabLayout } from "@/lib/tab-layout";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { useTopChromeHeight } from "@/lib/top-chrome-insets";
type PageHeaderProps = { type PageHeaderProps = {
title: string; title: string;
subtitle: string; subtitle: string;
}; };
/** Title block — transparent, scrolls under TopChromeBar blur. */ /** Title block — scrolls with tab screen content. */
export function PageHeader({ title, subtitle }: PageHeaderProps) { export function PageHeader({ title, subtitle }: PageHeaderProps) {
const { colors } = useAppTheme(); const { colors } = useAppTheme();
const topChromeHeight = useTopChromeHeight();
return ( return (
<View style={[tabLayout.pageHeader, { paddingTop: topChromeHeight + spacing.md }]}> <View style={tabLayout.pageHeader}>
<Text style={[styles.title, { color: colors.foreground }]}>{title}</Text> <Text style={[styles.title, { color: colors.foreground }]}>{title}</Text>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>{subtitle}</Text> <Text style={[styles.subtitle, { color: colors.mutedForeground }]}>{subtitle}</Text>
</View> </View>
+3 -11
View File
@@ -1,7 +1,6 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { StyleSheet, View } from "react-native"; import { StyleSheet, View } from "react-native";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TopChromeBar } from "@/components/TopChromeBar"; import { TopChromeBar } from "@/components/TopChromeBar";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
@@ -10,23 +9,15 @@ type TabPageProps = {
children: ReactNode; children: ReactNode;
}; };
/** Tab root — floating blurred top chrome; children should be a TabScrollView. */ /** Tab root — pinned top chrome, scrollable body below. */
export function TabPage({ children }: TabPageProps) { export function TabPage({ children }: TabPageProps) {
const insets = useSafeAreaInsets();
const { isDark } = useAppTheme(); const { isDark } = useAppTheme();
return ( return (
<View style={styles.root}> <View style={styles.root}>
<StatusBar style={isDark ? "light" : "dark"} /> <StatusBar style={isDark ? "light" : "dark"} />
<View
style={[
styles.content,
{ paddingLeft: insets.left, paddingRight: insets.right },
]}
>
{children}
</View>
<TopChromeBar /> <TopChromeBar />
<View style={styles.content}>{children}</View>
</View> </View>
); );
} }
@@ -38,6 +29,7 @@ const styles = StyleSheet.create({
}, },
content: { content: {
flex: 1, flex: 1,
minHeight: 0,
backgroundColor: "transparent", backgroundColor: "transparent",
}, },
}); });
+16 -7
View File
@@ -1,16 +1,20 @@
import type { ReactNode } from "react"; import { useScrollToTop } from "expo-router";
import { useRef, type ReactNode } from "react";
import { Platform, ScrollView, type ScrollViewProps, StyleSheet, View } from "react-native"; import { Platform, ScrollView, type ScrollViewProps, StyleSheet, View } from "react-native";
import { tabLayout } from "@/lib/tab-layout"; import { tabLayout } from "@/lib/tab-layout";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets"; import { useTabScreenScrollPadding } from "@/lib/tab-bar-insets";
type TabScrollViewProps = ScrollViewProps & { type TabScrollViewProps = ScrollViewProps & {
/** Rendered above screen body — scrolls under the blurred top chrome. */ /** Rendered at the top of scroll content (scrolls with the page). */
header?: ReactNode; header?: ReactNode;
children: ReactNode; children: ReactNode;
}; };
/** Scroll view for native tab screens — content scrolls under the tab bar. */ /**
* Tab screen scroll view. Top chrome (logo / account) is pinned in TabPage;
* the page header and body scroll together here.
*/
export function TabScrollView({ export function TabScrollView({
header, header,
children, children,
@@ -18,18 +22,22 @@ export function TabScrollView({
style, style,
...props ...props
}: TabScrollViewProps) { }: TabScrollViewProps) {
const scrollPadding = useTabBarScrollPadding(); const scrollRef = useRef<ScrollView>(null);
const bottomPadding = useTabScreenScrollPadding();
useScrollToTop(scrollRef);
return ( return (
<ScrollView <ScrollView
ref={scrollRef}
style={[styles.scroll, style]} style={[styles.scroll, style]}
contentContainerStyle={[ contentContainerStyle={[
tabLayout.scrollContent, tabLayout.scrollContent,
{ paddingBottom: scrollPadding }, { paddingBottom: bottomPadding },
contentContainerStyle, contentContainerStyle,
]} ]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined} contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }} scrollIndicatorInsets={{ bottom: bottomPadding }}
{...props} {...props}
> >
{header} {header}
@@ -41,6 +49,7 @@ export function TabScrollView({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scroll: { scroll: {
flex: 1, flex: 1,
minHeight: 0,
backgroundColor: "transparent", backgroundColor: "transparent",
}, },
}); });
+1 -5
View File
@@ -45,11 +45,7 @@ export function TopChromeBar() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
host: { host: {
flexShrink: 0,
overflow: "hidden", overflow: "hidden",
position: "absolute",
top: 0,
left: 0,
right: 0,
zIndex: 10,
}, },
}); });
+5 -12
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Alert, Platform, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native"; import { Alert, Pressable, RefreshControl, StyleSheet, Text, View } from "react-native";
import { router } from "expo-router"; import { router } from "expo-router";
import { GlassSurface } from "@/components/GlassSurface"; import { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen"; import { LoadingScreen } from "@/components/LoadingScreen";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button"; import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card"; import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField"; import { DateTimeField } from "@/components/ui/DateTimeField";
@@ -11,8 +12,6 @@ import { Input } from "@/components/ui/Input";
import { SelectField } from "@/components/ui/SelectField"; import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme"; import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext"; import { useAppTheme } from "@/contexts/ThemeContext";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { tabLayout } from "@/lib/tab-layout";
import { formatDateTime } from "@/lib/format"; import { formatDateTime } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation"; import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette"; import type { ThemeColors } from "@/lib/theme-palette";
@@ -70,7 +69,6 @@ export function TimeClockPanel({
}, []); }, []);
const todayQuery = api.timeEntries.getAll.useQuery({ from: todayStart }); const todayQuery = api.timeEntries.getAll.useQuery({ from: todayStart });
const scrollPadding = useTabBarScrollPadding();
const clockIn = api.timeEntries.clockIn.useMutation({ const clockIn = api.timeEntries.clockIn.useMutation({
onSuccess: async () => { onSuccess: async () => {
@@ -252,11 +250,9 @@ export function TimeClockPanel({
.join(" · "); .join(" · ");
return ( return (
<ScrollView <TabScrollView
style={styles.scroll} style={styles.scroll}
contentContainerStyle={[tabLayout.scrollContent, { paddingBottom: scrollPadding }]} header={header}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={runningQuery.isRefetching} refreshing={runningQuery.isRefetching}
@@ -270,8 +266,6 @@ export function TimeClockPanel({
/> />
} }
> >
{header}
<View style={tabLayout.scrollBody}>
{running || !compact ? ( {running || !compact ? (
<GlassSurface style={running ? styles.runningCard : undefined}> <GlassSurface style={running ? styles.runningCard : undefined}>
<View style={styles.hero}> <View style={styles.hero}>
@@ -442,8 +436,7 @@ export function TimeClockPanel({
})} })}
</Card> </Card>
) : null} ) : null}
</View> </TabScrollView>
</ScrollView>
); );
} }
+16 -2
View File
@@ -50,6 +50,7 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
const [biometricAvailable, setBiometricAvailable] = useState(false); const [biometricAvailable, setBiometricAvailable] = useState(false);
const [biometricLabel, setBiometricLabel] = useState("Biometrics"); const [biometricLabel, setBiometricLabel] = useState("Biometrics");
const wasBackgrounded = useRef(false); const wasBackgrounded = useRef(false);
const biometricUnlockInProgress = useRef(false);
const hydrated = useRef(false); const hydrated = useRef(false);
useEffect(() => { useEffect(() => {
@@ -105,11 +106,17 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => { const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
if (!hydrated.current || !enabled || !activeAccountId) return; if (!hydrated.current || !enabled || !activeAccountId) return;
if (nextState === "background" || nextState === "inactive") { // Only true backgrounding should re-lock — `inactive` fires during Face ID,
// Control Center, and other system sheets and must not trigger another lock.
if (nextState === "background") {
wasBackgrounded.current = true; wasBackgrounded.current = true;
} }
if (nextState === "active" && wasBackgrounded.current) { if (
nextState === "active" &&
wasBackgrounded.current &&
!biometricUnlockInProgress.current
) {
wasBackgrounded.current = false; wasBackgrounded.current = false;
setIsLocked(true); setIsLocked(true);
} }
@@ -125,6 +132,7 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
if (!stored || stored !== pin) { if (!stored || stored !== pin) {
return false; return false;
} }
wasBackgrounded.current = false;
setIsLocked(false); setIsLocked(false);
return true; return true;
}, },
@@ -136,6 +144,8 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
return false; return false;
} }
biometricUnlockInProgress.current = true;
try {
const result = await LocalAuthentication.authenticateAsync({ const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Unlock beenvoice", promptMessage: "Unlock beenvoice",
cancelLabel: "Use PIN", cancelLabel: "Use PIN",
@@ -152,8 +162,12 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
setBiometricEnabledState(true); setBiometricEnabledState(true);
} }
wasBackgrounded.current = false;
setIsLocked(false); setIsLocked(false);
return true; return true;
} finally {
biometricUnlockInProgress.current = false;
}
}, [biometricAvailable, biometricEnabled, activeAccountId]); }, [biometricAvailable, biometricEnabled, activeAccountId]);
const enableLock = useCallback( const enableLock = useCallback(
+16 -11
View File
@@ -1,5 +1,6 @@
import { expoClient } from "@better-auth/expo/client"; import { expoClient } from "@better-auth/expo/client";
import { createAuthClient } from "better-auth/react"; import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { import {
createContext, createContext,
@@ -10,6 +11,20 @@ import {
type AuthClient = ReturnType<typeof createAuthClient>; type AuthClient = ReturnType<typeof createAuthClient>;
function createAppAuthClient(apiUrl: string, storagePrefix: string): AuthClient {
return createAuthClient({
baseURL: apiUrl,
plugins: [
expoClient({
scheme: "beenvoice",
storagePrefix,
storage: SecureStore,
}),
genericOAuthClient(),
],
});
}
const AuthContext = createContext<AuthClient | null>(null); const AuthContext = createContext<AuthClient | null>(null);
export function AuthProvider({ export function AuthProvider({
@@ -22,17 +37,7 @@ export function AuthProvider({
children: ReactNode; children: ReactNode;
}) { }) {
const client = useMemo( const client = useMemo(
() => () => createAppAuthClient(apiUrl, storagePrefix),
createAuthClient({
baseURL: apiUrl,
plugins: [
expoClient({
scheme: "beenvoice",
storagePrefix,
storage: SecureStore,
}),
],
}),
[apiUrl, storagePrefix], [apiUrl, storagePrefix],
); );
+12
View File
@@ -0,0 +1,12 @@
import { router } from "expo-router";
import { prepareForAdditionalSignIn } from "@/lib/auth-storage";
/** Switch to guest mode and open sign-in without wiping other saved accounts. */
export async function startAdditionalAccountSignIn(
clearActiveAccount: () => Promise<void>,
) {
await clearActiveAccount();
await prepareForAdditionalSignIn();
router.replace("/(auth)/sign-in");
}
+21
View File
@@ -0,0 +1,21 @@
export type AuthCapabilities = {
authentik: boolean;
signupsDisabled: boolean;
};
const DEFAULT_CAPABILITIES: AuthCapabilities = {
authentik: false,
signupsDisabled: false,
};
export async function fetchAuthCapabilities(apiUrl: string): Promise<AuthCapabilities> {
const base = apiUrl.replace(/\/$/, "");
try {
const response = await fetch(`${base}/api/auth/capabilities`);
if (!response.ok) return DEFAULT_CAPABILITIES;
return (await response.json()) as AuthCapabilities;
} catch {
return DEFAULT_CAPABILITIES;
}
}
+15
View File
@@ -0,0 +1,15 @@
import type { createAuthClient } from "better-auth/react";
type AuthClient = ReturnType<typeof createAuthClient>;
type OAuth2SignIn = (input: {
providerId: string;
callbackURL: string;
}) => Promise<{ error?: { message?: string } | null }>;
export async function signInWithAuthentik(authClient: AuthClient, callbackURL: string) {
return (authClient.signIn as unknown as { oauth2: OAuth2SignIn }).oauth2({
providerId: "authentik",
callbackURL,
});
}
+27
View File
@@ -41,6 +41,33 @@ export async function migrateAuthStorage(fromPrefix: string, toPrefix: string):
); );
} }
export async function clearAuthStorage(prefix: string): Promise<void> {
await Promise.all(
AUTH_STORAGE_SUFFIXES.map(async (suffix) => {
const key = storageKeyForPrefix(prefix, suffix);
const value = await SecureStore.getItemAsync(key);
if (value?.startsWith(CHUNK_MARKER)) {
const count = Number(value.slice(CHUNK_MARKER.length));
if (Number.isInteger(count) && count > 0) {
await Promise.all(
Array.from({ length: count }, (_, index) =>
SecureStore.deleteItemAsync(`${key}.${index}`),
),
);
}
}
await SecureStore.deleteItemAsync(key);
}),
);
}
/** Clears guest auth storage before signing into an additional account. */
export async function prepareForAdditionalSignIn(): Promise<void> {
await clearAuthStorage(GUEST_AUTH_STORAGE_PREFIX);
}
export async function finalizeAuthenticatedAccount(input: { export async function finalizeAuthenticatedAccount(input: {
apiUrl: string; apiUrl: string;
userId: string; userId: string;
+34
View File
@@ -0,0 +1,34 @@
import type { createAuthClient } from "better-auth/react";
import { finalizeAuthenticatedAccount } from "@/lib/auth-storage";
type AuthClient = ReturnType<typeof createAuthClient>;
export async function completeSignInAfterAuth(
authClient: AuthClient,
input: {
apiUrl: string;
activeAccountId: string | null;
registerAccount: (account: {
instanceUrl: string;
userId: string;
email: string;
name: string;
}) => Promise<unknown>;
},
): Promise<boolean> {
const session = await authClient.getSession();
const user = session.data?.user;
if (!user) return false;
await finalizeAuthenticatedAccount({
apiUrl: input.apiUrl,
userId: user.id,
email: user.email,
name: user.name,
activeAccountId: input.activeAccountId,
registerAccount: input.registerAccount,
});
return true;
}
+11
View File
@@ -50,6 +50,17 @@ export function useFloatingActionBottom(): number {
return tabBar + homeIndicator + spacing.xs; return tabBar + homeIndicator + spacing.xs;
} }
/**
* Bottom padding for tab-root ScrollViews (Dashboard, Invoices, etc.).
* Uses full tab-bar clearance — do not trim; undershooting hides content under the bar.
*/
export function useTabScreenScrollPadding(): number {
const { bottom: homeIndicator } = useSafeAreaInsets();
const tabBar = useNativeTabBarHeight();
return tabBar + homeIndicator + spacing.sm;
}
/** @deprecated Use useTabBarScrollPadding */ /** @deprecated Use useTabBarScrollPadding */
export function useTabBarInset() { export function useTabBarInset() {
return useTabBarScrollPadding(); return useTabBarScrollPadding();
+2 -1
View File
@@ -6,12 +6,13 @@ import { spacing } from "@/constants/theme";
export const tabLayout = StyleSheet.create({ export const tabLayout = StyleSheet.create({
pageHeader: { pageHeader: {
gap: 4, gap: 4,
paddingTop: spacing.md,
paddingBottom: spacing.md,
}, },
scrollContent: { scrollContent: {
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
}, },
scrollBody: { scrollBody: {
gap: spacing.md, gap: spacing.md,
marginTop: spacing.sm,
}, },
}); });
+18 -13
View File
@@ -1,4 +1,4 @@
import { HStack, Image, Text } from "@expo/ui/swift-ui"; import { HStack, Image, Spacer, Text } from "@expo/ui/swift-ui";
import { import {
font, font,
foregroundStyle, foregroundStyle,
@@ -17,8 +17,8 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
const green = "green"; const green = "green";
const title = props.description.trim() || "Clock In"; const title = props.description.trim() || "Clock In";
const subtitle = [props.clientName, props.invoiceLabel].filter(Boolean).join(" · "); const clientLabel = props.clientName.trim() || title;
const detailLine = subtitle ? `${title}\n${subtitle}` : title; const subtitle = props.invoiceLabel.trim();
const timerMods = [ const timerMods = [
font({ design: "monospaced", weight: "bold", size: 20 }), font({ design: "monospaced", weight: "bold", size: 20 }),
@@ -34,41 +34,43 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
lineLimit(1), lineLimit(1),
minimumScaleFactor(0.8), minimumScaleFactor(0.8),
]; ];
const brandMods = [ const clientMods = [
font({ weight: "semibold", size: 13 }), font({ weight: "semibold", size: 13 }),
foregroundStyle(green), foregroundStyle({ type: "hierarchical", style: "primary" }),
lineLimit(1), lineLimit(1),
minimumScaleFactor(0.85), minimumScaleFactor(0.85),
]; ];
const detailMods = [ const subtitleMods = [
font({ weight: "medium", size: 12 }), font({ size: 11 }),
foregroundStyle({ type: "hierarchical", style: "secondary" }), foregroundStyle({ type: "hierarchical", style: "secondary" }),
lineLimit(2), lineLimit(1),
minimumScaleFactor(0.85), minimumScaleFactor(0.85),
]; ];
return { return {
banner: ( banner: (
<HStack alignment="center" spacing={10} modifiers={[padding({ all: 12 })]}> <HStack alignment="center" spacing={8} modifiers={[padding({ horizontal: 14, vertical: 12 })]}>
<Image <Image
systemName="dollarsign.circle.fill" systemName="dollarsign.circle.fill"
color={green} color={green}
size={22} size={22}
modifiers={[widgetAccentedRenderingMode("fullColor")]} modifiers={[widgetAccentedRenderingMode("fullColor")]}
/> />
<Text modifiers={brandMods}>beenvoice</Text> <Text modifiers={clientMods}>{clientLabel}</Text>
<Spacer minLength={12} />
<Text modifiers={timerMods}>{props.elapsedShort}</Text> <Text modifiers={timerMods}>{props.elapsedShort}</Text>
</HStack> </HStack>
), ),
bannerSmall: ( bannerSmall: (
<HStack alignment="center" spacing={8} modifiers={[padding({ all: 10 })]}> <HStack alignment="center" spacing={8} modifiers={[padding({ horizontal: 12, vertical: 10 })]}>
<Image <Image
systemName="dollarsign.circle.fill" systemName="dollarsign.circle.fill"
color={green} color={green}
size={18} size={18}
modifiers={[widgetAccentedRenderingMode("fullColor")]} modifiers={[widgetAccentedRenderingMode("fullColor")]}
/> />
<Text modifiers={brandMods}>beenvoice</Text> <Text modifiers={clientMods}>{clientLabel}</Text>
<Spacer minLength={8} />
<Text modifiers={compactTimerMods}>{props.elapsedShort}</Text> <Text modifiers={compactTimerMods}>{props.elapsedShort}</Text>
</HStack> </HStack>
), ),
@@ -97,8 +99,11 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
modifiers={[widgetAccentedRenderingMode("fullColor")]} modifiers={[widgetAccentedRenderingMode("fullColor")]}
/> />
), ),
expandedCenter: <Text modifiers={clientMods}>{clientLabel}</Text>,
expandedTrailing: <Text modifiers={timerMods}>{props.elapsedShort}</Text>, expandedTrailing: <Text modifiers={timerMods}>{props.elapsedShort}</Text>,
expandedBottom: <Text modifiers={detailMods}>{detailLine}</Text>, expandedBottom: (
<Text modifiers={subtitleMods}>{subtitle || "beenvoice"}</Text>
),
}; };
} }