Files
beenvoice-app/app/(auth)/sign-in.tsx
T

232 lines
6.9 KiB
TypeScript

import { Link, router } from "expo-router";
import * as Linking from "expo-linking";
import { useEffect, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
import { AuthCard } from "@/components/auth/AuthCard";
import { AuthCardHeader } from "@/components/auth/AuthCardHeader";
import { AuthDivider } from "@/components/auth/AuthDivider";
import { AuthNotice } from "@/components/auth/AuthNotice";
import { AuthScreenLayout } from "@/components/auth/AuthScreenLayout";
import { AuthServerPicker } from "@/components/AuthServerPicker";
import { LegalAgreementNotice } from "@/components/legal/LegalAgreementNotice";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { fonts, spacing } from "@/constants/theme";
import { useAccounts } from "@/contexts/AccountsContext";
import { useAuthClient } from "@/contexts/AuthContext";
import { useAppTheme } from "@/contexts/ThemeContext";
import { fetchAuthCapabilities } from "@/lib/auth-capabilities";
import { signInWithAuthentik } from "@/lib/auth-oauth";
import { prepareAuthScreenSession } from "@/lib/auth-session";
import { completeSignInAfterAuth } from "@/lib/complete-sign-in";
import { formatAuthErrorMessage } from "@/lib/trpc-errors";
import { isRequiredString, isValidEmail, useFieldVisibility } from "@/lib/form-validation";
export default function SignInScreen() {
const authClient = useAuthClient();
const { apiUrl, activeAccountId, clearActiveAccount, registerAccount } = useAccounts();
const { colors } = useAppTheme();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
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(() => {
void prepareAuthScreenSession(authClient, activeAccountId, clearActiveAccount);
}, [authClient, activeAccountId, clearActiveAccount]);
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)
? undefined
: "Enter a valid email";
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;
setError(null);
setLoading(true);
try {
const { error: signInError } = await authClient.signIn.email({
email: email.trim(),
password,
});
if (signInError) {
setError(formatAuthErrorMessage(signInError));
return;
}
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(formatAuthErrorMessage(oauthError));
return;
}
await finishSignIn();
} finally {
setLoading(false);
}
}
return (
<AuthScreenLayout>
<AuthCard>
<AuthCardHeader title="Welcome back" description="Sign in to your workspace" />
<AuthServerPicker onReadyChange={setServerReady} embedded />
{signupsDisabled ? (
<AuthNotice>New account registration is currently disabled.</AuthNotice>
) : null}
{authentikEnabled ? (
<View style={styles.ssoSection}>
<Button
title="Sign in with Authentik"
variant="secondary"
loading={loading}
disabled={!serverReady}
onPress={() => void handleAuthentikSignIn()}
/>
<AuthDivider />
</View>
) : null}
<View style={styles.form}>
<Input
label="Email"
leftIcon="mail-outline"
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"
leftIcon="lock-closed-outline"
secureTextEntry
autoComplete="password"
value={password}
onChangeText={setPassword}
onBlur={() => touch("password")}
placeholder="••••••••"
required
error={visible("password") ? passwordValidationError : undefined}
labelAccessory={
<Pressable onPress={() => router.push("/(auth)/forgot-password")} hitSlop={8}>
<Text style={[styles.forgot, { color: colors.mutedForeground }]}>
Forgot password?
</Text>
</Pressable>
}
/>
{error ? (
<Text style={[styles.error, { color: colors.destructive }]}>{error}</Text>
) : null}
<Button
title={loading ? "Signing in…" : "Sign in"}
loading={loading}
disabled={!canSignIn}
showArrow={!loading}
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 account
</Link>
</Text>
) : null}
<LegalAgreementNotice action="signing in" />
</AuthCard>
</AuthScreenLayout>
);
}
const styles = StyleSheet.create({
ssoSection: {
gap: spacing.md,
},
form: {
gap: spacing.md,
},
forgot: {
fontFamily: fonts.bodyMedium,
fontSize: 12,
},
error: {
fontSize: 14,
fontFamily: fonts.body,
},
footer: {
textAlign: "center",
fontSize: 14,
fontFamily: fonts.body,
},
link: {
fontFamily: fonts.bodySemiBold,
},
});