This commit is contained in:
seb
2026-07-31 07:07:12 +02:00
parent db8b192094
commit 043305c04c
7 changed files with 219 additions and 10 deletions

View File

@@ -7,6 +7,7 @@ CORS_ORIGINS=http://localhost:5173
# Auth — Google OAuth client ID and JWT signing secret # Auth — Google OAuth client ID and JWT signing secret
GOOGLE_CLIENT_ID= GOOGLE_CLIENT_ID=
JWT_SECRET=fallback_secret_please_change_in_production JWT_SECRET=fallback_secret_please_change_in_production
IMPLICIT_CREATE=false
# Database — MSSQL (auth allowlist and JTL-Wawi article data) # Database — MSSQL (auth allowlist and JTL-Wawi article data)
MSSQL_SERVER= MSSQL_SERVER=

View File

@@ -13,7 +13,7 @@ const googleClient = new OAuth2Client(env.googleClientId);
export function register(app, basePath = '') { export function register(app, basePath = '') {
/** /**
* @route POST /auth/login * @route POST /auth/login
* @description Verifies a Google ID token, checks allowlist, and sets an HTTP-only `token` cookie. * @description Verifies a Google ID token, checks allowlist (or creates entry when `IMPLICIT_CREATE=true`), and sets an HTTP-only `token` cookie.
* @body {string} idToken - Google ID token from Google Identity Services * @body {string} idToken - Google ID token from Google Identity Services
* @returns {Object} 200 - `{ message, user }` with `{ id, email, selectedCompanyId, picture? }` in `user` * @returns {Object} 200 - `{ message, user }` with `{ id, email, selectedCompanyId, picture? }` in `user`
* @returns {Object} 400 - Missing token or Google client not configured * @returns {Object} 400 - Missing token or Google client not configured
@@ -32,10 +32,15 @@ export function register(app, basePath = '') {
return res.status(400).json({ error: 'GOOGLE_CLIENT_ID is not configured' }); return res.status(400).json({ error: 'GOOGLE_CLIENT_ID is not configured' });
} }
const ticket = await googleClient.verifyIdToken({ let ticket;
idToken, try {
audience: env.googleClientId, ticket = await googleClient.verifyIdToken({
}); idToken,
audience: env.googleClientId,
});
} catch {
return res.status(401).json({ error: 'Invalid Google token' });
}
const payload = ticket.getPayload(); const payload = ticket.getPayload();
const email = payload?.email?.toLowerCase(); const email = payload?.email?.toLowerCase();
@@ -43,7 +48,11 @@ export function register(app, basePath = '') {
return res.status(401).json({ error: 'Invalid Google account' }); return res.status(401).json({ error: 'Invalid Google account' });
} }
const allowed = await db.getAllowedEmail(email); let allowed = await db.getAllowedEmail(email);
if (!allowed && env.implicitCreate) {
await db.addAllowedEmail(email);
allowed = await db.getAllowedEmail(email);
}
if (!allowed) { if (!allowed) {
console.warn(`Login rejected: email not authorized (${email})`); console.warn(`Login rejected: email not authorized (${email})`);
return res.status(403).json({ error: 'Email is not authorized' }); return res.status(403).json({ error: 'Email is not authorized' });
@@ -66,7 +75,8 @@ export function register(app, basePath = '') {
res.status(200).json({ message: 'Login successful', user }); res.status(200).json({ message: 'Login successful', user });
} catch (error) { } catch (error) {
res.status(401).json({ error: 'Invalid Google token' }); console.error('Login failed:', error);
res.status(500).json({ error: 'Login failed' });
} }
}); });
} }

View File

@@ -0,0 +1,40 @@
import * as db from '../../../db/googleAuth.js';
import { clearCachedUser } from '../../middleware/auth.js';
/**
* Registers the selected-company route (requires auth middleware).
* @param {import('express').Application} app - Express app
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
*/
export function register(app, basePath = '') {
/**
* @route PUT /auth/selected-company
* @description Sets the authenticated user's selected company.
* @body {number} companyId
* @returns {Object} 200 - `{ user: { id, email, selectedCompanyId, picture? } }`
*/
app.put(`${basePath}/auth/selected-company`, async (req, res) => {
const companyId = Number(req.body?.companyId);
if (!Number.isInteger(companyId) || companyId <= 0) {
return res.status(400).json({ error: 'companyId is required' });
}
try {
await db.setSelectedCompany(req.user.id, companyId);
clearCachedUser(req.user.email);
const updated = await db.getAllowedEmail(req.user.email);
const user = {
id: updated.id,
email: updated.email,
selectedCompanyId: updated.selectedCompanyId,
...(req.user.picture && { picture: req.user.picture }),
};
res.status(200).json({ user });
} catch (error) {
console.error('Set selected company failed:', error);
res.status(500).json({ error: 'Failed to set selected company' });
}
});
}

130
src/api/routes/company.js Normal file
View File

@@ -0,0 +1,130 @@
import * as db from '../../db/company.js';
import * as googleAuth from '../../db/googleAuth.js';
import { clearCachedUser } from '../middleware/auth.js';
function userPayload(req, authRow) {
return {
id: authRow.id,
email: authRow.email,
selectedCompanyId: authRow.selectedCompanyId,
...(req.user.picture && { picture: req.user.picture }),
};
}
function parseCompanyBody(body) {
const { iso639, name, taxrate, reducedtaxrate } = body ?? {};
if (!iso639 || !name || taxrate == null || reducedtaxrate == null) {
return { error: 'iso639, name, taxrate, and reducedtaxrate are required' };
}
return { iso639, name, taxrate, reducedtaxrate };
}
function parseCompanyId(id) {
const companyId = Number(id);
if (!Number.isInteger(companyId) || companyId <= 0) {
return { error: 'Invalid company id' };
}
return { companyId };
}
function companyErrorStatus(error) {
if (error.message === 'Company not found') return 404;
if (error.message === 'Company name not available') return 409;
return 500;
}
/**
* Registers company CRUD routes (requires auth middleware).
* @param {import('express').Application} app - Express app
* @param {string} [basePath=''] - Optional URL prefix for all routes in this module
*/
export function register(app, basePath = '') {
/**
* @route GET /company
* @description Lists companies for the authenticated user.
* @returns {Object} 200 - `{ companies }`
*/
app.get(`${basePath}/company`, async (req, res) => {
try {
const companies = await db.listCompanies(req.user.id);
res.status(200).json({ companies });
} catch (error) {
console.error('List companies failed:', error);
res.status(500).json({ error: 'Failed to list companies' });
}
});
/**
* @route POST /company
* @description Creates a company.
* @body {string} iso639
* @body {string} name
* @body {number} taxrate
* @body {number} reducedtaxrate
* @returns {Object} 201 - `{ company }`
*/
app.post(`${basePath}/company`, async (req, res) => {
const body = parseCompanyBody(req.body);
if (body.error) {
return res.status(400).json({ error: body.error });
}
try {
const company = await db.createCompany(req.user.id, body);
await googleAuth.setSelectedCompany(req.user.id, company.id);
clearCachedUser(req.user.email);
const authRow = await googleAuth.getAllowedEmail(req.user.email);
res.status(201).json({ company, user: userPayload(req, authRow) });
} catch (error) {
const status = companyErrorStatus(error);
if (status === 500) console.error('Create company failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @route PUT /company/:id
* @description Updates a company.
* @returns {Object} 200 - `{ company }`
*/
app.put(`${basePath}/company/:id`, async (req, res) => {
const parsedId = parseCompanyId(req.params.id);
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
const body = parseCompanyBody(req.body);
if (body.error) {
return res.status(400).json({ error: body.error });
}
try {
const company = await db.updateCompany(parsedId.companyId, body);
res.status(200).json({ company });
} catch (error) {
const status = companyErrorStatus(error);
if (status === 500) console.error('Update company failed:', error);
res.status(status).json({ error: error.message });
}
});
/**
* @route DELETE /company/:id
* @description Deletes a company.
* @returns {Object} 204 - No content
*/
app.delete(`${basePath}/company/:id`, async (req, res) => {
const parsedId = parseCompanyId(req.params.id);
if (parsedId.error) {
return res.status(400).json({ error: parsedId.error });
}
try {
await db.deleteCompany(parsedId.companyId);
res.sendStatus(204);
} catch (error) {
console.error('Delete company failed:', error);
res.status(500).json({ error: 'Failed to delete company' });
}
});
}

View File

@@ -12,6 +12,7 @@ export default {
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean), .filter(Boolean),
googleClientId: process.env.GOOGLE_CLIENT_ID, googleClientId: process.env.GOOGLE_CLIENT_ID,
implicitCreate: process.env.IMPLICIT_CREATE === 'true',
jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production', jwtSecret: process.env.JWT_SECRET || 'fallback_secret_please_change_in_production',
mssql: { mssql: {
server: process.env.MSSQL_SERVER, server: process.env.MSSQL_SERVER,

21
src/db/company.js Normal file
View File

@@ -0,0 +1,21 @@
import { getStore } from './store.js';
export async function listCompanies(authId) {
const store = await getStore();
return store.company.list(authId);
}
export async function createCompany(authId, data) {
const store = await getStore();
return store.company.create({ ...data, authId });
}
export async function updateCompany(id, data) {
const store = await getStore();
return store.company.update(id, data);
}
export async function deleteCompany(companyId) {
const store = await getStore();
await store.company.delete(companyId);
}

View File

@@ -2,7 +2,7 @@ import { getStore } from './store.js';
export async function listAllowedEmails() { export async function listAllowedEmails() {
const store = await getStore(); const store = await getStore();
return store.google_auth.list(); return store.google_auth.listAllowedEmails();
} }
export async function getAllowedEmail(email) { export async function getAllowedEmail(email) {
@@ -19,9 +19,15 @@ export async function addAllowedEmail(email) {
} }
export async function removeAllowedEmail(email) { export async function removeAllowedEmail(email) {
const row = await getAllowedEmail(email); const normalized = email.trim().toLowerCase();
const row = await getAllowedEmail(normalized);
if (!row) return false; if (!row) return false;
const store = await getStore(); const store = await getStore();
await store.google_auth.del(row.id); await store.google_auth.removeAllowedEmail(normalized);
return true; return true;
} }
export async function setSelectedCompany(authId, companyId) {
const store = await getStore();
await store.google_auth.setSelectedCompany(authId, companyId);
}