feat: implement user authentication with login and register
- Add login screen with email/username support - Add register screen with email validation - Implement AuthStore with expo-secure-store (native) / localStorage (web) - Add X-User-Id header authentication (simple auth without JWT) - Rename displayName to userName across codebase - Add findByUserName() to UserRepository - Check for existing email AND username on registration - Add AuthButton component with shadow effect - Add logout button to Header - Add hash-password.js utility script for manual password resets - Update CORS to allow X-User-Id header
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
"expo-image": "~3.0.10",
|
||||
"expo-linking": "~8.0.9",
|
||||
"expo-router": "~6.0.15",
|
||||
"expo-secure-store": "^15.0.8",
|
||||
"expo-splash-screen": "~31.0.11",
|
||||
"expo-status-bar": "~3.0.8",
|
||||
"expo-symbols": "~1.0.7",
|
||||
|
||||
@@ -28,7 +28,7 @@ const defaultLight: Theme = {
|
||||
confirmButton: "#22c55e",
|
||||
rejectButton: "#ef4444",
|
||||
disabledButton: "#ccc",
|
||||
buttonText: "#fff",
|
||||
buttonText: "#000000",
|
||||
textPrimary: "#000000",
|
||||
textSecondary: "#666",
|
||||
textMuted: "#888",
|
||||
|
||||
@@ -1,5 +1,34 @@
|
||||
import { useEffect } from "react";
|
||||
import { View, ActivityIndicator } from "react-native";
|
||||
import { Redirect } from "expo-router";
|
||||
import { useAuthStore } from "../stores";
|
||||
import currentTheme from "../Themes";
|
||||
|
||||
export default function Index() {
|
||||
return <Redirect href="/(tabs)/chat" />;
|
||||
const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadStoredUser();
|
||||
}, [loadStoredUser]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: currentTheme.primeBg,
|
||||
}}
|
||||
>
|
||||
<ActivityIndicator size="large" color={currentTheme.chatBot} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Redirect href="/(tabs)/chat" />;
|
||||
}
|
||||
|
||||
return <Redirect href="/login" />;
|
||||
}
|
||||
|
||||
@@ -1,31 +1,95 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text, TextInput, Pressable } from "react-native";
|
||||
import { Link, router } from "expo-router";
|
||||
import BaseBackground from "../components/BaseBackground";
|
||||
import AuthButton from "../components/AuthButton";
|
||||
import { AuthService } from "../services";
|
||||
import currentTheme from "../Themes";
|
||||
|
||||
const LoginScreen = () => {
|
||||
// TODO: Email input field
|
||||
// TODO: Password input field
|
||||
// TODO: Login button -> AuthService.login()
|
||||
// TODO: Link to RegisterScreen
|
||||
// TODO: Error handling and display
|
||||
// TODO: Navigate to Calendar on success
|
||||
throw new Error("Not implemented");
|
||||
const [identifier, setIdentifier] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!identifier || !password) {
|
||||
setError("Bitte alle Felder ausfüllen");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await AuthService.login({ identifier, password });
|
||||
router.replace("/(tabs)/chat");
|
||||
} catch {
|
||||
setError("Anmeldung fehlgeschlagen. Überprüfe deine Zugangsdaten.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseBackground>
|
||||
<View className="flex-1 justify-center items-center p-4">
|
||||
<Text className="text-2xl mb-8">Login</Text>
|
||||
<View className="flex-1 justify-center items-center p-8">
|
||||
<Text
|
||||
className="text-3xl font-bold mb-8"
|
||||
style={{ color: currentTheme.textPrimary }}
|
||||
>
|
||||
Anmelden
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Text className="mb-4 text-center" style={{ color: currentTheme.rejectButton }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
placeholder="Email"
|
||||
className="w-full border rounded p-2 mb-4"
|
||||
placeholder="E-Mail oder Benutzername"
|
||||
placeholderTextColor={currentTheme.textMuted}
|
||||
value={identifier}
|
||||
onChangeText={setIdentifier}
|
||||
autoCapitalize="none"
|
||||
className="w-full rounded-lg p-4 mb-4"
|
||||
style={{
|
||||
backgroundColor: currentTheme.secondaryBg,
|
||||
color: currentTheme.textPrimary,
|
||||
borderWidth: 1,
|
||||
borderColor: currentTheme.borderPrimary,
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
placeholder="Password"
|
||||
placeholder="Passwort"
|
||||
placeholderTextColor={currentTheme.textMuted}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
className="w-full border rounded p-2 mb-4"
|
||||
className="w-full rounded-lg p-4 mb-6"
|
||||
style={{
|
||||
backgroundColor: currentTheme.secondaryBg,
|
||||
color: currentTheme.textPrimary,
|
||||
borderWidth: 1,
|
||||
borderColor: currentTheme.borderPrimary,
|
||||
}}
|
||||
/>
|
||||
<Pressable className="bg-blue-500 p-3 rounded w-full">
|
||||
<Text className="text-white text-center">Login</Text>
|
||||
</Pressable>
|
||||
|
||||
<AuthButton
|
||||
title="Anmelden"
|
||||
onPress={handleLogin}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
<Link href="/register" asChild>
|
||||
<Pressable>
|
||||
<Text style={{ color: currentTheme.chatBot }}>
|
||||
Noch kein Konto? Registrieren
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</BaseBackground>
|
||||
);
|
||||
|
||||
@@ -1,42 +1,119 @@
|
||||
import { useState } from "react";
|
||||
import { View, Text, TextInput, Pressable } from "react-native";
|
||||
import { Link, router } from "expo-router";
|
||||
import BaseBackground from "../components/BaseBackground";
|
||||
import AuthButton from "../components/AuthButton";
|
||||
import { AuthService } from "../services";
|
||||
import currentTheme from "../Themes";
|
||||
|
||||
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
|
||||
const RegisterScreen = () => {
|
||||
// TODO: Email input field
|
||||
// TODO: Display name input field
|
||||
// TODO: Password input field
|
||||
// TODO: Password confirmation field
|
||||
// TODO: Register button -> AuthService.register()
|
||||
// TODO: Link to LoginScreen
|
||||
// TODO: Error handling and display
|
||||
// TODO: Navigate to Calendar on success
|
||||
throw new Error("Not implemented");
|
||||
const [email, setEmail] = useState("");
|
||||
const [userName, setUserName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleRegister = async () => {
|
||||
setError(null);
|
||||
|
||||
if (!email || !userName || !password) {
|
||||
setError("Bitte alle Felder ausfüllen");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!EMAIL_REGEX.test(email)) {
|
||||
setError("Bitte eine gültige E-Mail-Adresse eingeben");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await AuthService.register({ email, userName, password });
|
||||
router.replace("/(tabs)/chat");
|
||||
} catch {
|
||||
setError("Registrierung fehlgeschlagen. E-Mail bereits vergeben?");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseBackground>
|
||||
<View className="flex-1 justify-center items-center p-4">
|
||||
<Text className="text-2xl mb-8">Register</Text>
|
||||
<View className="flex-1 justify-center items-center p-8">
|
||||
<Text
|
||||
className="text-3xl font-bold mb-8"
|
||||
style={{ color: currentTheme.textPrimary }}
|
||||
>
|
||||
Registrieren
|
||||
</Text>
|
||||
|
||||
{error && (
|
||||
<Text className="mb-4 text-center" style={{ color: currentTheme.rejectButton }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
placeholder="Email"
|
||||
className="w-full border rounded p-2 mb-4"
|
||||
placeholder="E-Mail"
|
||||
placeholderTextColor={currentTheme.textMuted}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
className="w-full rounded-lg p-4 mb-4"
|
||||
style={{
|
||||
backgroundColor: currentTheme.secondaryBg,
|
||||
color: currentTheme.textPrimary,
|
||||
borderWidth: 1,
|
||||
borderColor: currentTheme.borderPrimary,
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
placeholder="Display Name"
|
||||
className="w-full border rounded p-2 mb-4"
|
||||
placeholder="Benutzername"
|
||||
placeholderTextColor={currentTheme.textMuted}
|
||||
value={userName}
|
||||
onChangeText={setUserName}
|
||||
autoCapitalize="none"
|
||||
className="w-full rounded-lg p-4 mb-4"
|
||||
style={{
|
||||
backgroundColor: currentTheme.secondaryBg,
|
||||
color: currentTheme.textPrimary,
|
||||
borderWidth: 1,
|
||||
borderColor: currentTheme.borderPrimary,
|
||||
}}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
placeholder="Password"
|
||||
placeholder="Passwort"
|
||||
placeholderTextColor={currentTheme.textMuted}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
className="w-full border rounded p-2 mb-4"
|
||||
className="w-full rounded-lg p-4 mb-6"
|
||||
style={{
|
||||
backgroundColor: currentTheme.secondaryBg,
|
||||
color: currentTheme.textPrimary,
|
||||
borderWidth: 1,
|
||||
borderColor: currentTheme.borderPrimary,
|
||||
}}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Confirm Password"
|
||||
secureTextEntry
|
||||
className="w-full border rounded p-2 mb-4"
|
||||
|
||||
<AuthButton
|
||||
title="Registrieren"
|
||||
onPress={handleRegister}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<Pressable className="bg-blue-500 p-3 rounded w-full">
|
||||
<Text className="text-white text-center">Register</Text>
|
||||
</Pressable>
|
||||
|
||||
<Link href="/login" asChild>
|
||||
<Pressable>
|
||||
<Text style={{ color: currentTheme.chatBot }}>
|
||||
Bereits ein Konto? Anmelden
|
||||
</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</BaseBackground>
|
||||
);
|
||||
|
||||
41
apps/client/src/components/AuthButton.tsx
Normal file
41
apps/client/src/components/AuthButton.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Pressable, Text, ActivityIndicator } from "react-native";
|
||||
import currentTheme from "../Themes";
|
||||
|
||||
interface AuthButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const AuthButton = ({ title, onPress, isLoading = false }: AuthButtonProps) => {
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
disabled={isLoading}
|
||||
className="w-full rounded-lg p-4 mb-4 border-4"
|
||||
style={{
|
||||
backgroundColor: isLoading
|
||||
? currentTheme.disabledButton
|
||||
: currentTheme.chatBot,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={currentTheme.buttonText} />
|
||||
) : (
|
||||
<Text
|
||||
className="text-center font-semibold text-lg"
|
||||
style={{ color: currentTheme.buttonText }}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthButton;
|
||||
@@ -1,12 +1,20 @@
|
||||
import { View } from "react-native";
|
||||
import { View, Pressable } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import currentTheme from "../Themes";
|
||||
import { ReactNode } from "react";
|
||||
import { AuthService } from "../services";
|
||||
|
||||
type HeaderProps = {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await AuthService.logout();
|
||||
router.replace("/login");
|
||||
};
|
||||
|
||||
const Header = (props: HeaderProps) => {
|
||||
return (
|
||||
<View>
|
||||
@@ -17,6 +25,13 @@ const Header = (props: HeaderProps) => {
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
<Pressable
|
||||
onPress={handleLogout}
|
||||
className="absolute right-1 top-0 p-2"
|
||||
hitSlop={8}
|
||||
>
|
||||
<Ionicons name="log-out-outline" size={24} color={currentTheme.primeFg} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<View
|
||||
className="h-2 bg-black"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Platform } from "react-native";
|
||||
import { apiLogger } from "../logging";
|
||||
import { useAuthStore } from "../stores";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.EXPO_PUBLIC_API_URL ||
|
||||
@@ -13,6 +14,16 @@ type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
||||
interface RequestOptions {
|
||||
headers?: Record<string, string>;
|
||||
body?: unknown;
|
||||
skipAuth?: boolean;
|
||||
}
|
||||
|
||||
function getAuthHeaders(): Record<string, string> {
|
||||
const user = useAuthStore.getState().user;
|
||||
apiLogger.debug(`getAuthHeaders - user: ${JSON.stringify(user)}`);
|
||||
if (user?.id) {
|
||||
return { "X-User-Id": user.id };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
@@ -24,10 +35,13 @@ async function request<T>(
|
||||
apiLogger.debug(`${method} ${endpoint}`);
|
||||
|
||||
try {
|
||||
const authHeaders = options?.skipAuth ? {} : getAuthHeaders();
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...authHeaders,
|
||||
...options?.headers,
|
||||
},
|
||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||
|
||||
@@ -1,19 +1,29 @@
|
||||
import { LoginDTO, CreateUserDTO, AuthResponse } from "@caldav/shared";
|
||||
import { ApiClient } from "./ApiClient";
|
||||
import { useAuthStore } from "../stores";
|
||||
|
||||
export const AuthService = {
|
||||
login: async (_credentials: LoginDTO): Promise<AuthResponse> => {
|
||||
throw new Error("Not implemented");
|
||||
login: async (credentials: LoginDTO): Promise<AuthResponse> => {
|
||||
const response = await ApiClient.post<AuthResponse>(
|
||||
"/auth/login",
|
||||
credentials,
|
||||
{ skipAuth: true },
|
||||
);
|
||||
await useAuthStore.getState().login(response.user);
|
||||
return response;
|
||||
},
|
||||
|
||||
register: async (_data: CreateUserDTO): Promise<AuthResponse> => {
|
||||
throw new Error("Not implemented");
|
||||
register: async (data: CreateUserDTO): Promise<AuthResponse> => {
|
||||
const response = await ApiClient.post<AuthResponse>(
|
||||
"/auth/register",
|
||||
data,
|
||||
{ skipAuth: true },
|
||||
);
|
||||
await useAuthStore.getState().login(response.user);
|
||||
return response;
|
||||
},
|
||||
|
||||
logout: async (): Promise<void> => {
|
||||
throw new Error("Not implemented");
|
||||
},
|
||||
|
||||
refresh: async (): Promise<AuthResponse> => {
|
||||
throw new Error("Not implemented");
|
||||
await useAuthStore.getState().logout();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,26 +1,69 @@
|
||||
import { create } from "zustand";
|
||||
import { Platform } from "react-native";
|
||||
import { User } from "@caldav/shared";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
const USER_STORAGE_KEY = "auth_user";
|
||||
|
||||
// SecureStore doesn't work on web, use localStorage as fallback
|
||||
const storage = {
|
||||
async setItem(key: string, value: string): Promise<void> {
|
||||
if (Platform.OS === "web") {
|
||||
localStorage.setItem(key, value);
|
||||
} else {
|
||||
await SecureStore.setItemAsync(key, value);
|
||||
}
|
||||
},
|
||||
async getItem(key: string): Promise<string | null> {
|
||||
if (Platform.OS === "web") {
|
||||
return localStorage.getItem(key);
|
||||
}
|
||||
return SecureStore.getItemAsync(key);
|
||||
},
|
||||
async deleteItem(key: string): Promise<void> {
|
||||
if (Platform.OS === "web") {
|
||||
localStorage.removeItem(key);
|
||||
} else {
|
||||
await SecureStore.deleteItemAsync(key);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
interface AuthState {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isAuthenticated: boolean;
|
||||
login: (user: User, token: string) => void;
|
||||
logout: () => void;
|
||||
setToken: (token: string) => void;
|
||||
isLoading: boolean;
|
||||
login: (user: User) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
loadStoredUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
login: (_user: User, _token: string) => {
|
||||
throw new Error("Not implemented");
|
||||
isLoading: true,
|
||||
|
||||
login: async (user: User) => {
|
||||
await storage.setItem(USER_STORAGE_KEY, JSON.stringify(user));
|
||||
set({ user, isAuthenticated: true });
|
||||
},
|
||||
logout: () => {
|
||||
throw new Error("Not implemented");
|
||||
|
||||
logout: async () => {
|
||||
await storage.deleteItem(USER_STORAGE_KEY);
|
||||
set({ user: null, isAuthenticated: false });
|
||||
},
|
||||
setToken: (_token: string) => {
|
||||
throw new Error("Not implemented");
|
||||
|
||||
loadStoredUser: async () => {
|
||||
try {
|
||||
const stored = await storage.getItem(USER_STORAGE_KEY);
|
||||
if (stored) {
|
||||
const user = JSON.parse(stored) as User;
|
||||
set({ user, isAuthenticated: true, isLoading: false });
|
||||
} else {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
} catch {
|
||||
set({ isLoading: false });
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user