feat: implement user authentication with login and register

- Add login screen with email/username support
- Add register screen with email validation
- Implement AuthStore with expo-secure-store (native) / localStorage (web)
- Add X-User-Id header authentication (simple auth without JWT)
- Rename displayName to userName across codebase
- Add findByUserName() to UserRepository
- Check for existing email AND username on registration
- Add AuthButton component with shadow effect
- Add logout button to Header
- Add hash-password.js utility script for manual password resets
- Update CORS to allow X-User-Id header
This commit is contained in:
2026-01-10 20:07:35 +01:00
parent 71f84d1cc7
commit 8efe6c304e
20 changed files with 468 additions and 108 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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