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
+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,