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:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const UserSchema = new Schema<
|
||||
lowercase: true,
|
||||
trim: true,
|
||||
},
|
||||
displayName: {
|
||||
userName: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user