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:
2026-01-10 20:07:35 +01:00
parent 71f84d1cc7
commit 8efe6c304e
20 changed files with 468 additions and 108 deletions

View File

@@ -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",

View File

@@ -28,7 +28,7 @@ const defaultLight: Theme = {
confirmButton: "#22c55e",
rejectButton: "#ef4444",
disabledButton: "#ccc",
buttonText: "#fff",
buttonText: "#000000",
textPrimary: "#000000",
textSecondary: "#666",
textMuted: "#888",

View File

@@ -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" />;
}

View File

@@ -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>
);

View File

@@ -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>
);

View 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;

View File

@@ -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"

View File

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

View File

@@ -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();
},
};

View File

@@ -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 });
}
},
}));

View File

@@ -0,0 +1,10 @@
const bcrypt = require("bcrypt");
const password = process.argv[2];
if (!password) {
console.error("Usage: node scripts/hash-password.js <password>");
process.exit(1);
}
bcrypt.hash(password, 10).then((hash) => console.log(hash));

View File

@@ -35,7 +35,7 @@ if (process.env.NODE_ENV !== "production") {
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS",
);
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, X-User-Id");
if (req.method === "OPTIONS") {
res.sendStatus(200);
return;

View File

@@ -1,8 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { verifyToken, TokenPayload } from "../utils/jwt";
export interface AuthenticatedUser {
userId: string;
}
export interface AuthenticatedRequest extends Request {
user?: TokenPayload;
user?: AuthenticatedUser;
}
export function authenticate(
@@ -10,11 +13,13 @@ export function authenticate(
res: Response,
next: NextFunction,
): void {
// TODO: Implement real JWT verification
// Fake user for testing purposes
req.user = {
userId: "fake-user-id",
email: "test@example.com",
};
const userId = req.headers["x-user-id"];
if (!userId || typeof userId !== "string") {
res.status(401).json({ error: "Unauthorized" });
return;
}
req.user = { userId };
next();
}

View File

@@ -1,23 +1,38 @@
import { User } from "@caldav/shared";
import { UserRepository, CreateUserData } from "../../services/interfaces";
import { Logged } from "../../logging";
import { UserModel } from "./models";
import { UserModel, UserDocument } from "./models";
function toUser(doc: UserDocument): User {
return {
id: doc._id.toString(),
email: doc.email,
userName: doc.userName,
passwordHash: doc.passwordHash,
createdAt: doc.createdAt,
updatedAt: doc.updatedAt,
};
}
@Logged("MongoUserRepository")
export class MongoUserRepository implements UserRepository {
async findById(id: string): Promise<User | null> {
throw new Error("Not implemented");
const user = await UserModel.findById(id);
return user ? toUser(user) : null;
}
async findByEmail(email: string): Promise<User | null> {
const user = await UserModel.findOne({ email: email.toLowerCase() });
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field
return (user?.toJSON() as unknown as User) ?? null;
return user ? toUser(user) : null;
}
async findByUserName(userName: string): Promise<User | null> {
const user = await UserModel.findOne({ userName });
return user ? toUser(user) : null;
}
async create(data: CreateUserData): Promise<User> {
const user = await UserModel.create(data);
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field
return user.toJSON() as unknown as User;
return toUser(user);
}
}

View File

@@ -21,7 +21,7 @@ const UserSchema = new Schema<
lowercase: true,
trim: true,
},
displayName: {
userName: {
type: String,
required: true,
trim: true,

View File

@@ -7,7 +7,12 @@ export class AuthService {
constructor(private userRepo: UserRepository) {}
async login(data: LoginDTO): Promise<AuthResponse> {
const user = await this.userRepo.findByEmail(data.email);
// Try email first, then userName
let user = await this.userRepo.findByEmail(data.identifier);
if (!user) {
user = await this.userRepo.findByUserName(data.identifier);
}
if (!user || !user.passwordHash) {
throw new Error("Invalid credentials");
}
@@ -21,15 +26,20 @@ export class AuthService {
}
async register(data: CreateUserDTO): Promise<AuthResponse> {
const existingUser = await this.userRepo.findByEmail(data.email);
if (existingUser) {
const existingEmail = await this.userRepo.findByEmail(data.email);
if (existingEmail) {
throw new Error("Email already exists");
}
const existingUserName = await this.userRepo.findByUserName(data.userName);
if (existingUserName) {
throw new Error("Username already exists");
}
const passwordHash = await password.hash(data.password);
const user = await this.userRepo.create({
email: data.email,
displayName: data.displayName,
userName: data.userName,
passwordHash,
});

View File

@@ -2,12 +2,13 @@ import { User } from "@caldav/shared";
export interface CreateUserData {
email: string;
displayName: string;
userName: string;
passwordHash: string;
}
export interface UserRepository {
findById(id: string): Promise<User | null>;
findByEmail(email: string): Promise<User | null>;
findByUserName(userName: string): Promise<User | null>;
create(data: CreateUserData): Promise<User>;
}