u
This commit is contained in:
@@ -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=
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/api/routes/auth/selectedCompany.js
Normal file
40
src/api/routes/auth/selectedCompany.js
Normal 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
130
src/api/routes/company.js
Normal 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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
21
src/db/company.js
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user