phase-10.5: market screener ui enhancements

This commit is contained in:
Kazuma
2026-06-09 01:21:02 -04:00
parent 7bc242911e
commit fbadd7fb6e
45 changed files with 3054 additions and 539 deletions
+146
View File
@@ -0,0 +1,146 @@
/**
* 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 });
}
}
}