From 8efe6c304e8b6aa7bf741a9fcfdd746e2e29d145 Mon Sep 17 00:00:00 2001 From: Linus Waldowsky Date: Sat, 10 Jan 2026 20:07:35 +0100 Subject: [PATCH] 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 --- CLAUDE.md | 57 +++++--- apps/client/package.json | 1 + apps/client/src/Themes.tsx | 2 +- apps/client/src/app/index.tsx | 31 ++++- apps/client/src/app/login.tsx | 96 +++++++++++--- apps/client/src/app/register.tsx | 125 ++++++++++++++---- apps/client/src/components/AuthButton.tsx | 41 ++++++ apps/client/src/components/Header.tsx | 17 ++- apps/client/src/services/ApiClient.ts | 14 ++ apps/client/src/services/AuthService.ts | 28 ++-- apps/client/src/stores/AuthStore.ts | 65 +++++++-- apps/server/scripts/hash-password.js | 10 ++ apps/server/src/app.ts | 2 +- apps/server/src/controllers/AuthMiddleware.ts | 21 +-- .../repositories/mongo/MongoUserRepository.ts | 27 +++- .../repositories/mongo/models/UserModel.ts | 2 +- apps/server/src/services/AuthService.ts | 18 ++- .../src/services/interfaces/UserRepository.ts | 3 +- package-lock.json | 10 ++ packages/shared/src/models/User.ts | 6 +- 20 files changed, 468 insertions(+), 108 deletions(-) create mode 100644 apps/client/src/components/AuthButton.tsx create mode 100644 apps/server/scripts/hash-password.js diff --git a/CLAUDE.md b/CLAUDE.md index d9be7c8..fdfcb73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,7 +46,7 @@ npm run start -w @caldav/server # Run compiled server (port 3000) | | MongoDB | Database | | | Mongoose | ODM | | | GPT (OpenAI) | AI/LLM for chat | -| | JWT | Authentication | +| | X-User-Id Header | Authentication (simple, no JWT yet) | | | pino / pino-http | Structured logging | | | react-native-logs | Client-side logging | | Planned | iCalendar | Event export/import | @@ -79,7 +79,8 @@ src/ │ └── [id].tsx # Note editor for event (dynamic route) ├── components/ │ ├── BaseBackground.tsx # Common screen wrapper -│ ├── Header.tsx # Header component +│ ├── Header.tsx # Header component with logout button +│ ├── AuthButton.tsx # Reusable button for auth screens (with shadow) │ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard) │ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons) │ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal @@ -90,13 +91,14 @@ src/ │ └── logger.ts # react-native-logs config (apiLogger, storeLogger) ├── services/ │ ├── index.ts # Re-exports all services -│ ├── ApiClient.ts # HTTP client with request logging (get, post, put, delete) -│ ├── AuthService.ts # login(), register(), logout(), refresh() +│ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging +│ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore │ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete() │ └── ChatService.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() └── stores/ # Zustand state management ├── index.ts # Re-exports all stores - ├── AuthStore.ts # user, token, isAuthenticated, login(), logout(), setToken() + ├── AuthStore.ts # user, isAuthenticated, isLoading, login(), logout(), loadStoredUser() + │ # Uses expo-secure-store (native) / localStorage (web) ├── ChatStore.ts # messages[], addMessage(), addMessages(), updateMessage(), clearMessages(), chatMessageToMessageData() └── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent() ``` @@ -112,7 +114,7 @@ src/ │ ├── AuthController.ts # login(), register(), refresh(), logout() │ ├── ChatController.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() │ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete() -│ ├── AuthMiddleware.ts # authenticate() - JWT validation +│ ├── AuthMiddleware.ts # authenticate() - X-User-Id header validation │ └── LoggingMiddleware.ts # httpLogger - pino-http request logging ├── logging/ │ ├── index.ts # Re-exports @@ -126,7 +128,7 @@ src/ ├── services/ # Business logic │ ├── interfaces/ # DB-agnostic interfaces (for dependency injection) │ │ ├── AIProvider.ts # processMessage() -│ │ ├── UserRepository.ts # + CreateUserData (server-internal DTO) +│ │ ├── UserRepository.ts # findById, findByEmail, findByUserName, create + CreateUserData │ │ ├── EventRepository.ts │ │ └── ChatRepository.ts │ ├── AuthService.ts @@ -140,7 +142,7 @@ src/ │ │ ├── UserModel.ts │ │ ├── EventModel.ts │ │ └── ChatModel.ts -│ ├── MongoUserRepository.ts +│ ├── MongoUserRepository.ts # findById, findByEmail, findByUserName, create │ ├── MongoEventRepository.ts │ └── MongoChatRepository.ts ├── ai/ @@ -152,11 +154,13 @@ src/ │ ├── systemPrompt.ts # buildSystemPrompt() - German calendar assistant prompt │ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs │ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents -└── utils/ - ├── jwt.ts # signToken(), verifyToken() - ├── password.ts # hash(), compare() - ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings - └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences +├── utils/ +│ ├── jwt.ts # signToken(), verifyToken() - NOT USED YET (no JWT) +│ ├── password.ts # hash(), compare() using bcrypt +│ ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings +│ └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences +└── scripts/ + └── hash-password.js # Utility to hash passwords for manual DB updates ``` **API Endpoints:** @@ -198,12 +202,14 @@ src/ ``` **Key Types:** -- `User`: id, email, displayName, passwordHash?, createdAt?, updatedAt? +- `User`: id, email, userName, passwordHash?, createdAt?, updatedAt? - `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule? - `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances) - `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChange?, respondedAction? - `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates? - `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading) +- `CreateUserDTO`: email, userName, password (for registration) +- `LoginDTO`: identifier (email OR userName), password - `CreateEventDTO`: Used for creating events AND for AI-proposed events - `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number` - `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list) @@ -318,10 +324,11 @@ NODE_ENV=development # development = pretty logs, production = JSON **Backend:** - **Implemented:** - `AuthController`: login(), register() with error handling - - `AuthService`: login(), register() with password validation - - `MongoUserRepository`: findByEmail(), create() + - `AuthService`: login() supports email OR userName, register() checks for existing email AND userName + - `AuthMiddleware`: Validates X-User-Id header for protected routes + - `MongoUserRepository`: findById(), findByEmail(), findByUserName(), create() - `utils/password`: hash(), compare() using bcrypt - - `utils/jwt`: signToken() (verifyToken() pending) + - `scripts/hash-password.js`: Utility for manual password resets - `dotenv` integration for environment variables - `ChatController`: sendMessage(), confirmEvent(), rejectEvent() - `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions @@ -338,14 +345,24 @@ NODE_ENV=development # development = pretty logs, production = JSON - `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter) - `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator - All repositories and GPTAdapter decorated with @Logged for automatic method logging + - CORS configured to allow X-User-Id header - **Stubbed (TODO):** - - `AuthMiddleware.authenticate()`: Currently uses fake user for testing - `AuthController`: refresh(), logout() - `AuthService`: refreshToken() + - JWT authentication (currently using simple X-User-Id header) **Shared:** Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, and date utilities defined and exported. **Frontend:** +- **Authentication fully implemented:** + - `AuthStore`: Manages user state with expo-secure-store (native) / localStorage (web) + - `AuthService`: login(), register(), logout() - calls backend API + - `ApiClient`: Automatically injects X-User-Id header for authenticated requests + - Login screen: Supports email OR userName login + - Register screen: Email validation, checks for existing email/userName + - `AuthButton`: Reusable button component with shadow effect + - `Header`: Contains logout button on all screens + - `index.tsx`: Auth redirect - checks stored user on app start - Tab navigation (Chat, Calendar) implemented with basic UI - Calendar screen fully functional: - Month navigation with grid display and Ionicons (chevron-back/forward) @@ -362,7 +379,6 @@ NODE_ENV=development # development = pretty logs, production = JSON - KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height) - Auto-scroll to end on new messages and keyboard show - keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled" -- `ApiClient`: get(), post(), put(), delete() implemented with request/response logging - `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented - `ChatService`: sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination - `EventCardBase`: Shared base component with event layout (header, date/time/recurring icons, description) - used by both EventCard and ProposedEventCard @@ -371,8 +387,7 @@ NODE_ENV=development # development = pretty logs, production = JSON - `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg - `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[] - `ChatStore`: Zustand store with addMessage(), addMessages(), updateMessage(), clearMessages() - loads from server on mount and persists across tab switches -- Auth screens (Login, Register), Event Detail, and Note screens exist as skeletons -- AuthStore defined with `throw new Error('Not implemented')` +- Event Detail and Note screens exist as skeletons ## Documentation diff --git a/apps/client/package.json b/apps/client/package.json index e3b2f86..ed1416c 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -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", diff --git a/apps/client/src/Themes.tsx b/apps/client/src/Themes.tsx index b36892d..1d8c69e 100644 --- a/apps/client/src/Themes.tsx +++ b/apps/client/src/Themes.tsx @@ -28,7 +28,7 @@ const defaultLight: Theme = { confirmButton: "#22c55e", rejectButton: "#ef4444", disabledButton: "#ccc", - buttonText: "#fff", + buttonText: "#000000", textPrimary: "#000000", textSecondary: "#666", textMuted: "#888", diff --git a/apps/client/src/app/index.tsx b/apps/client/src/app/index.tsx index 4b14027..8c98e8d 100644 --- a/apps/client/src/app/index.tsx +++ b/apps/client/src/app/index.tsx @@ -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 ; + const { isAuthenticated, isLoading, loadStoredUser } = useAuthStore(); + + useEffect(() => { + loadStoredUser(); + }, [loadStoredUser]); + + if (isLoading) { + return ( + + + + ); + } + + if (isAuthenticated) { + return ; + } + + return ; } diff --git a/apps/client/src/app/login.tsx b/apps/client/src/app/login.tsx index f1e2272..d48b613 100644 --- a/apps/client/src/app/login.tsx +++ b/apps/client/src/app/login.tsx @@ -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(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 ( - - Login + + + Anmelden + + + {error && ( + + {error} + + )} + + - - Login - + + + + + + + Noch kein Konto? Registrieren + + + ); diff --git a/apps/client/src/app/register.tsx b/apps/client/src/app/register.tsx index abc48d2..a707f0d 100644 --- a/apps/client/src/app/register.tsx +++ b/apps/client/src/app/register.tsx @@ -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(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 ( - - Register + + + Registrieren + + + {error && ( + + {error} + + )} + + + - - - Register - + + + + + Bereits ein Konto? Anmelden + + + ); diff --git a/apps/client/src/components/AuthButton.tsx b/apps/client/src/components/AuthButton.tsx new file mode 100644 index 0000000..3bec939 --- /dev/null +++ b/apps/client/src/components/AuthButton.tsx @@ -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 ( + + {isLoading ? ( + + ) : ( + + {title} + + )} + + ); +}; + +export default AuthButton; diff --git a/apps/client/src/components/Header.tsx b/apps/client/src/components/Header.tsx index 6a90c2e..d10e21b 100644 --- a/apps/client/src/components/Header.tsx +++ b/apps/client/src/components/Header.tsx @@ -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 ( @@ -17,6 +25,13 @@ const Header = (props: HeaderProps) => { }} > {props.children} + + + ; body?: unknown; + skipAuth?: boolean; +} + +function getAuthHeaders(): Record { + 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( @@ -24,10 +35,13 @@ async function request( 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, diff --git a/apps/client/src/services/AuthService.ts b/apps/client/src/services/AuthService.ts index 4c973ff..082ebed 100644 --- a/apps/client/src/services/AuthService.ts +++ b/apps/client/src/services/AuthService.ts @@ -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 => { - throw new Error("Not implemented"); + login: async (credentials: LoginDTO): Promise => { + const response = await ApiClient.post( + "/auth/login", + credentials, + { skipAuth: true }, + ); + await useAuthStore.getState().login(response.user); + return response; }, - register: async (_data: CreateUserDTO): Promise => { - throw new Error("Not implemented"); + register: async (data: CreateUserDTO): Promise => { + const response = await ApiClient.post( + "/auth/register", + data, + { skipAuth: true }, + ); + await useAuthStore.getState().login(response.user); + return response; }, logout: async (): Promise => { - throw new Error("Not implemented"); - }, - - refresh: async (): Promise => { - throw new Error("Not implemented"); + await useAuthStore.getState().logout(); }, }; diff --git a/apps/client/src/stores/AuthStore.ts b/apps/client/src/stores/AuthStore.ts index 1856e5b..cb5a3f9 100644 --- a/apps/client/src/stores/AuthStore.ts +++ b/apps/client/src/stores/AuthStore.ts @@ -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 { + if (Platform.OS === "web") { + localStorage.setItem(key, value); + } else { + await SecureStore.setItemAsync(key, value); + } + }, + async getItem(key: string): Promise { + if (Platform.OS === "web") { + return localStorage.getItem(key); + } + return SecureStore.getItemAsync(key); + }, + async deleteItem(key: string): Promise { + 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; + logout: () => Promise; + loadStoredUser: () => Promise; } export const useAuthStore = create((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 }); + } }, })); diff --git a/apps/server/scripts/hash-password.js b/apps/server/scripts/hash-password.js new file mode 100644 index 0000000..77a3323 --- /dev/null +++ b/apps/server/scripts/hash-password.js @@ -0,0 +1,10 @@ +const bcrypt = require("bcrypt"); + +const password = process.argv[2]; + +if (!password) { + console.error("Usage: node scripts/hash-password.js "); + process.exit(1); +} + +bcrypt.hash(password, 10).then((hash) => console.log(hash)); diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 4c71c27..8f32731 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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; diff --git a/apps/server/src/controllers/AuthMiddleware.ts b/apps/server/src/controllers/AuthMiddleware.ts index d1c40fe..8c95b8e 100644 --- a/apps/server/src/controllers/AuthMiddleware.ts +++ b/apps/server/src/controllers/AuthMiddleware.ts @@ -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(); } diff --git a/apps/server/src/repositories/mongo/MongoUserRepository.ts b/apps/server/src/repositories/mongo/MongoUserRepository.ts index c8b44e8..9a5e01e 100644 --- a/apps/server/src/repositories/mongo/MongoUserRepository.ts +++ b/apps/server/src/repositories/mongo/MongoUserRepository.ts @@ -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 { - throw new Error("Not implemented"); + const user = await UserModel.findById(id); + return user ? toUser(user) : null; } async findByEmail(email: string): Promise { 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 { + const user = await UserModel.findOne({ userName }); + return user ? toUser(user) : null; } async create(data: CreateUserData): Promise { 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); } } diff --git a/apps/server/src/repositories/mongo/models/UserModel.ts b/apps/server/src/repositories/mongo/models/UserModel.ts index 0239c1a..d76b359 100644 --- a/apps/server/src/repositories/mongo/models/UserModel.ts +++ b/apps/server/src/repositories/mongo/models/UserModel.ts @@ -21,7 +21,7 @@ const UserSchema = new Schema< lowercase: true, trim: true, }, - displayName: { + userName: { type: String, required: true, trim: true, diff --git a/apps/server/src/services/AuthService.ts b/apps/server/src/services/AuthService.ts index 3d02033..d451fee 100644 --- a/apps/server/src/services/AuthService.ts +++ b/apps/server/src/services/AuthService.ts @@ -7,7 +7,12 @@ export class AuthService { constructor(private userRepo: UserRepository) {} async login(data: LoginDTO): Promise { - 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 { - 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, }); diff --git a/apps/server/src/services/interfaces/UserRepository.ts b/apps/server/src/services/interfaces/UserRepository.ts index 1276540..0a96aa8 100644 --- a/apps/server/src/services/interfaces/UserRepository.ts +++ b/apps/server/src/services/interfaces/UserRepository.ts @@ -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; findByEmail(email: string): Promise; + findByUserName(userName: string): Promise; create(data: CreateUserData): Promise; } diff --git a/package-lock.json b/package-lock.json index 047c12e..b1cde84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,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", @@ -7681,6 +7682,15 @@ "node": ">=10" } }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz", diff --git a/packages/shared/src/models/User.ts b/packages/shared/src/models/User.ts index b207330..aecf93c 100644 --- a/packages/shared/src/models/User.ts +++ b/packages/shared/src/models/User.ts @@ -1,7 +1,7 @@ export interface User { id: string; email: string; - displayName: string; + userName: string; passwordHash?: string; createdAt?: Date; updatedAt?: Date; @@ -9,12 +9,12 @@ export interface User { export interface CreateUserDTO { email: string; - displayName: string; + userName: string; password: string; } export interface LoginDTO { - email: string; + identifier: string; password: string; }