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:
57
CLAUDE.md
57
CLAUDE.md
@@ -46,7 +46,7 @@ npm run start -w @caldav/server # Run compiled server (port 3000)
|
|||||||
| | MongoDB | Database |
|
| | MongoDB | Database |
|
||||||
| | Mongoose | ODM |
|
| | Mongoose | ODM |
|
||||||
| | GPT (OpenAI) | AI/LLM for chat |
|
| | GPT (OpenAI) | AI/LLM for chat |
|
||||||
| | JWT | Authentication |
|
| | X-User-Id Header | Authentication (simple, no JWT yet) |
|
||||||
| | pino / pino-http | Structured logging |
|
| | pino / pino-http | Structured logging |
|
||||||
| | react-native-logs | Client-side logging |
|
| | react-native-logs | Client-side logging |
|
||||||
| Planned | iCalendar | Event export/import |
|
| Planned | iCalendar | Event export/import |
|
||||||
@@ -79,7 +79,8 @@ src/
|
|||||||
│ └── [id].tsx # Note editor for event (dynamic route)
|
│ └── [id].tsx # Note editor for event (dynamic route)
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── BaseBackground.tsx # Common screen wrapper
|
│ ├── 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)
|
│ ├── EventCardBase.tsx # Shared event card layout with icons (used by EventCard & ProposedEventCard)
|
||||||
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
|
│ ├── EventCard.tsx # Calendar event card (uses EventCardBase + edit/delete buttons)
|
||||||
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal
|
│ ├── EventConfirmDialog.tsx # AI-proposed event confirmation modal
|
||||||
@@ -90,13 +91,14 @@ src/
|
|||||||
│ └── logger.ts # react-native-logs config (apiLogger, storeLogger)
|
│ └── logger.ts # react-native-logs config (apiLogger, storeLogger)
|
||||||
├── services/
|
├── services/
|
||||||
│ ├── index.ts # Re-exports all services
|
│ ├── index.ts # Re-exports all services
|
||||||
│ ├── ApiClient.ts # HTTP client with request logging (get, post, put, delete)
|
│ ├── ApiClient.ts # HTTP client with X-User-Id header injection, request logging
|
||||||
│ ├── AuthService.ts # login(), register(), logout(), refresh()
|
│ ├── AuthService.ts # login(), register(), logout() - calls API and updates AuthStore
|
||||||
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete()
|
│ ├── EventService.ts # getAll(), getById(), getByDateRange(), create(), update(), delete()
|
||||||
│ └── ChatService.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation()
|
│ └── ChatService.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation()
|
||||||
└── stores/ # Zustand state management
|
└── stores/ # Zustand state management
|
||||||
├── index.ts # Re-exports all stores
|
├── 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()
|
├── ChatStore.ts # messages[], addMessage(), addMessages(), updateMessage(), clearMessages(), chatMessageToMessageData()
|
||||||
└── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent()
|
└── EventsStore.ts # events[], setEvents(), addEvent(), updateEvent(), deleteEvent()
|
||||||
```
|
```
|
||||||
@@ -112,7 +114,7 @@ src/
|
|||||||
│ ├── AuthController.ts # login(), register(), refresh(), logout()
|
│ ├── AuthController.ts # login(), register(), refresh(), logout()
|
||||||
│ ├── ChatController.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation()
|
│ ├── ChatController.ts # sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation()
|
||||||
│ ├── EventController.ts # create(), getById(), getAll(), getByDateRange(), update(), delete()
|
│ ├── 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
|
│ └── LoggingMiddleware.ts # httpLogger - pino-http request logging
|
||||||
├── logging/
|
├── logging/
|
||||||
│ ├── index.ts # Re-exports
|
│ ├── index.ts # Re-exports
|
||||||
@@ -126,7 +128,7 @@ src/
|
|||||||
├── services/ # Business logic
|
├── services/ # Business logic
|
||||||
│ ├── interfaces/ # DB-agnostic interfaces (for dependency injection)
|
│ ├── interfaces/ # DB-agnostic interfaces (for dependency injection)
|
||||||
│ │ ├── AIProvider.ts # processMessage()
|
│ │ ├── AIProvider.ts # processMessage()
|
||||||
│ │ ├── UserRepository.ts # + CreateUserData (server-internal DTO)
|
│ │ ├── UserRepository.ts # findById, findByEmail, findByUserName, create + CreateUserData
|
||||||
│ │ ├── EventRepository.ts
|
│ │ ├── EventRepository.ts
|
||||||
│ │ └── ChatRepository.ts
|
│ │ └── ChatRepository.ts
|
||||||
│ ├── AuthService.ts
|
│ ├── AuthService.ts
|
||||||
@@ -140,7 +142,7 @@ src/
|
|||||||
│ │ ├── UserModel.ts
|
│ │ ├── UserModel.ts
|
||||||
│ │ ├── EventModel.ts
|
│ │ ├── EventModel.ts
|
||||||
│ │ └── ChatModel.ts
|
│ │ └── ChatModel.ts
|
||||||
│ ├── MongoUserRepository.ts
|
│ ├── MongoUserRepository.ts # findById, findByEmail, findByUserName, create
|
||||||
│ ├── MongoEventRepository.ts
|
│ ├── MongoEventRepository.ts
|
||||||
│ └── MongoChatRepository.ts
|
│ └── MongoChatRepository.ts
|
||||||
├── ai/
|
├── ai/
|
||||||
@@ -152,11 +154,13 @@ src/
|
|||||||
│ ├── systemPrompt.ts # buildSystemPrompt() - German calendar assistant prompt
|
│ ├── systemPrompt.ts # buildSystemPrompt() - German calendar assistant prompt
|
||||||
│ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs
|
│ ├── toolDefinitions.ts # TOOL_DEFINITIONS - provider-agnostic tool specs
|
||||||
│ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents
|
│ └── toolExecutor.ts # executeToolCall() - handles getDay, proposeCreate/Update/Delete, searchEvents
|
||||||
└── utils/
|
├── utils/
|
||||||
├── jwt.ts # signToken(), verifyToken()
|
│ ├── jwt.ts # signToken(), verifyToken() - NOT USED YET (no JWT)
|
||||||
├── password.ts # hash(), compare()
|
│ ├── password.ts # hash(), compare() using bcrypt
|
||||||
├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings
|
│ ├── eventFormatters.ts # getWeeksOverview(), getMonthOverview() - formatted event listings
|
||||||
└── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences
|
│ └── recurrenceExpander.ts # expandRecurringEvents() - expand recurring events into occurrences
|
||||||
|
└── scripts/
|
||||||
|
└── hash-password.js # Utility to hash passwords for manual DB updates
|
||||||
```
|
```
|
||||||
|
|
||||||
**API Endpoints:**
|
**API Endpoints:**
|
||||||
@@ -198,12 +202,14 @@ src/
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Key Types:**
|
**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?
|
- `CalendarEvent`: id, userId, title, description?, startTime, endTime, note?, isRecurring?, recurrenceRule?
|
||||||
- `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances)
|
- `ExpandedEvent`: Extends CalendarEvent with occurrenceStart, occurrenceEnd (for recurring event instances)
|
||||||
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChange?, respondedAction?
|
- `ChatMessage`: id, conversationId, sender ('user' | 'assistant'), content, proposedChange?, respondedAction?
|
||||||
- `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates?
|
- `ProposedEventChange`: action ('create' | 'update' | 'delete'), eventId?, event?, updates?
|
||||||
- `Conversation`: id, userId, createdAt?, updatedAt? (messages loaded separately via lazy loading)
|
- `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
|
- `CreateEventDTO`: Used for creating events AND for AI-proposed events
|
||||||
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
|
- `GetMessagesOptions`: Cursor-based pagination with `before?: string` and `limit?: number`
|
||||||
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
|
- `ConversationSummary`: id, lastMessage?, createdAt? (for conversation list)
|
||||||
@@ -318,10 +324,11 @@ NODE_ENV=development # development = pretty logs, production = JSON
|
|||||||
**Backend:**
|
**Backend:**
|
||||||
- **Implemented:**
|
- **Implemented:**
|
||||||
- `AuthController`: login(), register() with error handling
|
- `AuthController`: login(), register() with error handling
|
||||||
- `AuthService`: login(), register() with password validation
|
- `AuthService`: login() supports email OR userName, register() checks for existing email AND userName
|
||||||
- `MongoUserRepository`: findByEmail(), create()
|
- `AuthMiddleware`: Validates X-User-Id header for protected routes
|
||||||
|
- `MongoUserRepository`: findById(), findByEmail(), findByUserName(), create()
|
||||||
- `utils/password`: hash(), compare() using bcrypt
|
- `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
|
- `dotenv` integration for environment variables
|
||||||
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
|
- `ChatController`: sendMessage(), confirmEvent(), rejectEvent()
|
||||||
- `ChatService`: processMessage() with test responses (create, update, delete actions), confirmEvent() handles all CRUD actions
|
- `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)
|
- `ai/utils/`: Provider-agnostic shared utilities (systemPrompt, toolDefinitions, toolExecutor, eventFormatter)
|
||||||
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
|
- `logging/`: Structured logging with pino, pino-http middleware, @Logged decorator
|
||||||
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
|
- All repositories and GPTAdapter decorated with @Logged for automatic method logging
|
||||||
|
- CORS configured to allow X-User-Id header
|
||||||
- **Stubbed (TODO):**
|
- **Stubbed (TODO):**
|
||||||
- `AuthMiddleware.authenticate()`: Currently uses fake user for testing
|
|
||||||
- `AuthController`: refresh(), logout()
|
- `AuthController`: refresh(), logout()
|
||||||
- `AuthService`: refreshToken()
|
- `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.
|
**Shared:** Types, DTOs, constants (Day, Month with German translations), ExpandedEvent type, and date utilities defined and exported.
|
||||||
|
|
||||||
**Frontend:**
|
**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
|
- Tab navigation (Chat, Calendar) implemented with basic UI
|
||||||
- Calendar screen fully functional:
|
- Calendar screen fully functional:
|
||||||
- Month navigation with grid display and Ionicons (chevron-back/forward)
|
- 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)
|
- KeyboardAvoidingView for proper keyboard handling (iOS padding, Android height)
|
||||||
- Auto-scroll to end on new messages and keyboard show
|
- Auto-scroll to end on new messages and keyboard show
|
||||||
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
|
- keyboardDismissMode="interactive" and keyboardShouldPersistTaps="handled"
|
||||||
- `ApiClient`: get(), post(), put(), delete() implemented with request/response logging
|
|
||||||
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented
|
- `EventService`: getAll(), getById(), getByDateRange(), create(), update(), delete() - fully implemented
|
||||||
- `ChatService`: sendMessage(), confirmEvent(), rejectEvent(), getConversations(), getConversation() - fully implemented with cursor pagination
|
- `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
|
- `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
|
- `Themes.tsx`: Centralized color definitions including textPrimary, borderPrimary, eventIndicator, secondaryBg
|
||||||
- `EventsStore`: Zustand store with setEvents(), addEvent(), updateEvent(), deleteEvent() - stores ExpandedEvent[]
|
- `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
|
- `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
|
- Event Detail and Note screens exist as skeletons
|
||||||
- AuthStore defined with `throw new Error('Not implemented')`
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
"expo-linking": "~8.0.9",
|
"expo-linking": "~8.0.9",
|
||||||
"expo-router": "~6.0.15",
|
"expo-router": "~6.0.15",
|
||||||
|
"expo-secure-store": "^15.0.8",
|
||||||
"expo-splash-screen": "~31.0.11",
|
"expo-splash-screen": "~31.0.11",
|
||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
"expo-symbols": "~1.0.7",
|
"expo-symbols": "~1.0.7",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const defaultLight: Theme = {
|
|||||||
confirmButton: "#22c55e",
|
confirmButton: "#22c55e",
|
||||||
rejectButton: "#ef4444",
|
rejectButton: "#ef4444",
|
||||||
disabledButton: "#ccc",
|
disabledButton: "#ccc",
|
||||||
buttonText: "#fff",
|
buttonText: "#000000",
|
||||||
textPrimary: "#000000",
|
textPrimary: "#000000",
|
||||||
textSecondary: "#666",
|
textSecondary: "#666",
|
||||||
textMuted: "#888",
|
textMuted: "#888",
|
||||||
|
|||||||
@@ -1,5 +1,34 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { View, ActivityIndicator } from "react-native";
|
||||||
import { Redirect } from "expo-router";
|
import { Redirect } from "expo-router";
|
||||||
|
import { useAuthStore } from "../stores";
|
||||||
|
import currentTheme from "../Themes";
|
||||||
|
|
||||||
export default function Index() {
|
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 { View, Text, TextInput, Pressable } from "react-native";
|
||||||
|
import { Link, router } from "expo-router";
|
||||||
import BaseBackground from "../components/BaseBackground";
|
import BaseBackground from "../components/BaseBackground";
|
||||||
|
import AuthButton from "../components/AuthButton";
|
||||||
|
import { AuthService } from "../services";
|
||||||
|
import currentTheme from "../Themes";
|
||||||
|
|
||||||
const LoginScreen = () => {
|
const LoginScreen = () => {
|
||||||
// TODO: Email input field
|
const [identifier, setIdentifier] = useState("");
|
||||||
// TODO: Password input field
|
const [password, setPassword] = useState("");
|
||||||
// TODO: Login button -> AuthService.login()
|
const [error, setError] = useState<string | null>(null);
|
||||||
// TODO: Link to RegisterScreen
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
// TODO: Error handling and display
|
|
||||||
// TODO: Navigate to Calendar on success
|
const handleLogin = async () => {
|
||||||
throw new Error("Not implemented");
|
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 (
|
return (
|
||||||
<BaseBackground>
|
<BaseBackground>
|
||||||
<View className="flex-1 justify-center items-center p-4">
|
<View className="flex-1 justify-center items-center p-8">
|
||||||
<Text className="text-2xl mb-8">Login</Text>
|
<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
|
<TextInput
|
||||||
placeholder="Email"
|
placeholder="E-Mail oder Benutzername"
|
||||||
className="w-full border rounded p-2 mb-4"
|
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
|
<TextInput
|
||||||
placeholder="Password"
|
placeholder="Passwort"
|
||||||
|
placeholderTextColor={currentTheme.textMuted}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
secureTextEntry
|
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>
|
<AuthButton
|
||||||
</Pressable>
|
title="Anmelden"
|
||||||
|
onPress={handleLogin}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Link href="/register" asChild>
|
||||||
|
<Pressable>
|
||||||
|
<Text style={{ color: currentTheme.chatBot }}>
|
||||||
|
Noch kein Konto? Registrieren
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
</BaseBackground>
|
</BaseBackground>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,42 +1,119 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { View, Text, TextInput, Pressable } from "react-native";
|
import { View, Text, TextInput, Pressable } from "react-native";
|
||||||
|
import { Link, router } from "expo-router";
|
||||||
import BaseBackground from "../components/BaseBackground";
|
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 = () => {
|
const RegisterScreen = () => {
|
||||||
// TODO: Email input field
|
const [email, setEmail] = useState("");
|
||||||
// TODO: Display name input field
|
const [userName, setUserName] = useState("");
|
||||||
// TODO: Password input field
|
const [password, setPassword] = useState("");
|
||||||
// TODO: Password confirmation field
|
const [error, setError] = useState<string | null>(null);
|
||||||
// TODO: Register button -> AuthService.register()
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
// TODO: Link to LoginScreen
|
|
||||||
// TODO: Error handling and display
|
const handleRegister = async () => {
|
||||||
// TODO: Navigate to Calendar on success
|
setError(null);
|
||||||
throw new Error("Not implemented");
|
|
||||||
|
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 (
|
return (
|
||||||
<BaseBackground>
|
<BaseBackground>
|
||||||
<View className="flex-1 justify-center items-center p-4">
|
<View className="flex-1 justify-center items-center p-8">
|
||||||
<Text className="text-2xl mb-8">Register</Text>
|
<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
|
<TextInput
|
||||||
placeholder="Email"
|
placeholder="E-Mail"
|
||||||
className="w-full border rounded p-2 mb-4"
|
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
|
<TextInput
|
||||||
placeholder="Display Name"
|
placeholder="Benutzername"
|
||||||
className="w-full border rounded p-2 mb-4"
|
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
|
<TextInput
|
||||||
placeholder="Password"
|
placeholder="Passwort"
|
||||||
|
placeholderTextColor={currentTheme.textMuted}
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
secureTextEntry
|
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"
|
<AuthButton
|
||||||
secureTextEntry
|
title="Registrieren"
|
||||||
className="w-full border rounded p-2 mb-4"
|
onPress={handleRegister}
|
||||||
|
isLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
<Pressable className="bg-blue-500 p-3 rounded w-full">
|
|
||||||
<Text className="text-white text-center">Register</Text>
|
<Link href="/login" asChild>
|
||||||
</Pressable>
|
<Pressable>
|
||||||
|
<Text style={{ color: currentTheme.chatBot }}>
|
||||||
|
Bereits ein Konto? Anmelden
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</Link>
|
||||||
</View>
|
</View>
|
||||||
</BaseBackground>
|
</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 currentTheme from "../Themes";
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
import { AuthService } from "../services";
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await AuthService.logout();
|
||||||
|
router.replace("/login");
|
||||||
|
};
|
||||||
|
|
||||||
const Header = (props: HeaderProps) => {
|
const Header = (props: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<View>
|
<View>
|
||||||
@@ -17,6 +25,13 @@ const Header = (props: HeaderProps) => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{props.children}
|
{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>
|
||||||
<View
|
<View
|
||||||
className="h-2 bg-black"
|
className="h-2 bg-black"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Platform } from "react-native";
|
import { Platform } from "react-native";
|
||||||
import { apiLogger } from "../logging";
|
import { apiLogger } from "../logging";
|
||||||
|
import { useAuthStore } from "../stores";
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.EXPO_PUBLIC_API_URL ||
|
process.env.EXPO_PUBLIC_API_URL ||
|
||||||
@@ -13,6 +14,16 @@ type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
|||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
body?: unknown;
|
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>(
|
async function request<T>(
|
||||||
@@ -24,10 +35,13 @@ async function request<T>(
|
|||||||
apiLogger.debug(`${method} ${endpoint}`);
|
apiLogger.debug(`${method} ${endpoint}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const authHeaders = options?.skipAuth ? {} : getAuthHeaders();
|
||||||
|
|
||||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
...authHeaders,
|
||||||
...options?.headers,
|
...options?.headers,
|
||||||
},
|
},
|
||||||
body: options?.body ? JSON.stringify(options.body) : undefined,
|
body: options?.body ? JSON.stringify(options.body) : undefined,
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
import { LoginDTO, CreateUserDTO, AuthResponse } from "@caldav/shared";
|
import { LoginDTO, CreateUserDTO, AuthResponse } from "@caldav/shared";
|
||||||
|
import { ApiClient } from "./ApiClient";
|
||||||
|
import { useAuthStore } from "../stores";
|
||||||
|
|
||||||
export const AuthService = {
|
export const AuthService = {
|
||||||
login: async (_credentials: LoginDTO): Promise<AuthResponse> => {
|
login: async (credentials: LoginDTO): Promise<AuthResponse> => {
|
||||||
throw new Error("Not implemented");
|
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> => {
|
register: async (data: CreateUserDTO): Promise<AuthResponse> => {
|
||||||
throw new Error("Not implemented");
|
const response = await ApiClient.post<AuthResponse>(
|
||||||
|
"/auth/register",
|
||||||
|
data,
|
||||||
|
{ skipAuth: true },
|
||||||
|
);
|
||||||
|
await useAuthStore.getState().login(response.user);
|
||||||
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
logout: async (): Promise<void> => {
|
logout: async (): Promise<void> => {
|
||||||
throw new Error("Not implemented");
|
await useAuthStore.getState().logout();
|
||||||
},
|
|
||||||
|
|
||||||
refresh: async (): Promise<AuthResponse> => {
|
|
||||||
throw new Error("Not implemented");
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,26 +1,69 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { Platform } from "react-native";
|
||||||
import { User } from "@caldav/shared";
|
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 {
|
interface AuthState {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
token: string | null;
|
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
login: (user: User, token: string) => void;
|
isLoading: boolean;
|
||||||
logout: () => void;
|
login: (user: User) => Promise<void>;
|
||||||
setToken: (token: string) => void;
|
logout: () => Promise<void>;
|
||||||
|
loadStoredUser: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set) => ({
|
export const useAuthStore = create<AuthState>((set) => ({
|
||||||
user: null,
|
user: null,
|
||||||
token: null,
|
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
login: (_user: User, _token: string) => {
|
isLoading: true,
|
||||||
throw new Error("Not implemented");
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
10
apps/server/scripts/hash-password.js
Normal file
10
apps/server/scripts/hash-password.js
Normal 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));
|
||||||
@@ -35,7 +35,7 @@ if (process.env.NODE_ENV !== "production") {
|
|||||||
"Access-Control-Allow-Methods",
|
"Access-Control-Allow-Methods",
|
||||||
"GET, POST, PUT, DELETE, OPTIONS",
|
"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") {
|
if (req.method === "OPTIONS") {
|
||||||
res.sendStatus(200);
|
res.sendStatus(200);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { verifyToken, TokenPayload } from "../utils/jwt";
|
|
||||||
|
export interface AuthenticatedUser {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuthenticatedRequest extends Request {
|
export interface AuthenticatedRequest extends Request {
|
||||||
user?: TokenPayload;
|
user?: AuthenticatedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authenticate(
|
export function authenticate(
|
||||||
@@ -10,11 +13,13 @@ export function authenticate(
|
|||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction,
|
next: NextFunction,
|
||||||
): void {
|
): void {
|
||||||
// TODO: Implement real JWT verification
|
const userId = req.headers["x-user-id"];
|
||||||
// Fake user for testing purposes
|
|
||||||
req.user = {
|
if (!userId || typeof userId !== "string") {
|
||||||
userId: "fake-user-id",
|
res.status(401).json({ error: "Unauthorized" });
|
||||||
email: "test@example.com",
|
return;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
req.user = { userId };
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,38 @@
|
|||||||
import { User } from "@caldav/shared";
|
import { User } from "@caldav/shared";
|
||||||
import { UserRepository, CreateUserData } from "../../services/interfaces";
|
import { UserRepository, CreateUserData } from "../../services/interfaces";
|
||||||
import { Logged } from "../../logging";
|
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")
|
@Logged("MongoUserRepository")
|
||||||
export class MongoUserRepository implements UserRepository {
|
export class MongoUserRepository implements UserRepository {
|
||||||
async findById(id: string): Promise<User | null> {
|
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> {
|
async findByEmail(email: string): Promise<User | null> {
|
||||||
const user = await UserModel.findOne({ email: email.toLowerCase() });
|
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 ? toUser(user) : null;
|
||||||
return (user?.toJSON() as unknown as 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> {
|
async create(data: CreateUserData): Promise<User> {
|
||||||
const user = await UserModel.create(data);
|
const user = await UserModel.create(data);
|
||||||
// NOTE: Casting required because Mongoose's toJSON() type doesn't reflect our virtual 'id' field
|
return toUser(user);
|
||||||
return user.toJSON() as unknown as User;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const UserSchema = new Schema<
|
|||||||
lowercase: true,
|
lowercase: true,
|
||||||
trim: true,
|
trim: true,
|
||||||
},
|
},
|
||||||
displayName: {
|
userName: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
trim: true,
|
trim: true,
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ export class AuthService {
|
|||||||
constructor(private userRepo: UserRepository) {}
|
constructor(private userRepo: UserRepository) {}
|
||||||
|
|
||||||
async login(data: LoginDTO): Promise<AuthResponse> {
|
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) {
|
if (!user || !user.passwordHash) {
|
||||||
throw new Error("Invalid credentials");
|
throw new Error("Invalid credentials");
|
||||||
}
|
}
|
||||||
@@ -21,15 +26,20 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async register(data: CreateUserDTO): Promise<AuthResponse> {
|
async register(data: CreateUserDTO): Promise<AuthResponse> {
|
||||||
const existingUser = await this.userRepo.findByEmail(data.email);
|
const existingEmail = await this.userRepo.findByEmail(data.email);
|
||||||
if (existingUser) {
|
if (existingEmail) {
|
||||||
throw new Error("Email already exists");
|
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 passwordHash = await password.hash(data.password);
|
||||||
const user = await this.userRepo.create({
|
const user = await this.userRepo.create({
|
||||||
email: data.email,
|
email: data.email,
|
||||||
displayName: data.displayName,
|
userName: data.userName,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { User } from "@caldav/shared";
|
|||||||
|
|
||||||
export interface CreateUserData {
|
export interface CreateUserData {
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
userName: string;
|
||||||
passwordHash: string;
|
passwordHash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserRepository {
|
export interface UserRepository {
|
||||||
findById(id: string): Promise<User | null>;
|
findById(id: string): Promise<User | null>;
|
||||||
findByEmail(email: string): Promise<User | null>;
|
findByEmail(email: string): Promise<User | null>;
|
||||||
|
findByUserName(userName: string): Promise<User | null>;
|
||||||
create(data: CreateUserData): Promise<User>;
|
create(data: CreateUserData): Promise<User>;
|
||||||
}
|
}
|
||||||
|
|||||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -34,6 +34,7 @@
|
|||||||
"expo-image": "~3.0.10",
|
"expo-image": "~3.0.10",
|
||||||
"expo-linking": "~8.0.9",
|
"expo-linking": "~8.0.9",
|
||||||
"expo-router": "~6.0.15",
|
"expo-router": "~6.0.15",
|
||||||
|
"expo-secure-store": "^15.0.8",
|
||||||
"expo-splash-screen": "~31.0.11",
|
"expo-splash-screen": "~31.0.11",
|
||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
"expo-symbols": "~1.0.7",
|
"expo-symbols": "~1.0.7",
|
||||||
@@ -7681,6 +7682,15 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/expo-server": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
userName: string;
|
||||||
passwordHash?: string;
|
passwordHash?: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
@@ -9,12 +9,12 @@ export interface User {
|
|||||||
|
|
||||||
export interface CreateUserDTO {
|
export interface CreateUserDTO {
|
||||||
email: string;
|
email: string;
|
||||||
displayName: string;
|
userName: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginDTO {
|
export interface LoginDTO {
|
||||||
email: string;
|
identifier: string;
|
||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user