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