147 lines
4.1 KiB
TypeScript
147 lines
4.1 KiB
TypeScript
/**
|
|
* AuthController — HTTP layer for authentication.
|
|
*
|
|
* POST /auth/register — create account (requires invite code generated at boot)
|
|
* POST /auth/login — verify credentials, returns JWT
|
|
*/
|
|
|
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
|
import type { AuthService } from './AuthService.js';
|
|
|
|
interface RegisterBody {
|
|
email: string;
|
|
password: string;
|
|
inviteCode: string;
|
|
role?: 'trader' | 'viewer';
|
|
}
|
|
|
|
interface LoginBody {
|
|
email: string;
|
|
password: string;
|
|
}
|
|
|
|
interface ForgotBody {
|
|
email: string;
|
|
}
|
|
|
|
interface ResetBody {
|
|
token: string;
|
|
password: string;
|
|
}
|
|
|
|
const registerSchema = {
|
|
body: {
|
|
type: 'object',
|
|
required: ['email', 'password', 'inviteCode'],
|
|
properties: {
|
|
email: { type: 'string', format: 'email' },
|
|
password: { type: 'string', minLength: 8 },
|
|
inviteCode: { type: 'string' },
|
|
role: { type: 'string', enum: ['trader', 'viewer'] },
|
|
},
|
|
},
|
|
};
|
|
|
|
const loginSchema = {
|
|
body: {
|
|
type: 'object',
|
|
required: ['email', 'password'],
|
|
properties: {
|
|
email: { type: 'string', format: 'email' },
|
|
password: { type: 'string' },
|
|
},
|
|
},
|
|
};
|
|
|
|
const forgotSchema = {
|
|
body: {
|
|
type: 'object',
|
|
required: ['email'],
|
|
properties: {
|
|
email: { type: 'string', format: 'email' },
|
|
},
|
|
},
|
|
};
|
|
|
|
const resetSchema = {
|
|
body: {
|
|
type: 'object',
|
|
required: ['token', 'password'],
|
|
properties: {
|
|
token: { type: 'string', minLength: 32 },
|
|
password: { type: 'string', minLength: 8 },
|
|
},
|
|
},
|
|
};
|
|
|
|
export class AuthController {
|
|
readonly #inviteCode: string;
|
|
|
|
constructor(
|
|
private readonly authService: AuthService,
|
|
inviteCode: string,
|
|
) {
|
|
this.#inviteCode = inviteCode;
|
|
}
|
|
|
|
register(app: FastifyInstance): void {
|
|
app.post('/auth/register', { schema: registerSchema }, this.#register.bind(this));
|
|
app.post('/auth/login', { schema: loginSchema }, this.#login.bind(this));
|
|
app.post('/auth/forgot-password', { schema: forgotSchema }, this.#forgot.bind(this));
|
|
app.post('/auth/reset-password', { schema: resetSchema }, this.#reset.bind(this));
|
|
}
|
|
|
|
async #register(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
const { email, password, inviteCode, role } = req.body as RegisterBody;
|
|
|
|
if (inviteCode !== this.#inviteCode) {
|
|
return reply.code(403).send({ error: 'Invalid invite code' });
|
|
}
|
|
|
|
try {
|
|
const result = this.authService.register(email, password, role ?? 'viewer');
|
|
reply.code(201).send(result);
|
|
} catch (err: unknown) {
|
|
const e = err as { message: string; statusCode?: number };
|
|
reply.code(e.statusCode ?? 500).send({ error: e.message });
|
|
}
|
|
}
|
|
|
|
async #login(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
const { email, password } = req.body as LoginBody;
|
|
try {
|
|
const result = this.authService.login(email, password);
|
|
reply.send(result);
|
|
} catch (err: unknown) {
|
|
const e = err as { message: string; statusCode?: number };
|
|
reply.code(e.statusCode ?? 500).send({ error: e.message });
|
|
}
|
|
}
|
|
|
|
async #forgot(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
const { email } = req.body as ForgotBody;
|
|
const origin = process.env.CLIENT_ORIGIN ?? 'http://localhost:5173';
|
|
try {
|
|
this.authService.forgotPassword(email, origin);
|
|
} catch (err) {
|
|
// Log server-side but never expose details to client
|
|
console.error('[forgot-password] error:', err);
|
|
}
|
|
// Always return 200 — never reveal whether the email exists or any error occurred
|
|
reply.send({
|
|
message: 'If that email is registered, a reset link has been printed to the server console.',
|
|
});
|
|
}
|
|
|
|
async #reset(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
const { token, password } = req.body as ResetBody;
|
|
try {
|
|
this.authService.resetPassword(token, password);
|
|
reply.send({ message: 'Password updated. You can now log in.' });
|
|
} catch (err: unknown) {
|
|
const e = err as { message: string; statusCode?: number };
|
|
reply.code(e.statusCode ?? 500).send({ error: e.message });
|
|
}
|
|
}
|
|
}
|