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
co-authored by Cursor
parent 3daf123399
commit 0b2d65a4e9
21 changed files with 449 additions and 200 deletions
+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>
);
}