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:
@@ -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
@@ -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
@@ -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
@@ -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't have an account?{" "}
|
Don'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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user