c9fe2bd9fe
- 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>
230 lines
6.3 KiB
TypeScript
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 };
|
|
});
|
|
}
|