232 lines
6.9 KiB
TypeScript
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'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,
|
|
},
|
|
});
|