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
backgroundColor={Platform.OS === "android" ? colors.background : undefined}
>
<NativeTabs.Trigger name="index" disableAutomaticContentInsets contentStyle={tabContentStyle}>
<NativeTabs.Trigger name="index" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "square.grid.2x2", selected: "square.grid.2x2.fill" }}
md="grid_view"
@@ -39,7 +39,7 @@ export default function AppLayout() {
<NativeTabs.Trigger.Label>Dashboard</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="timer" disableAutomaticContentInsets contentStyle={tabContentStyle}>
<NativeTabs.Trigger name="timer" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "timer", selected: "timer" }}
md="timer"
@@ -47,7 +47,7 @@ export default function AppLayout() {
<NativeTabs.Trigger.Label>Timer</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="entities" disableAutomaticContentInsets contentStyle={tabContentStyle}>
<NativeTabs.Trigger name="entities" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "square.stack.3d.up", selected: "square.stack.3d.up.fill" }}
md="corporate_fare"
@@ -55,7 +55,7 @@ export default function AppLayout() {
<NativeTabs.Trigger.Label>Entities</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="invoices" disableAutomaticContentInsets contentStyle={tabContentStyle}>
<NativeTabs.Trigger name="invoices" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "doc.text", selected: "doc.text.fill" }}
md="description"
@@ -63,7 +63,7 @@ export default function AppLayout() {
<NativeTabs.Trigger.Label>Invoices</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>
<NativeTabs.Trigger name="settings" disableAutomaticContentInsets contentStyle={tabContentStyle}>
<NativeTabs.Trigger name="settings" contentStyle={tabContentStyle} disableAutomaticContentInsets>
<NativeTabs.Trigger.Icon
sf={{ default: "gearshape", selected: "gearshape.fill" }}
md="settings"
+15 -18
View File
@@ -18,6 +18,7 @@ import { useAccounts } from "@/contexts/AccountsContext";
import { useAppLock } from "@/contexts/AppLockContext";
import { useAuthClient, useSession } from "@/contexts/AuthContext";
import { type ColorMode, useAppTheme } from "@/contexts/ThemeContext";
import { startAdditionalAccountSignIn } from "@/lib/add-account";
import { api } from "@/lib/trpc";
const THEME_OPTIONS: { value: ColorMode; label: string }[] = [
@@ -251,11 +252,7 @@ export default function SettingsScreen() {
<Button
title="Add another account"
variant="secondary"
onPress={async () => {
await authClient.signOut();
await clearActiveAccount();
router.replace("/(auth)/sign-in");
}}
onPress={() => void startAdditionalAccountSignIn(clearActiveAccount)}
/>
{accounts.length > 1 ? (
<Text style={[styles.meta, { color: colors.mutedForeground }]}>
@@ -336,6 +333,19 @@ export default function SettingsScreen() {
</View>
</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
accessibilityRole="button"
accessibilityState={{ expanded: showAdvanced }}
@@ -359,19 +369,6 @@ export default function SettingsScreen() {
</Card>
) : 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}>
<Button title="Sign Out" variant="danger" onPress={confirmSignOut} />
</View>
+30 -17
View File
@@ -20,7 +20,7 @@ import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
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";
export default function RegisterScreen() {
@@ -82,11 +82,8 @@ export default function RegisterScreen() {
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
await finalizeAuthenticatedAccount({
await completeSignInAfterAuth(authClient, {
apiUrl,
userId: user.id,
email: user.email,
name: user.name,
activeAccountId,
registerAccount: saveAccount,
});
@@ -109,15 +106,17 @@ export default function RegisterScreen() {
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Create your account</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Get started today
</Text>
</View>
<View style={styles.content}>
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Create your account</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Get started today
</Text>
</View>
<AuthServerPicker onReadyChange={setServerReady} embedded />
<View style={styles.form}>
<View style={styles.row}>
@@ -190,6 +189,7 @@ export default function RegisterScreen() {
</Link>
</Text>
</Card>
</View>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
@@ -203,17 +203,30 @@ const styles = StyleSheet.create({
container: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
paddingBottom: spacing.md,
paddingVertical: spacing.xl,
},
content: {
width: "100%",
maxWidth: 420,
},
card: {
gap: spacing.lg,
},
header: { gap: spacing.sm },
title: { fontSize: 24, marginTop: spacing.sm },
header: {
alignItems: "center",
gap: spacing.sm,
},
title: {
fontSize: 24,
marginTop: spacing.sm,
textAlign: "center",
},
subtitle: {
fontSize: 14,
fontFamily: fonts.body,
textAlign: "center",
},
form: { gap: spacing.md },
row: { flexDirection: "row", gap: spacing.md },
+173 -69
View File
@@ -1,5 +1,6 @@
import { Link, router } from "expo-router";
import { useState } from "react";
import * as Linking from "expo-linking";
import { useEffect, useState } from "react";
import {
KeyboardAvoidingView,
Platform,
@@ -20,7 +21,9 @@ import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
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";
export default function SignInScreen() {
@@ -32,8 +35,24 @@ export default function SignInScreen() {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [serverReady, setServerReady] = useState(true);
const [authentikEnabled, setAuthentikEnabled] = useState(false);
const [signupsDisabled, setSignupsDisabled] = useState(false);
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()
? "Email is required"
: isValidEmail(email)
@@ -42,6 +61,18 @@ export default function SignInScreen() {
const passwordValidationError = password.trim() ? undefined : "Password is required";
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() {
markSubmitted();
if (!canSignIn) return;
@@ -64,18 +95,29 @@ export default function SignInScreen() {
return;
}
const session = await authClient.getSession();
const user = session.data?.user;
if (user) {
await finalizeAuthenticatedAccount({
apiUrl,
userId: user.id,
email: user.email,
name: user.name,
activeAccountId,
registerAccount,
});
await finishSignIn();
} finally {
setLoading(false);
}
}
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 {
setLoading(false);
}
@@ -92,66 +134,96 @@ export default function SignInScreen() {
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<AuthServerPicker onReadyChange={setServerReady} />
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Welcome back</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Sign in to manage invoices on the go
</Text>
</View>
<View style={styles.form}>
<Input
label="Email"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
secureTextEntry
autoComplete="password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
required
error={visible("password") ? passwordValidationError : undefined}
/>
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
Forgot password?
<View style={styles.content}>
<Card style={styles.card}>
<View style={styles.header}>
<Logo size="lg" />
<HeadingText style={styles.title}>Welcome back</HeadingText>
<Text style={[styles.subtitle, { color: colors.mutedForeground }]}>
Sign in to manage invoices on the go
</Text>
</Pressable>
</View>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
<AuthServerPicker onReadyChange={setServerReady} embedded />
{signupsDisabled ? (
<Text style={[styles.notice, { color: colors.mutedForeground }]}>
New account registration is currently disabled on this server.
</Text>
) : null}
<Button
title="Sign In"
loading={loading}
disabled={!canSignIn}
onPress={handleSignIn}
/>
</View>
{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}
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Don&apos;t have an account?{" "}
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
Create one
</Link>
</Text>
</Card>
<View style={styles.form}>
<Input
label="Email"
autoCapitalize="none"
autoComplete="email"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
onBlur={() => touch("email")}
placeholder="you@example.com"
required
error={visible("email") ? emailValidationError : undefined}
/>
<Input
label="Password"
secureTextEntry
autoComplete="password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
required
error={visible("password") ? passwordValidationError : undefined}
/>
<Pressable onPress={() => router.push("/(auth)/forgot-password")}>
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
Forgot password?
</Text>
</Pressable>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button
title="Sign In"
loading={loading}
disabled={!canSignIn}
onPress={handleSignIn}
/>
</View>
{!signupsDisabled ? (
<Text style={[styles.footer, { color: colors.mutedForeground }]}>
Don&apos;t have an account?{" "}
<Link href="/(auth)/register" style={[styles.link, { color: colors.foreground }]}>
Create one
</Link>
</Text>
) : null}
</Card>
</View>
</ScrollView>
</KeyboardAvoidingView>
</FullScreen>
@@ -169,22 +241,54 @@ const styles = StyleSheet.create({
container: {
flexGrow: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
paddingBottom: spacing.md,
paddingVertical: spacing.xl,
},
content: {
width: "100%",
maxWidth: 420,
},
card: {
gap: spacing.lg,
},
header: {
alignItems: "center",
gap: spacing.sm,
},
title: {
fontSize: 24,
marginTop: spacing.sm,
textAlign: "center",
},
subtitle: {
fontSize: 14,
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: {
gap: spacing.md,
+3 -6
View File
@@ -1,5 +1,4 @@
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useState } from "react";
import {
Modal,
@@ -12,7 +11,8 @@ import {
import { fonts, radii, spacing } from "@/constants/theme";
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 { 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. */
export function AccountSwitcher() {
const { colors } = useAppTheme();
const authClient = useAuthClient();
const { data: session } = useSession();
const {
accounts,
@@ -56,9 +55,7 @@ export function AccountSwitcher() {
async function handleAddAccount() {
setOpen(false);
await authClient.signOut();
await clearActiveAccount();
router.replace("/(auth)/sign-in");
await startAdditionalAccountSignIn(clearActiveAccount);
}
async function handleSwitch(accountId: string) {
+7 -2
View File
@@ -18,6 +18,8 @@ import {
type AuthServerPickerProps = {
onReadyChange?: (ready: boolean) => void;
/** When true, picker sits inside the auth card with no outer margin. */
embedded?: boolean;
};
function modeSummary(mode: ServerMode, selfHostedUrl: string) {
@@ -26,7 +28,7 @@ function modeSummary(mode: ServerMode, selfHostedUrl: string) {
return host || "Self-hosted";
}
export function AuthServerPicker({ onReadyChange }: AuthServerPickerProps) {
export function AuthServerPicker({ onReadyChange, embedded = false }: AuthServerPickerProps) {
const { colors } = useAppTheme();
const { apiUrl, setInstanceUrl } = useAccounts();
const [expanded, setExpanded] = useState(false);
@@ -93,7 +95,7 @@ export function AuthServerPicker({ onReadyChange }: AuthServerPickerProps) {
}
return (
<View style={styles.wrapper}>
<View style={[styles.wrapper, embedded && styles.wrapperEmbedded]}>
<Pressable
accessibilityRole="button"
accessibilityState={{ expanded }}
@@ -182,6 +184,9 @@ const styles = StyleSheet.create({
gap: spacing.sm,
marginBottom: spacing.md,
},
wrapperEmbedded: {
marginBottom: 0,
},
trigger: {
flexDirection: "row",
alignItems: "center",
+3 -5
View File
@@ -1,22 +1,20 @@
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 { useAppTheme } from "@/contexts/ThemeContext";
import { useTopChromeHeight } from "@/lib/top-chrome-insets";
type PageHeaderProps = {
title: string;
subtitle: string;
};
/** Title block — transparent, scrolls under TopChromeBar blur. */
/** Title block — scrolls with tab screen content. */
export function PageHeader({ title, subtitle }: PageHeaderProps) {
const { colors } = useAppTheme();
const topChromeHeight = useTopChromeHeight();
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.subtitle, { color: colors.mutedForeground }]}>{subtitle}</Text>
</View>
+3 -11
View File
@@ -1,7 +1,6 @@
import type { ReactNode } from "react";
import { StyleSheet, View } from "react-native";
import { StatusBar } from "expo-status-bar";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { TopChromeBar } from "@/components/TopChromeBar";
import { useAppTheme } from "@/contexts/ThemeContext";
@@ -10,23 +9,15 @@ type TabPageProps = {
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) {
const insets = useSafeAreaInsets();
const { isDark } = useAppTheme();
return (
<View style={styles.root}>
<StatusBar style={isDark ? "light" : "dark"} />
<View
style={[
styles.content,
{ paddingLeft: insets.left, paddingRight: insets.right },
]}
>
{children}
</View>
<TopChromeBar />
<View style={styles.content}>{children}</View>
</View>
);
}
@@ -38,6 +29,7 @@ const styles = StyleSheet.create({
},
content: {
flex: 1,
minHeight: 0,
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 { tabLayout } from "@/lib/tab-layout";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { useTabScreenScrollPadding } from "@/lib/tab-bar-insets";
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;
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({
header,
children,
@@ -18,18 +22,22 @@ export function TabScrollView({
style,
...props
}: TabScrollViewProps) {
const scrollPadding = useTabBarScrollPadding();
const scrollRef = useRef<ScrollView>(null);
const bottomPadding = useTabScreenScrollPadding();
useScrollToTop(scrollRef);
return (
<ScrollView
ref={scrollRef}
style={[styles.scroll, style]}
contentContainerStyle={[
tabLayout.scrollContent,
{ paddingBottom: scrollPadding },
{ paddingBottom: bottomPadding },
contentContainerStyle,
]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
scrollIndicatorInsets={{ bottom: bottomPadding }}
{...props}
>
{header}
@@ -41,6 +49,7 @@ export function TabScrollView({
const styles = StyleSheet.create({
scroll: {
flex: 1,
minHeight: 0,
backgroundColor: "transparent",
},
});
+1 -5
View File
@@ -45,11 +45,7 @@ export function TopChromeBar() {
const styles = StyleSheet.create({
host: {
flexShrink: 0,
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 { 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 { GlassSurface } from "@/components/GlassSurface";
import { LoadingScreen } from "@/components/LoadingScreen";
import { TabScrollView } from "@/components/TabScrollView";
import { Button } from "@/components/ui/Button";
import { Card } from "@/components/ui/Card";
import { DateTimeField } from "@/components/ui/DateTimeField";
@@ -11,8 +12,6 @@ import { Input } from "@/components/ui/Input";
import { SelectField } from "@/components/ui/SelectField";
import { fonts, spacing } from "@/constants/theme";
import { useAppTheme } from "@/contexts/ThemeContext";
import { useTabBarScrollPadding } from "@/lib/tab-bar-insets";
import { tabLayout } from "@/lib/tab-layout";
import { formatDateTime } from "@/lib/format";
import { parseNonNegativeNumber } from "@/lib/form-validation";
import type { ThemeColors } from "@/lib/theme-palette";
@@ -70,7 +69,6 @@ export function TimeClockPanel({
}, []);
const todayQuery = api.timeEntries.getAll.useQuery({ from: todayStart });
const scrollPadding = useTabBarScrollPadding();
const clockIn = api.timeEntries.clockIn.useMutation({
onSuccess: async () => {
@@ -252,11 +250,9 @@ export function TimeClockPanel({
.join(" · ");
return (
<ScrollView
<TabScrollView
style={styles.scroll}
contentContainerStyle={[tabLayout.scrollContent, { paddingBottom: scrollPadding }]}
contentInsetAdjustmentBehavior={Platform.OS === "ios" ? "never" : undefined}
scrollIndicatorInsets={{ bottom: scrollPadding }}
header={header}
refreshControl={
<RefreshControl
refreshing={runningQuery.isRefetching}
@@ -270,8 +266,6 @@ export function TimeClockPanel({
/>
}
>
{header}
<View style={tabLayout.scrollBody}>
{running || !compact ? (
<GlassSurface style={running ? styles.runningCard : undefined}>
<View style={styles.hero}>
@@ -442,8 +436,7 @@ export function TimeClockPanel({
})}
</Card>
) : null}
</View>
</ScrollView>
</TabScrollView>
);
}
+32 -18
View File
@@ -50,6 +50,7 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
const [biometricAvailable, setBiometricAvailable] = useState(false);
const [biometricLabel, setBiometricLabel] = useState("Biometrics");
const wasBackgrounded = useRef(false);
const biometricUnlockInProgress = useRef(false);
const hydrated = useRef(false);
useEffect(() => {
@@ -105,11 +106,17 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
const subscription = AppState.addEventListener("change", (nextState: AppStateStatus) => {
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;
}
if (nextState === "active" && wasBackgrounded.current) {
if (
nextState === "active" &&
wasBackgrounded.current &&
!biometricUnlockInProgress.current
) {
wasBackgrounded.current = false;
setIsLocked(true);
}
@@ -125,6 +132,7 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
if (!stored || stored !== pin) {
return false;
}
wasBackgrounded.current = false;
setIsLocked(false);
return true;
},
@@ -136,24 +144,30 @@ export function AppLockProvider({ children }: { children: ReactNode }) {
return false;
}
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Unlock beenvoice",
cancelLabel: "Use PIN",
disableDeviceFallback: false,
biometricsSecurityLevel: "weak",
});
biometricUnlockInProgress.current = true;
try {
const result = await LocalAuthentication.authenticateAsync({
promptMessage: "Unlock beenvoice",
cancelLabel: "Use PIN",
disableDeviceFallback: false,
biometricsSecurityLevel: "weak",
});
if (!result.success) {
return false;
if (!result.success) {
return false;
}
if (!biometricEnabled) {
await setBiometricEnabled(activeAccountId, true);
setBiometricEnabledState(true);
}
wasBackgrounded.current = false;
setIsLocked(false);
return true;
} finally {
biometricUnlockInProgress.current = false;
}
if (!biometricEnabled) {
await setBiometricEnabled(activeAccountId, true);
setBiometricEnabledState(true);
}
setIsLocked(false);
return true;
}, [biometricAvailable, biometricEnabled, activeAccountId]);
const enableLock = useCallback(
+16 -11
View File
@@ -1,5 +1,6 @@
import { expoClient } from "@better-auth/expo/client";
import { createAuthClient } from "better-auth/react";
import { genericOAuthClient } from "better-auth/client/plugins";
import * as SecureStore from "expo-secure-store";
import {
createContext,
@@ -10,6 +11,20 @@ import {
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);
export function AuthProvider({
@@ -22,17 +37,7 @@ export function AuthProvider({
children: ReactNode;
}) {
const client = useMemo(
() =>
createAuthClient({
baseURL: apiUrl,
plugins: [
expoClient({
scheme: "beenvoice",
storagePrefix,
storage: SecureStore,
}),
],
}),
() => createAppAuthClient(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: {
apiUrl: 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;
}
/**
* 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 */
export function useTabBarInset() {
return useTabBarScrollPadding();
+2 -1
View File
@@ -6,12 +6,13 @@ import { spacing } from "@/constants/theme";
export const tabLayout = StyleSheet.create({
pageHeader: {
gap: 4,
paddingTop: spacing.md,
paddingBottom: spacing.md,
},
scrollContent: {
paddingHorizontal: spacing.md,
},
scrollBody: {
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 {
font,
foregroundStyle,
@@ -17,8 +17,8 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
const green = "green";
const title = props.description.trim() || "Clock In";
const subtitle = [props.clientName, props.invoiceLabel].filter(Boolean).join(" · ");
const detailLine = subtitle ? `${title}\n${subtitle}` : title;
const clientLabel = props.clientName.trim() || title;
const subtitle = props.invoiceLabel.trim();
const timerMods = [
font({ design: "monospaced", weight: "bold", size: 20 }),
@@ -34,41 +34,43 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
lineLimit(1),
minimumScaleFactor(0.8),
];
const brandMods = [
const clientMods = [
font({ weight: "semibold", size: 13 }),
foregroundStyle(green),
foregroundStyle({ type: "hierarchical", style: "primary" }),
lineLimit(1),
minimumScaleFactor(0.85),
];
const detailMods = [
font({ weight: "medium", size: 12 }),
const subtitleMods = [
font({ size: 11 }),
foregroundStyle({ type: "hierarchical", style: "secondary" }),
lineLimit(2),
lineLimit(1),
minimumScaleFactor(0.85),
];
return {
banner: (
<HStack alignment="center" spacing={10} modifiers={[padding({ all: 12 })]}>
<HStack alignment="center" spacing={8} modifiers={[padding({ horizontal: 14, vertical: 12 })]}>
<Image
systemName="dollarsign.circle.fill"
color={green}
size={22}
modifiers={[widgetAccentedRenderingMode("fullColor")]}
/>
<Text modifiers={brandMods}>beenvoice</Text>
<Text modifiers={clientMods}>{clientLabel}</Text>
<Spacer minLength={12} />
<Text modifiers={timerMods}>{props.elapsedShort}</Text>
</HStack>
),
bannerSmall: (
<HStack alignment="center" spacing={8} modifiers={[padding({ all: 10 })]}>
<HStack alignment="center" spacing={8} modifiers={[padding({ horizontal: 12, vertical: 10 })]}>
<Image
systemName="dollarsign.circle.fill"
color={green}
size={18}
modifiers={[widgetAccentedRenderingMode("fullColor")]}
/>
<Text modifiers={brandMods}>beenvoice</Text>
<Text modifiers={clientMods}>{clientLabel}</Text>
<Spacer minLength={8} />
<Text modifiers={compactTimerMods}>{props.elapsedShort}</Text>
</HStack>
),
@@ -97,8 +99,11 @@ function TimeClockActivity(props: TimeClockActivityProps, _environment: LiveActi
modifiers={[widgetAccentedRenderingMode("fullColor")]}
/>
),
expandedCenter: <Text modifiers={clientMods}>{clientLabel}</Text>,
expandedTrailing: <Text modifiers={timerMods}>{props.elapsedShort}</Text>,
expandedBottom: <Text modifiers={detailMods}>{detailLine}</Text>,
expandedBottom: (
<Text modifiers={subtitleMods}>{subtitle || "beenvoice"}</Text>
),
};
}