Files
source-gamepanel/apps/api/src/routes/auth/index.ts
T
hibna c9fe2bd9fe fix: resolve frontend routing, API mismatches, and missing UI components
- Add servers list page and missing routes (servers, settings redirect, account security)
- Fix members page .map error (API returns { data } wrapper, not flat array)
- Fix auth store fetchUser expecting flat User but API returns { user } wrapper
- Add node token display dialog after creation
- Add allocation management UI to node detail page
- Add account security page with password change
- Add change-password API endpoint
- Add node servers and stats API endpoints
- Fix config save using PATCH instead of PUT, add api.put method
- Fix audit logs field name mismatch (userName vs username)
- Replace admin nodes page to avoid orgId dependency
- Remove duplicate sidebar nav items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:07:00 +03:00

230 lines
6.3 KiB
TypeScript

import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { users } from '@source/database';
import { hashPassword, verifyPassword } from '../../lib/password.js';
import { signAccessToken, signRefreshToken, verifyRefreshToken } from '../../lib/jwt.js';
import type { AccessTokenPayload, RefreshTokenPayload } from '../../lib/jwt.js';
import { AppError } from '../../lib/errors.js';
import { RegisterSchema, LoginSchema } from './schemas.js';
const REFRESH_COOKIE_NAME = 'refresh_token';
const REFRESH_COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/api/auth',
maxAge: 7 * 24 * 60 * 60, // 7 days in seconds
};
export default async function authRoutes(app: FastifyInstance) {
// POST /api/auth/register
app.post('/register', { schema: RegisterSchema }, async (request, reply) => {
const { email, username, password } = request.body as {
email: string;
username: string;
password: string;
};
// Check if email already exists
const existingEmail = await app.db.query.users.findFirst({
where: eq(users.email, email),
});
if (existingEmail) {
throw AppError.conflict('Email already in use', 'EMAIL_TAKEN');
}
// Check if username already exists
const existingUsername = await app.db.query.users.findFirst({
where: eq(users.username, username),
});
if (existingUsername) {
throw AppError.conflict('Username already in use', 'USERNAME_TAKEN');
}
const passwordHash = await hashPassword(password);
const [user] = await app.db
.insert(users)
.values({
email,
username,
passwordHash,
})
.returning({
id: users.id,
email: users.email,
username: users.username,
isSuperAdmin: users.isSuperAdmin,
});
// Generate tokens
const accessToken = signAccessToken(app, {
sub: user!.id,
email: user!.email,
isSuperAdmin: user!.isSuperAdmin,
});
const refreshToken = signRefreshToken(app, {
sub: user!.id,
type: 'refresh',
});
reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
return reply.code(201).send({
user: {
id: user!.id,
email: user!.email,
username: user!.username,
isSuperAdmin: user!.isSuperAdmin,
},
accessToken,
});
});
// POST /api/auth/login
app.post('/login', { schema: LoginSchema }, async (request, reply) => {
const { email, password } = request.body as { email: string; password: string };
const user = await app.db.query.users.findFirst({
where: eq(users.email, email),
});
if (!user) {
throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS');
}
const isValid = await verifyPassword(user.passwordHash, password);
if (!isValid) {
throw AppError.unauthorized('Invalid email or password', 'INVALID_CREDENTIALS');
}
const accessToken = signAccessToken(app, {
sub: user.id,
email: user.email,
isSuperAdmin: user.isSuperAdmin,
});
const refreshToken = signRefreshToken(app, {
sub: user.id,
type: 'refresh',
});
reply.setCookie(REFRESH_COOKIE_NAME, refreshToken, REFRESH_COOKIE_OPTIONS);
return {
user: {
id: user.id,
email: user.email,
username: user.username,
isSuperAdmin: user.isSuperAdmin,
avatarUrl: user.avatarUrl,
},
accessToken,
};
});
// POST /api/auth/refresh
app.post('/refresh', async (request, reply) => {
const token = request.cookies[REFRESH_COOKIE_NAME];
if (!token) {
throw AppError.unauthorized('No refresh token', 'NO_REFRESH_TOKEN');
}
let payload: RefreshTokenPayload;
try {
payload = verifyRefreshToken(app, token);
} catch {
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
throw AppError.unauthorized('Invalid refresh token', 'INVALID_REFRESH_TOKEN');
}
const user = await app.db.query.users.findFirst({
where: eq(users.id, payload.sub),
});
if (!user) {
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
throw AppError.unauthorized('User not found', 'USER_NOT_FOUND');
}
// Token rotation: issue new tokens
const accessToken = signAccessToken(app, {
sub: user.id,
email: user.email,
isSuperAdmin: user.isSuperAdmin,
});
const newRefreshToken = signRefreshToken(app, {
sub: user.id,
type: 'refresh',
});
reply.setCookie(REFRESH_COOKIE_NAME, newRefreshToken, REFRESH_COOKIE_OPTIONS);
return { accessToken };
});
// POST /api/auth/logout
app.post('/logout', async (_request, reply) => {
reply.clearCookie(REFRESH_COOKIE_NAME, { path: '/api/auth' });
return { success: true };
});
// POST /api/auth/change-password
app.post('/change-password', { onRequest: [app.authenticate] }, async (request) => {
const { currentPassword, newPassword } = request.body as {
currentPassword: string;
newPassword: string;
};
if (!currentPassword || !newPassword || newPassword.length < 8) {
throw AppError.badRequest('New password must be at least 8 characters');
}
const user = await app.db.query.users.findFirst({
where: eq(users.id, request.user.sub),
});
if (!user) {
throw AppError.notFound('User not found');
}
const isValid = await verifyPassword(user.passwordHash, currentPassword);
if (!isValid) {
throw AppError.unauthorized('Current password is incorrect', 'INVALID_PASSWORD');
}
const newHash = await hashPassword(newPassword);
await app.db
.update(users)
.set({ passwordHash: newHash, updatedAt: new Date() })
.where(eq(users.id, user.id));
return { success: true };
});
// GET /api/auth/me
app.get('/me', { onRequest: [app.authenticate] }, async (request) => {
const payload = request.user;
const user = await app.db.query.users.findFirst({
where: eq(users.id, payload.sub),
columns: {
id: true,
email: true,
username: true,
isSuperAdmin: true,
avatarUrl: true,
createdAt: true,
},
});
if (!user) {
throw AppError.notFound('User not found');
}
return { user };
});
}