Add 'apps/mobile/' from commit '5fa30f365f21531094cd4d2045042bb4f1370ac3'
git-subtree-dir: apps/mobile git-subtree-mainline:86f8987dffgit-subtree-split:5fa30f365f
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user